react-native-platform-components 1.6.0 → 1.7.0

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 (139) hide show
  1. package/README.md +19 -18
  2. package/android/src/main/java/com/platformcomponents/PCActionSheetView.kt +309 -0
  3. package/android/src/main/java/com/platformcomponents/PCActionSheetViewManager.kt +131 -0
  4. package/android/src/main/java/com/platformcomponents/PCButtonGroupView.kt +5 -1
  5. package/android/src/main/java/com/platformcomponents/PCButtonView.kt +5 -1
  6. package/android/src/main/java/com/platformcomponents/PCContextMenuView.kt +82 -27
  7. package/android/src/main/java/com/platformcomponents/PCContextMenuViewManager.kt +12 -13
  8. package/android/src/main/java/com/platformcomponents/PCMenuSupport.kt +66 -2
  9. package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +1 -0
  10. package/android/src/main/res/values/ids.xml +2 -0
  11. package/ios/PCActionSheet.h +12 -0
  12. package/ios/PCActionSheet.mm +160 -0
  13. package/ios/PCActionSheetView.swift +280 -0
  14. package/ios/PCContextMenu.mm +22 -1
  15. package/ios/PCContextMenu.swift +218 -15
  16. package/ios/PCMenuItems.h +2 -1
  17. package/ios/PCMenuSupport.swift +22 -3
  18. package/lib/commonjs/ActionSheet.js +85 -0
  19. package/lib/commonjs/ActionSheet.js.map +1 -0
  20. package/lib/commonjs/ActionSheetNativeComponent.ts +94 -0
  21. package/lib/commonjs/ButtonGroupNativeComponent.ts +2 -0
  22. package/lib/commonjs/ButtonNativeComponent.ts +2 -0
  23. package/lib/commonjs/ContextMenu.js +50 -4
  24. package/lib/commonjs/ContextMenu.js.map +1 -1
  25. package/lib/commonjs/ContextMenuNativeComponent.ts +29 -8
  26. package/lib/commonjs/index.js +11 -0
  27. package/lib/commonjs/index.js.map +1 -1
  28. package/lib/commonjs/index.web.js +7 -0
  29. package/lib/commonjs/index.web.js.map +1 -1
  30. package/lib/commonjs/jest.js +92 -5
  31. package/lib/commonjs/jest.js.map +1 -1
  32. package/lib/commonjs/menuItems.js +2 -1
  33. package/lib/commonjs/menuItems.js.map +1 -1
  34. package/lib/commonjs/selectionIdentifiers.js.map +1 -1
  35. package/lib/commonjs/web/ActionSheet.js +131 -0
  36. package/lib/commonjs/web/ActionSheet.js.map +1 -0
  37. package/lib/commonjs/web/components.js +3 -1
  38. package/lib/commonjs/web/components.js.map +1 -1
  39. package/lib/commonjs/web/containers.js +1 -0
  40. package/lib/commonjs/web/containers.js.map +1 -1
  41. package/lib/module/ActionSheet.js +78 -0
  42. package/lib/module/ActionSheet.js.map +1 -0
  43. package/lib/module/ActionSheetNativeComponent.ts +94 -0
  44. package/lib/module/ButtonGroupNativeComponent.ts +2 -0
  45. package/lib/module/ButtonNativeComponent.ts +2 -0
  46. package/lib/module/ContextMenu.js +51 -4
  47. package/lib/module/ContextMenu.js.map +1 -1
  48. package/lib/module/ContextMenuNativeComponent.ts +29 -8
  49. package/lib/module/index.js +1 -0
  50. package/lib/module/index.js.map +1 -1
  51. package/lib/module/index.web.js +1 -1
  52. package/lib/module/index.web.js.map +1 -1
  53. package/lib/module/jest.js +90 -5
  54. package/lib/module/jest.js.map +1 -1
  55. package/lib/module/menuItems.js +2 -1
  56. package/lib/module/menuItems.js.map +1 -1
  57. package/lib/module/selectionIdentifiers.js.map +1 -1
  58. package/lib/module/web/ActionSheet.js +126 -0
  59. package/lib/module/web/ActionSheet.js.map +1 -0
  60. package/lib/module/web/components.js +2 -0
  61. package/lib/module/web/components.js.map +1 -1
  62. package/lib/module/web/containers.js +1 -0
  63. package/lib/module/web/containers.js.map +1 -1
  64. package/lib/typescript/commonjs/docs/sidebars.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/src/ActionSheet.d.ts +98 -0
  66. package/lib/typescript/commonjs/src/ActionSheet.d.ts.map +1 -0
  67. package/lib/typescript/commonjs/src/ActionSheetNativeComponent.d.ts +78 -0
  68. package/lib/typescript/commonjs/src/ActionSheetNativeComponent.d.ts.map +1 -0
  69. package/lib/typescript/commonjs/src/ButtonGroupNativeComponent.d.ts +2 -0
  70. package/lib/typescript/commonjs/src/ButtonGroupNativeComponent.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/src/ButtonNativeComponent.d.ts +2 -0
  72. package/lib/typescript/commonjs/src/ButtonNativeComponent.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/src/ContextMenu.d.ts +22 -6
  74. package/lib/typescript/commonjs/src/ContextMenu.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts +20 -7
  76. package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/src/index.d.ts +1 -0
  78. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/src/index.web.d.ts +2 -1
  80. package/lib/typescript/commonjs/src/index.web.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/src/jest.d.ts +17 -3
  82. package/lib/typescript/commonjs/src/jest.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/src/menuItems.d.ts +12 -0
  84. package/lib/typescript/commonjs/src/menuItems.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/src/selectionIdentifiers.d.ts +1 -1
  86. package/lib/typescript/commonjs/src/selectionIdentifiers.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/src/web/ActionSheet.d.ts +11 -0
  88. package/lib/typescript/commonjs/src/web/ActionSheet.d.ts.map +1 -0
  89. package/lib/typescript/commonjs/src/web/components.d.ts +1 -0
  90. package/lib/typescript/commonjs/src/web/components.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/src/web/containers.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/src/webComponents.d.ts +2 -0
  93. package/lib/typescript/commonjs/src/webComponents.d.ts.map +1 -1
  94. package/lib/typescript/module/docs/sidebars.d.ts.map +1 -1
  95. package/lib/typescript/module/src/ActionSheet.d.ts +98 -0
  96. package/lib/typescript/module/src/ActionSheet.d.ts.map +1 -0
  97. package/lib/typescript/module/src/ActionSheetNativeComponent.d.ts +78 -0
  98. package/lib/typescript/module/src/ActionSheetNativeComponent.d.ts.map +1 -0
  99. package/lib/typescript/module/src/ButtonGroupNativeComponent.d.ts +2 -0
  100. package/lib/typescript/module/src/ButtonGroupNativeComponent.d.ts.map +1 -1
  101. package/lib/typescript/module/src/ButtonNativeComponent.d.ts +2 -0
  102. package/lib/typescript/module/src/ButtonNativeComponent.d.ts.map +1 -1
  103. package/lib/typescript/module/src/ContextMenu.d.ts +22 -6
  104. package/lib/typescript/module/src/ContextMenu.d.ts.map +1 -1
  105. package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts +20 -7
  106. package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts.map +1 -1
  107. package/lib/typescript/module/src/index.d.ts +1 -0
  108. package/lib/typescript/module/src/index.d.ts.map +1 -1
  109. package/lib/typescript/module/src/index.web.d.ts +2 -1
  110. package/lib/typescript/module/src/index.web.d.ts.map +1 -1
  111. package/lib/typescript/module/src/jest.d.ts +17 -3
  112. package/lib/typescript/module/src/jest.d.ts.map +1 -1
  113. package/lib/typescript/module/src/menuItems.d.ts +12 -0
  114. package/lib/typescript/module/src/menuItems.d.ts.map +1 -1
  115. package/lib/typescript/module/src/selectionIdentifiers.d.ts +1 -1
  116. package/lib/typescript/module/src/selectionIdentifiers.d.ts.map +1 -1
  117. package/lib/typescript/module/src/web/ActionSheet.d.ts +11 -0
  118. package/lib/typescript/module/src/web/ActionSheet.d.ts.map +1 -0
  119. package/lib/typescript/module/src/web/components.d.ts +1 -0
  120. package/lib/typescript/module/src/web/components.d.ts.map +1 -1
  121. package/lib/typescript/module/src/web/containers.d.ts.map +1 -1
  122. package/lib/typescript/module/src/webComponents.d.ts +2 -0
  123. package/lib/typescript/module/src/webComponents.d.ts.map +1 -1
  124. package/package.json +9 -2
  125. package/src/ActionSheet.tsx +193 -0
  126. package/src/ActionSheetNativeComponent.ts +94 -0
  127. package/src/ButtonGroupNativeComponent.ts +2 -0
  128. package/src/ButtonNativeComponent.ts +2 -0
  129. package/src/ContextMenu.tsx +71 -12
  130. package/src/ContextMenuNativeComponent.ts +29 -8
  131. package/src/index.tsx +1 -0
  132. package/src/index.web.tsx +2 -0
  133. package/src/jest.tsx +108 -4
  134. package/src/menuItems.ts +13 -0
  135. package/src/selectionIdentifiers.ts +1 -1
  136. package/src/web/ActionSheet.tsx +130 -0
  137. package/src/web/components.tsx +5 -0
  138. package/src/web/containers.tsx +1 -0
  139. package/src/webComponents.ts +2 -0
@@ -7,7 +7,10 @@ import android.view.Gravity
7
7
  import android.view.HapticFeedbackConstants
8
8
  import android.view.MotionEvent
9
9
  import android.view.ViewConfiguration
10
+ import android.view.ViewGroup
10
11
  import androidx.appcompat.widget.PopupMenu
12
+ import com.facebook.react.uimanager.RootView
13
+ import com.facebook.react.uimanager.events.NativeGestureUtil
11
14
  import com.facebook.react.views.view.ReactViewGroup
12
15
 
13
16
  class PCContextMenuView(context: Context) : ReactViewGroup(context) {
@@ -21,8 +24,8 @@ class PCContextMenuView(context: Context) : ReactViewGroup(context) {
21
24
  /** The flattened menu items (see PCMenuSupport) */
22
25
  var actions: List<PCMenuSupport.Item> = emptyList()
23
26
  var interactivity: String = "enabled" // "enabled" | "disabled"
24
- var trigger: String = "longPress" // "longPress" | "tap"
25
- var androidVisible: String = "closed" // "open" | "closed" (Android-only programmatic)
27
+ var trigger: String = "longPress" // "longPress" | "tap" | "none"
28
+ var visible: String = "closed" // "open" | "closed" (programmatic)
26
29
  var androidAnchorPosition: String? = "left" // "left" | "right"
27
30
 
28
31
  /** The `haptics` prop, played when an action is picked (an action's own `haptics` wins). */
@@ -42,8 +45,12 @@ class PCContextMenuView(context: Context) : ReactViewGroup(context) {
42
45
 
43
46
  // Long-press detection
44
47
  private var longPressRunnable: Runnable? = null
48
+ /** A long press opened the menu and cancelled the touch in JS. */
49
+ private var nativeGestureStarted = false
45
50
  private var touchDownX = 0f
46
51
  private var touchDownY = 0f
52
+ /** Tap mode: the last touch went down on a native control (see handleTap). */
53
+ private var downOnNativeControl = false
47
54
  private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
48
55
  private val longPressTimeout = ViewConfiguration.getLongPressTimeout().toLong()
49
56
 
@@ -51,8 +58,8 @@ class PCContextMenuView(context: Context) : ReactViewGroup(context) {
51
58
  // Enable long-click for longPress mode
52
59
  isLongClickable = true
53
60
  setOnLongClickListener { handleLongClick() }
54
- // Enable click for tap mode
55
- setOnClickListener { handleTap() }
61
+ // Enable click for tap mode (accessibility services; touches go through JS)
62
+ setOnClickListener { openTapMenu() }
56
63
  }
57
64
 
58
65
  override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
@@ -64,6 +71,7 @@ class PCContextMenuView(context: Context) : ReactViewGroup(context) {
64
71
  MotionEvent.ACTION_DOWN -> {
65
72
  touchDownX = ev.x
66
73
  touchDownY = ev.y
74
+ downOnNativeControl = trigger == "tap" && isOnNativeControl(this, ev.x, ev.y)
67
75
  // In longPress mode, schedule long-press detection
68
76
  if (trigger == "longPress") {
69
77
  // Dispatch recycles MotionEvent instances before this callback runs.
@@ -76,12 +84,18 @@ class PCContextMenuView(context: Context) : ReactViewGroup(context) {
76
84
  // The long-press haptic View.performLongClick plays, which this
77
85
  // path bypasses; off with haptics "none" (isHapticFeedbackEnabled)
78
86
  if (popupShowing) performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
79
- // Cancel any pending touch events on children
87
+ // Cancel any pending touch events on children, and the touch in
88
+ // JS, so a pressable under the finger doesn't fire onPress when
89
+ // it lifts (as a ScrollView does when it starts scrolling)
80
90
  val cancel = MotionEvent.obtain(
81
91
  downTime, SystemClock.uptimeMillis(),
82
92
  MotionEvent.ACTION_CANCEL, downX, downY, 0
83
93
  )
84
94
  super.dispatchTouchEvent(cancel)
95
+ if (hasReactRoot()) {
96
+ NativeGestureUtil.notifyNativeGestureStarted(this, cancel)
97
+ nativeGestureStarted = true
98
+ }
85
99
  cancel.recycle()
86
100
  }
87
101
  }
@@ -95,24 +109,36 @@ class PCContextMenuView(context: Context) : ReactViewGroup(context) {
95
109
  cancelPendingLongPress()
96
110
  }
97
111
  }
98
- MotionEvent.ACTION_UP -> {
99
- cancelPendingLongPress()
100
- // In tap mode, handle tap on ACTION_UP if within slop
101
- if (trigger == "tap" &&
102
- Math.abs(ev.x - touchDownX) <= touchSlop &&
103
- Math.abs(ev.y - touchDownY) <= touchSlop) {
104
- handleTap()
105
- return true // Consume the event
106
- }
107
- }
108
- MotionEvent.ACTION_CANCEL -> {
112
+ // Tap mode opens the menu from JS (handleTap), for taps that no inner
113
+ // pressable took
114
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
109
115
  cancelPendingLongPress()
116
+ endNativeGesture(ev)
110
117
  }
111
118
  }
112
119
 
113
120
  return super.onInterceptTouchEvent(ev)
114
121
  }
115
122
 
123
+ private fun endNativeGesture(ev: MotionEvent) {
124
+ if (!nativeGestureStarted) return
125
+ nativeGestureStarted = false
126
+ if (hasReactRoot()) NativeGestureUtil.notifyNativeGestureEnded(this, ev)
127
+ }
128
+
129
+ /**
130
+ * Whether a React root (a RootView) is above this view. NativeGestureUtil
131
+ * asserts on one, and crashes for a view hosted outside React Native.
132
+ */
133
+ private fun hasReactRoot(): Boolean {
134
+ var parent = parent
135
+ while (parent != null) {
136
+ if (parent is RootView) return true
137
+ parent = parent.parent
138
+ }
139
+ return false
140
+ }
141
+
116
142
  private fun cancelPendingLongPress() {
117
143
  longPressRunnable?.let {
118
144
  removeCallbacks(it)
@@ -128,13 +154,39 @@ class PCContextMenuView(context: Context) : ReactViewGroup(context) {
128
154
  return true
129
155
  }
130
156
 
131
- private fun handleTap() {
157
+ /**
158
+ * Tap mode: JS calls this for a tap on the content that no inner pressable
159
+ * took (React Native's responder system knows: the deepest responder wins).
160
+ */
161
+ fun handleTap() {
162
+ // A native control (this library's Button) handles its own taps,
163
+ // outside the JS responder system
164
+ if (downOnNativeControl) return
165
+ openTapMenu()
166
+ }
167
+
168
+ /** Tap mode: JS taps (handleTap), and TalkBack through the click listener. */
169
+ private fun openTapMenu() {
132
170
  if (trigger != "tap") return
133
171
  if (interactivity != "enabled") return
134
172
 
135
173
  showPopupMenu()
136
174
  }
137
175
 
176
+ /** Whether (x, y) of `group` lands on a descendant with its own click listener. */
177
+ private fun isOnNativeControl(group: ViewGroup, x: Float, y: Float): Boolean {
178
+ for (i in group.childCount - 1 downTo 0) {
179
+ val child = group.getChildAt(i)
180
+ if (child.visibility != VISIBLE) continue
181
+ val childX = x + group.scrollX - child.left - child.translationX
182
+ val childY = y + group.scrollY - child.top - child.translationY
183
+ if (childX < 0 || childY < 0 || childX >= child.width || childY >= child.height) continue
184
+ if (child.isEnabled && child.hasOnClickListeners()) return true
185
+ return child is ViewGroup && isOnNativeControl(child, childX, childY)
186
+ }
187
+ return false
188
+ }
189
+
138
190
  // ---- Public apply* (called by manager) ----
139
191
 
140
192
  fun applyMenuTitle(value: String?) {
@@ -161,23 +213,22 @@ class PCContextMenuView(context: Context) : ReactViewGroup(context) {
161
213
 
162
214
  fun applyTrigger(value: String?) {
163
215
  trigger = when (value) {
164
- "longPress", "tap" -> value
216
+ // "none": only `visible` opens the menu; no touch is intercepted
217
+ "longPress", "tap", "none" -> value
165
218
  else -> "longPress"
166
219
  }
167
220
  Log.d(TAG, "applyTrigger trigger=$trigger")
168
221
  updateTriggerState()
169
222
  }
170
223
 
171
- fun applyAndroidVisible(value: String?) {
172
- androidVisible = when (value) {
173
- "open", "closed" -> value
174
- else -> "closed"
175
- }
176
- Log.d(TAG, "applyAndroidVisible androidVisible=$androidVisible")
224
+ /** Called only when the prop changes, so a menu the user opened stays open. */
225
+ fun applyVisible(value: String?) {
226
+ visible = if (value == "open") "open" else "closed"
227
+ Log.d(TAG, "applyVisible visible=$visible")
177
228
  openToken += 1
178
229
  val token = openToken
179
230
 
180
- if (androidVisible == "open") {
231
+ if (visible == "open") {
181
232
  presentIfNeeded(token)
182
233
  } else {
183
234
  if (popupShowing) {
@@ -217,7 +268,7 @@ class PCContextMenuView(context: Context) : ReactViewGroup(context) {
217
268
  Log.d(TAG, "presentIfNeeded stale token -> skip")
218
269
  return@post
219
270
  }
220
- if (androidVisible != "open") {
271
+ if (visible != "open") {
221
272
  Log.d(TAG, "presentIfNeeded no longer open -> skip")
222
273
  return@post
223
274
  }
@@ -257,7 +308,10 @@ class PCContextMenuView(context: Context) : ReactViewGroup(context) {
257
308
  popup.setOnMenuItemClickListener { menuItem ->
258
309
  // Submenu headers open their submenu; they aren't actions.
259
310
  val item = PCMenuSupport.itemFor(menuItem, items)
260
- if (item == null || !item.isAction) return@setOnMenuItemClickListener false
311
+ if (item == null || !item.isAction) {
312
+ post { PCMenuSupport.tagPopupRows(popup.menu, items) }
313
+ return@setOnMenuItemClickListener false
314
+ }
261
315
  Log.d(TAG, "popup onMenuItemClick")
262
316
  dismissAfterSelect = true
263
317
  PCHaptics.perform(this, item.haptics.ifEmpty { haptics })
@@ -285,6 +339,7 @@ class PCContextMenuView(context: Context) : ReactViewGroup(context) {
285
339
 
286
340
  onMenuOpen?.invoke()
287
341
  popup.show()
342
+ PCMenuSupport.tagPopupRows(popup.menu, items)
288
343
  }
289
344
 
290
345
  override fun onDetachedFromWindow() {
@@ -64,6 +64,11 @@ class PCContextMenuViewManager :
64
64
  view.applyTrigger(value)
65
65
  }
66
66
 
67
+ // "open" | "closed" | "" (unset, treated as closed)
68
+ override fun setVisible(view: PCContextMenuView, value: String?) {
69
+ view.applyVisible(value)
70
+ }
71
+
67
72
  override fun setIos(view: PCContextMenuView, value: ReadableMap?) {
68
73
  // Android ignores iOS config
69
74
  }
@@ -74,25 +79,19 @@ class PCContextMenuViewManager :
74
79
  }
75
80
 
76
81
  override fun setAndroid(view: PCContextMenuView, value: ReadableMap?) {
77
- if (value == null) {
78
- view.applyAndroidAnchorPosition(null)
79
- view.applyAndroidVisible(null)
80
- return
81
- }
82
-
83
- val anchorPosition = if (value.hasKey("anchorPosition") && !value.isNull("anchorPosition")) {
82
+ val anchorPosition = if (value != null && value.hasKey("anchorPosition") && !value.isNull("anchorPosition")) {
84
83
  value.getString("anchorPosition")
85
84
  } else {
86
85
  null
87
86
  }
88
87
  view.applyAndroidAnchorPosition(anchorPosition)
88
+ }
89
89
 
90
- val visible = if (value.hasKey("visible") && !value.isNull("visible")) {
91
- value.getString("visible")
92
- } else {
93
- null
94
- }
95
- view.applyAndroidVisible(visible)
90
+ // --- Commands ---
91
+
92
+ // trigger "tap": a tap on the content that no inner pressable took
93
+ override fun handleTap(view: PCContextMenuView) {
94
+ view.handleTap()
96
95
  }
97
96
 
98
97
  // --- ViewGroupManager child management ---
@@ -5,6 +5,7 @@ import android.graphics.Rect
5
5
  import android.graphics.drawable.BitmapDrawable
6
6
  import android.graphics.drawable.Drawable
7
7
  import android.graphics.drawable.DrawableWrapper
8
+ import android.os.Build
8
9
  import android.text.SpannableString
9
10
  import android.text.Spanned
10
11
  import android.text.style.ForegroundColorSpan
@@ -12,6 +13,12 @@ import android.util.Log
12
13
  import android.util.TypedValue
13
14
  import android.view.Menu
14
15
  import android.view.MenuItem
16
+ import android.view.View
17
+ import android.view.ViewGroup
18
+ import android.view.ViewTreeObserver
19
+ import android.view.WindowManager
20
+ import android.view.inspector.WindowInspector
21
+ import android.widget.ListView
15
22
  import androidx.appcompat.R as AppCompatR
16
23
  import androidx.core.content.ContextCompat
17
24
  import androidx.core.graphics.drawable.DrawableCompat
@@ -57,7 +64,9 @@ object PCMenuSupport {
57
64
  /** "" | "off" | "on" | "mixed" */
58
65
  val state: String,
59
66
  /** The item's own `haptics`; "" = the component's */
60
- val haptics: String = ""
67
+ val haptics: String = "",
68
+ /** Tag of the item's popup row, for UI tests; "" = none */
69
+ val testID: String = ""
61
70
  ) {
62
71
  val isAction: Boolean get() = kind != "menu" && kind != "section"
63
72
  }
@@ -82,7 +91,8 @@ object PCMenuSupport {
82
91
  disabled = map.stringOr("disabled", "false") == "true",
83
92
  keepsMenuPresented = map.stringOr("keepsMenuPresented", "false") == "true",
84
93
  state = map.stringOr("state", ""),
85
- haptics = map.stringOr("haptics", "")
94
+ haptics = map.stringOr("haptics", ""),
95
+ testID = map.stringOr("testID", "")
86
96
  )
87
97
  )
88
98
  }
@@ -165,6 +175,60 @@ object PCMenuSupport {
165
175
  /** The item behind a clicked MenuItem. */
166
176
  fun itemFor(menuItem: MenuItem, items: List<Item>): Item? = items.getOrNull(menuItem.itemId)
167
177
 
178
+ /**
179
+ * Tags the rows of [menu]'s showing popups with their items' testIDs:
180
+ * Detox and Espresso match `by.id` against the view tag. AppCompat builds
181
+ * the rows in the popup's own window and keeps its list private, so the
182
+ * list is found among the app's windows (WindowInspector, Android 10+;
183
+ * earlier versions leave the rows untagged). Call it after the popup shows,
184
+ * and after a submenu header is picked: the submenu opens in a new popup.
185
+ */
186
+ fun tagPopupRows(menu: Menu, items: List<Item>) {
187
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
188
+ if (items.none { it.testID.isNotEmpty() }) return
189
+ for (root in WindowInspector.getGlobalWindowViews()) {
190
+ // Popups are sub-windows of the activity's window
191
+ val type = (root.layoutParams as? WindowManager.LayoutParams)?.type ?: continue
192
+ if (type < WindowManager.LayoutParams.FIRST_SUB_WINDOW ||
193
+ type > WindowManager.LayoutParams.LAST_SUB_WINDOW) continue
194
+ forEachListView(root) { list -> watchRows(list, menu, items) }
195
+ }
196
+ }
197
+
198
+ private fun forEachListView(view: View, action: (ListView) -> Unit) {
199
+ if (view is ListView) {
200
+ action(view)
201
+ } else if (view is ViewGroup) {
202
+ for (i in 0 until view.childCount) forEachListView(view.getChildAt(i), action)
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Tags [list]'s rows if it lists [menu]'s items, and again before every
208
+ * frame: the list binds rows to items as it lays out and scrolls.
209
+ */
210
+ private fun watchRows(list: ListView, menu: Menu, items: List<Item>) {
211
+ if (list.getTag(R.id.pc_menu_rows_tagged) != null) return
212
+ val first = (0 until list.count).firstNotNullOfOrNull { list.getItemAtPosition(it) as? MenuItem }
213
+ if (first == null || menu.findItem(first.itemId) !== first) return
214
+
215
+ fun tagRows() {
216
+ for (i in 0 until list.childCount) {
217
+ val menuItem = list.getItemAtPosition(list.firstVisiblePosition + i) as? MenuItem
218
+ val testID = menuItem?.let { itemFor(it, items)?.testID }.orEmpty()
219
+ list.getChildAt(i).tag = testID.ifEmpty { null }
220
+ }
221
+ }
222
+ list.setTag(R.id.pc_menu_rows_tagged, true)
223
+ list.viewTreeObserver.addOnPreDrawListener(
224
+ ViewTreeObserver.OnPreDrawListener {
225
+ tagRows()
226
+ true
227
+ }
228
+ )
229
+ tagRows()
230
+ }
231
+
168
232
  /** Destructive titles are drawn in the theme's error color. */
169
233
  private fun title(context: Context, item: Item): CharSequence {
170
234
  if (!item.destructive) return item.title
@@ -15,6 +15,7 @@ class PlatformComponentsViewPackage : BaseReactPackage() {
15
15
  PCSelectionMenuViewManager(),
16
16
  PCDatePickerViewManager(),
17
17
  PCContextMenuViewManager(),
18
+ PCActionSheetViewManager(),
18
19
  PCSegmentedControlViewManager(),
19
20
  PCButtonViewManager(),
20
21
  PCButtonGroupViewManager(),
@@ -2,4 +2,6 @@
2
2
  <resources>
3
3
  <!-- The text size a view was capped at (maxFontSizeMultiplier); see PCThemeSupport.capTextSize -->
4
4
  <item name="pc_capped_text_size" type="id" />
5
+ <!-- A menu popup's list whose rows carry their items' testIDs; see PCMenuSupport.tagPopupRows -->
6
+ <item name="pc_menu_rows_tagged" type="id" />
5
7
  </resources>
@@ -0,0 +1,12 @@
1
+ // PCActionSheet.h
2
+
3
+ #import <React/RCTViewComponentView.h>
4
+ #import <UIKit/UIKit.h>
5
+
6
+ NS_ASSUME_NONNULL_BEGIN
7
+
8
+ @interface PCActionSheet : RCTViewComponentView
9
+
10
+ @end
11
+
12
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,160 @@
1
+ // PCActionSheet.mm
2
+
3
+ #import "PCActionSheet.h"
4
+
5
+ #import <React/RCTComponentViewFactory.h>
6
+ #import <React/RCTConversions.h>
7
+ #import <React/RCTFabricComponentsPlugins.h>
8
+
9
+ #import <react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h>
10
+ #import <react/renderer/components/PlatformComponentsViewSpec/EventEmitters.h>
11
+ #import <react/renderer/components/PlatformComponentsViewSpec/Props.h>
12
+
13
+ #if __has_include(<PlatformComponents/PlatformComponents-Swift.h>)
14
+ #import <PlatformComponents/PlatformComponents-Swift.h>
15
+ #else
16
+ #import "PlatformComponents-Swift.h"
17
+ #endif
18
+
19
+ using namespace facebook::react;
20
+
21
+ namespace {
22
+ static inline NSString *ToNSString(const std::string &value) {
23
+ return value.empty() ? @"" : [NSString stringWithUTF8String:value.c_str()];
24
+ }
25
+
26
+ static inline bool ActionsEqual(
27
+ const std::vector<PCActionSheetActionsStruct> &a,
28
+ const std::vector<PCActionSheetActionsStruct> &b) {
29
+ if (a.size() != b.size()) return false;
30
+ for (size_t i = 0; i < a.size(); i++) {
31
+ const auto &x = a[i];
32
+ const auto &y = b[i];
33
+ if (x.id != y.id || x.title != y.title || x.iconType != y.iconType ||
34
+ x.iconName != y.iconName || x.iconRequest != y.iconRequest || x.iconUri != y.iconUri ||
35
+ x.iconScale != y.iconScale || x.iconTinted != y.iconTinted ||
36
+ x.imageColor != y.imageColor || x.destructive != y.destructive ||
37
+ x.disabled != y.disabled || x.haptics != y.haptics || x.testID != y.testID) {
38
+ return false;
39
+ }
40
+ }
41
+ return true;
42
+ }
43
+ } // namespace
44
+
45
+ @implementation PCActionSheet {
46
+ PCActionSheetView *_view;
47
+ }
48
+
49
+ + (ComponentDescriptorProvider)componentDescriptorProvider {
50
+ return concreteComponentDescriptorProvider<PCActionSheetComponentDescriptor>();
51
+ }
52
+
53
+ - (instancetype)initWithFrame:(CGRect)frame {
54
+ if (self = [super initWithFrame:frame]) {
55
+ // Fabric views seed _props with their own defaults: RCTViewComponentView
56
+ // asserts on it in debug builds of React Native core, and the first
57
+ // updateProps would otherwise read the base ViewProps as PCActionSheetProps.
58
+ static const auto defaultProps = std::make_shared<const PCActionSheetProps>();
59
+ _props = defaultProps;
60
+
61
+ // Covers the component's frame without taking touches; the children
62
+ // mount next to it, and the sheet's popover points at them.
63
+ _view = [PCActionSheetView new];
64
+ self.contentView = _view;
65
+
66
+ __weak __typeof(self) weakSelf = self;
67
+
68
+ _view.onSelect = ^(NSString *actionId) {
69
+ __typeof(self) strongSelf = weakSelf;
70
+ if (!strongSelf) return;
71
+
72
+ auto eventEmitter =
73
+ std::static_pointer_cast<const PCActionSheetEventEmitter>(
74
+ strongSelf->_eventEmitter);
75
+ if (!eventEmitter) return;
76
+
77
+ PCActionSheetEventEmitter::OnSelect payload = {
78
+ .actionId = actionId.UTF8String,
79
+ };
80
+
81
+ eventEmitter->onSelect(payload);
82
+ };
83
+
84
+ _view.onCancel = ^{
85
+ __typeof(self) strongSelf = weakSelf;
86
+ if (!strongSelf) return;
87
+
88
+ auto eventEmitter =
89
+ std::static_pointer_cast<const PCActionSheetEventEmitter>(
90
+ strongSelf->_eventEmitter);
91
+ if (!eventEmitter) return;
92
+
93
+ eventEmitter->onCancel({});
94
+ };
95
+ }
96
+ return self;
97
+ }
98
+
99
+ - (void)updateProps:(Props::Shared const &)props
100
+ oldProps:(Props::Shared const &)oldProps {
101
+ const auto &newProps =
102
+ *std::static_pointer_cast<const PCActionSheetProps>(props);
103
+ const auto prevProps =
104
+ std::static_pointer_cast<const PCActionSheetProps>(oldProps);
105
+
106
+ if (!prevProps || newProps.title != prevProps->title) {
107
+ _view.sheetTitle = ToNSString(newProps.title);
108
+ }
109
+
110
+ if (!prevProps || newProps.message != prevProps->message) {
111
+ _view.message = ToNSString(newProps.message);
112
+ }
113
+
114
+ // actions: [{id, title, icon fields, imageColor, destructive, disabled, haptics, testID}]
115
+ if (!prevProps || !ActionsEqual(newProps.actions, prevProps->actions)) {
116
+ NSMutableArray *arr = [NSMutableArray arrayWithCapacity:newProps.actions.size()];
117
+ for (const auto &action : newProps.actions) {
118
+ [arr addObject:@{
119
+ @"id" : ToNSString(action.id),
120
+ @"title" : ToNSString(action.title),
121
+ @"destructive" : ToNSString(action.destructive),
122
+ @"disabled" : ToNSString(action.disabled),
123
+ @"haptics" : ToNSString(action.haptics),
124
+ @"testID" : ToNSString(action.testID),
125
+ }];
126
+ }
127
+ _view.actions = arr;
128
+ }
129
+
130
+ if (!prevProps || newProps.cancelTitle != prevProps->cancelTitle) {
131
+ _view.cancelTitle = ToNSString(newProps.cancelTitle);
132
+ }
133
+
134
+ if (!prevProps || newProps.cancelTestID != prevProps->cancelTestID) {
135
+ _view.cancelTestID = ToNSString(newProps.cancelTestID);
136
+ }
137
+
138
+ if (!prevProps || newProps.haptics != prevProps->haptics) {
139
+ _view.haptics.kind = ToNSString(newProps.haptics);
140
+ }
141
+
142
+ // visible last, so a sheet opened by this update has the new actions.
143
+ // "open" | "closed"
144
+ if (!prevProps || newProps.visible != prevProps->visible) {
145
+ _view.visible = newProps.visible.empty() ? @"closed" : ToNSString(newProps.visible);
146
+ }
147
+
148
+ [super updateProps:props oldProps:oldProps];
149
+ }
150
+
151
+ - (void)prepareForRecycle {
152
+ [super prepareForRecycle];
153
+ [_view resetForRecycle];
154
+ }
155
+
156
+ @end
157
+
158
+ Class<RCTComponentViewProtocol> PCActionSheetCls(void) {
159
+ return PCActionSheet.class;
160
+ }