react-native-platform-components 0.5.5 → 0.6.1

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 (78) hide show
  1. package/README.md +342 -72
  2. package/android/src/main/java/com/platformcomponents/PCContextMenuView.kt +419 -0
  3. package/android/src/main/java/com/platformcomponents/PCContextMenuViewManager.kt +200 -0
  4. package/android/src/main/java/com/platformcomponents/PCSelectionMenuView.kt +4 -0
  5. package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +1 -0
  6. package/app.plugin.cjs +4 -0
  7. package/expo-module.config.json +4 -0
  8. package/ios/PCContextMenu.h +12 -0
  9. package/ios/PCContextMenu.mm +247 -0
  10. package/ios/PCContextMenu.swift +389 -0
  11. package/ios/PCDatePickerView.swift +25 -11
  12. package/lib/commonjs/ContextMenu.js +118 -0
  13. package/lib/commonjs/ContextMenu.js.map +1 -0
  14. package/lib/commonjs/ContextMenuNativeComponent.ts +141 -0
  15. package/lib/commonjs/DatePicker.js +86 -0
  16. package/lib/commonjs/DatePicker.js.map +1 -0
  17. package/lib/commonjs/DatePickerNativeComponent.ts +69 -0
  18. package/lib/commonjs/SelectionMenu.js +73 -0
  19. package/lib/commonjs/SelectionMenu.js.map +1 -0
  20. package/lib/commonjs/SelectionMenuNativeComponent.ts +97 -0
  21. package/lib/commonjs/index.js +50 -0
  22. package/lib/commonjs/index.js.map +1 -0
  23. package/lib/commonjs/package.json +1 -0
  24. package/lib/commonjs/sharedTypes.js +6 -0
  25. package/lib/commonjs/sharedTypes.js.map +1 -0
  26. package/lib/module/ContextMenu.js +111 -0
  27. package/lib/module/ContextMenu.js.map +1 -0
  28. package/lib/module/ContextMenuNativeComponent.ts +141 -0
  29. package/lib/module/index.js +1 -0
  30. package/lib/module/index.js.map +1 -1
  31. package/lib/typescript/commonjs/package.json +1 -0
  32. package/lib/typescript/commonjs/src/ContextMenu.d.ts +79 -0
  33. package/lib/typescript/commonjs/src/ContextMenu.d.ts.map +1 -0
  34. package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts +122 -0
  35. package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts.map +1 -0
  36. package/lib/typescript/commonjs/src/DatePicker.d.ts.map +1 -0
  37. package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts.map +1 -0
  38. package/lib/typescript/commonjs/src/SelectionMenu.d.ts.map +1 -0
  39. package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts.map +1 -0
  40. package/lib/typescript/{src → commonjs/src}/index.d.ts +1 -0
  41. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  42. package/lib/typescript/commonjs/src/sharedTypes.d.ts.map +1 -0
  43. package/lib/typescript/module/src/ContextMenu.d.ts +79 -0
  44. package/lib/typescript/module/src/ContextMenu.d.ts.map +1 -0
  45. package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts +122 -0
  46. package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts.map +1 -0
  47. package/lib/typescript/module/src/DatePicker.d.ts +40 -0
  48. package/lib/typescript/module/src/DatePicker.d.ts.map +1 -0
  49. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts +54 -0
  50. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts.map +1 -0
  51. package/lib/typescript/module/src/SelectionMenu.d.ts +47 -0
  52. package/lib/typescript/module/src/SelectionMenu.d.ts.map +1 -0
  53. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts +78 -0
  54. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts.map +1 -0
  55. package/lib/typescript/module/src/index.d.ts +5 -0
  56. package/lib/typescript/module/src/index.d.ts.map +1 -0
  57. package/lib/typescript/module/src/sharedTypes.d.ts +12 -0
  58. package/lib/typescript/module/src/sharedTypes.d.ts.map +1 -0
  59. package/package.json +32 -11
  60. package/plugin/build/index.cjs +26 -0
  61. package/plugin/build/index.d.ts +22 -0
  62. package/plugin/build/index.d.ts.map +1 -0
  63. package/plugin/tsconfig.json +16 -0
  64. package/src/ContextMenu.tsx +209 -0
  65. package/src/ContextMenuNativeComponent.ts +141 -0
  66. package/src/index.tsx +1 -0
  67. package/lib/typescript/src/DatePicker.d.ts.map +0 -1
  68. package/lib/typescript/src/DatePickerNativeComponent.d.ts.map +0 -1
  69. package/lib/typescript/src/SelectionMenu.d.ts.map +0 -1
  70. package/lib/typescript/src/SelectionMenuNativeComponent.d.ts.map +0 -1
  71. package/lib/typescript/src/index.d.ts.map +0 -1
  72. package/lib/typescript/src/sharedTypes.d.ts.map +0 -1
  73. /package/lib/typescript/{src → commonjs/src}/DatePicker.d.ts +0 -0
  74. /package/lib/typescript/{src → commonjs/src}/DatePickerNativeComponent.d.ts +0 -0
  75. /package/lib/typescript/{src → commonjs/src}/SelectionMenu.d.ts +0 -0
  76. /package/lib/typescript/{src → commonjs/src}/SelectionMenuNativeComponent.d.ts +0 -0
  77. /package/lib/typescript/{src → commonjs/src}/sharedTypes.d.ts +0 -0
  78. /package/lib/typescript/{package.json → module/package.json} +0 -0
@@ -0,0 +1,419 @@
1
+ package com.platformcomponents
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.Context
5
+ import android.graphics.Color
6
+ import android.graphics.drawable.Drawable
7
+ import android.util.Log
8
+ import android.view.Menu
9
+ import android.view.MotionEvent
10
+ import android.view.View
11
+ import android.view.ViewConfiguration
12
+ import androidx.appcompat.widget.PopupMenu
13
+ import androidx.core.content.ContextCompat
14
+ import androidx.core.graphics.drawable.DrawableCompat
15
+ import com.facebook.react.views.view.ReactViewGroup
16
+
17
+ class PCContextMenuView(context: Context) : ReactViewGroup(context) {
18
+
19
+ data class Action(
20
+ val id: String,
21
+ val title: String,
22
+ val subtitle: String?,
23
+ val image: String?,
24
+ val imageColor: String?,
25
+ val destructive: Boolean,
26
+ val disabled: Boolean,
27
+ val hidden: Boolean,
28
+ val state: String?, // "off" | "on" | "mixed"
29
+ val subactions: List<Action>
30
+ )
31
+
32
+ companion object {
33
+ private const val TAG = "PCContextMenu"
34
+ }
35
+
36
+ // --- Props ---
37
+ var menuTitle: String? = null
38
+ var actions: List<Action> = emptyList()
39
+ var interactivity: String = "enabled" // "enabled" | "disabled"
40
+ var trigger: String = "longPress" // "longPress" | "tap"
41
+ var androidVisible: String = "closed" // "open" | "closed" (Android-only programmatic)
42
+ var androidAnchorPosition: String? = "left" // "left" | "right"
43
+
44
+ // --- Events ---
45
+ var onPressAction: ((id: String, title: String) -> Unit)? = null
46
+ var onMenuOpen: (() -> Unit)? = null
47
+ var onMenuClose: (() -> Unit)? = null
48
+
49
+ // --- Internal ---
50
+ private var popupMenu: PopupMenu? = null
51
+ private var popupShowing = false
52
+ private var dismissProgrammatic = false
53
+ private var dismissAfterSelect = false
54
+ private var openToken = 0
55
+
56
+ // Long-press detection
57
+ private var longPressRunnable: Runnable? = null
58
+ private var touchDownX = 0f
59
+ private var touchDownY = 0f
60
+ private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
61
+ private val longPressTimeout = ViewConfiguration.getLongPressTimeout().toLong()
62
+
63
+ init {
64
+ // Enable long-click for longPress mode
65
+ isLongClickable = true
66
+ setOnLongClickListener { handleLongClick() }
67
+ // Enable click for tap mode
68
+ setOnClickListener { handleTap() }
69
+ }
70
+
71
+ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
72
+ if (interactivity != "enabled") {
73
+ return super.onInterceptTouchEvent(ev)
74
+ }
75
+
76
+ when (ev.action) {
77
+ MotionEvent.ACTION_DOWN -> {
78
+ touchDownX = ev.x
79
+ touchDownY = ev.y
80
+ // In longPress mode, schedule long-press detection
81
+ if (trigger == "longPress") {
82
+ longPressRunnable = Runnable {
83
+ if (handleLongClick()) {
84
+ // Cancel any pending touch events on children
85
+ val cancel = MotionEvent.obtain(
86
+ ev.downTime, System.currentTimeMillis(),
87
+ MotionEvent.ACTION_CANCEL, ev.x, ev.y, 0
88
+ )
89
+ super.dispatchTouchEvent(cancel)
90
+ cancel.recycle()
91
+ }
92
+ }
93
+ postDelayed(longPressRunnable, longPressTimeout)
94
+ }
95
+ }
96
+ MotionEvent.ACTION_MOVE -> {
97
+ // Cancel if moved too much
98
+ if (Math.abs(ev.x - touchDownX) > touchSlop ||
99
+ Math.abs(ev.y - touchDownY) > touchSlop) {
100
+ cancelPendingLongPress()
101
+ }
102
+ }
103
+ MotionEvent.ACTION_UP -> {
104
+ cancelPendingLongPress()
105
+ // In tap mode, handle tap on ACTION_UP if within slop
106
+ if (trigger == "tap" &&
107
+ Math.abs(ev.x - touchDownX) <= touchSlop &&
108
+ Math.abs(ev.y - touchDownY) <= touchSlop) {
109
+ handleTap()
110
+ return true // Consume the event
111
+ }
112
+ }
113
+ MotionEvent.ACTION_CANCEL -> {
114
+ cancelPendingLongPress()
115
+ }
116
+ }
117
+
118
+ return super.onInterceptTouchEvent(ev)
119
+ }
120
+
121
+ private fun cancelPendingLongPress() {
122
+ longPressRunnable?.let {
123
+ removeCallbacks(it)
124
+ longPressRunnable = null
125
+ }
126
+ }
127
+
128
+ private fun handleLongClick(): Boolean {
129
+ if (trigger != "longPress") return false
130
+ if (interactivity != "enabled") return false
131
+
132
+ showPopupMenu()
133
+ return true
134
+ }
135
+
136
+ private fun handleTap() {
137
+ if (trigger != "tap") return
138
+ if (interactivity != "enabled") return
139
+
140
+ showPopupMenu()
141
+ }
142
+
143
+ // ---- Public apply* (called by manager) ----
144
+
145
+ fun applyMenuTitle(value: String?) {
146
+ menuTitle = value
147
+ }
148
+
149
+ fun applyActions(newActions: List<Action>) {
150
+ actions = newActions
151
+ Log.d(TAG, "applyActions size=${actions.size}")
152
+ }
153
+
154
+ fun applyInteractivity(value: String?) {
155
+ interactivity = if (value == "disabled") "disabled" else "enabled"
156
+ Log.d(TAG, "applyInteractivity interactivity=$interactivity")
157
+ updateEnabledState()
158
+
159
+ // If disabled while menu is open, dismiss
160
+ if (interactivity != "enabled" && popupShowing) {
161
+ Log.d(TAG, "applyInteractivity disabled while open -> dismiss")
162
+ dismissProgrammatic = true
163
+ popupMenu?.dismiss()
164
+ }
165
+ }
166
+
167
+ fun applyTrigger(value: String?) {
168
+ trigger = when (value) {
169
+ "longPress", "tap" -> value
170
+ else -> "longPress"
171
+ }
172
+ Log.d(TAG, "applyTrigger trigger=$trigger")
173
+ updateTriggerState()
174
+ }
175
+
176
+ fun applyAndroidVisible(value: String?) {
177
+ androidVisible = when (value) {
178
+ "open", "closed" -> value
179
+ else -> "closed"
180
+ }
181
+ Log.d(TAG, "applyAndroidVisible androidVisible=$androidVisible")
182
+ openToken += 1
183
+ val token = openToken
184
+
185
+ if (androidVisible == "open") {
186
+ presentIfNeeded(token)
187
+ } else {
188
+ if (popupShowing) {
189
+ dismissProgrammatic = true
190
+ popupMenu?.dismiss()
191
+ }
192
+ }
193
+ }
194
+
195
+ fun applyAndroidAnchorPosition(value: String?) {
196
+ androidAnchorPosition = value ?: "left"
197
+ }
198
+
199
+ // ---- Internal ----
200
+
201
+ private fun updateEnabledState() {
202
+ val enabled = interactivity == "enabled"
203
+ alpha = if (enabled) 1f else 0.5f
204
+ isLongClickable = enabled && trigger == "longPress"
205
+ isClickable = enabled && trigger == "tap"
206
+ }
207
+
208
+ private fun updateTriggerState() {
209
+ val enabled = interactivity == "enabled"
210
+ isLongClickable = enabled && trigger == "longPress"
211
+ isClickable = enabled && trigger == "tap"
212
+ }
213
+
214
+ private fun presentIfNeeded(token: Int) {
215
+ post {
216
+ if (token != openToken) {
217
+ Log.d(TAG, "presentIfNeeded stale token -> skip")
218
+ return@post
219
+ }
220
+ if (androidVisible != "open") {
221
+ Log.d(TAG, "presentIfNeeded no longer open -> skip")
222
+ return@post
223
+ }
224
+ if (interactivity != "enabled") {
225
+ Log.d(TAG, "presentIfNeeded disabled -> skip")
226
+ return@post
227
+ }
228
+ if (!isAttachedToWindow) {
229
+ Log.d(TAG, "presentIfNeeded not attached -> skip")
230
+ return@post
231
+ }
232
+
233
+ showPopupMenu()
234
+ }
235
+ }
236
+
237
+ @SuppressLint("RestrictedApi")
238
+ private fun showPopupMenu() {
239
+ val visibleActions = actions.filter { !it.hidden }
240
+ if (visibleActions.isEmpty()) {
241
+ Log.d(TAG, "showPopupMenu: no visible actions")
242
+ return
243
+ }
244
+
245
+ if (popupShowing) {
246
+ Log.d(TAG, "showPopupMenu: already showing")
247
+ return
248
+ }
249
+
250
+ Log.d(TAG, "showPopupMenu: creating popup with ${visibleActions.size} actions")
251
+
252
+ val popup = PopupMenu(context, this)
253
+
254
+ // Try to enable icons in the popup menu
255
+ try {
256
+ val menuHelper = PopupMenu::class.java.getDeclaredField("mPopup")
257
+ menuHelper.isAccessible = true
258
+ val menuPopupHelper = menuHelper.get(popup)
259
+ val setForceShowIcon = menuPopupHelper.javaClass.getDeclaredMethod(
260
+ "setForceShowIcon",
261
+ Boolean::class.java
262
+ )
263
+ setForceShowIcon.invoke(menuPopupHelper, true)
264
+ } catch (e: Exception) {
265
+ Log.d(TAG, "Could not enable popup menu icons: ${e.message}")
266
+ }
267
+
268
+ buildMenu(popup.menu, visibleActions, 0)
269
+
270
+ popup.setOnMenuItemClickListener { item ->
271
+ val actionId = item.intent?.getStringExtra("actionId") ?: ""
272
+ val actionTitle = item.title?.toString() ?: ""
273
+ Log.d(TAG, "popup onMenuItemClick id=$actionId title=$actionTitle")
274
+ dismissAfterSelect = true
275
+ onPressAction?.invoke(actionId, actionTitle)
276
+ true
277
+ }
278
+
279
+ popup.setOnDismissListener {
280
+ val programmatic = dismissProgrammatic || dismissAfterSelect
281
+ dismissProgrammatic = false
282
+ dismissAfterSelect = false
283
+ popupShowing = false
284
+ popupMenu = null
285
+
286
+ if (!programmatic) {
287
+ Log.d(TAG, "popup onDismiss user-initiated")
288
+ }
289
+ onMenuClose?.invoke()
290
+ }
291
+
292
+ popupMenu = popup
293
+ popupShowing = true
294
+ dismissProgrammatic = false
295
+ dismissAfterSelect = false
296
+
297
+ onMenuOpen?.invoke()
298
+ popup.show()
299
+ }
300
+
301
+ private fun buildMenu(menu: Menu, actions: List<Action>, groupId: Int) {
302
+ var order = 0
303
+ for (action in actions) {
304
+ if (action.hidden) continue
305
+
306
+ if (action.subactions.isNotEmpty()) {
307
+ // Create submenu
308
+ val subMenu = menu.addSubMenu(groupId, order, order, action.title)
309
+ buildMenu(subMenu, action.subactions, groupId + 1)
310
+
311
+ // Set icon on submenu header if available
312
+ action.image?.let { imageName ->
313
+ getDrawableByName(imageName)?.let { drawable ->
314
+ val tintedDrawable = tintDrawable(drawable, action.imageColor)
315
+ subMenu.item.icon = tintedDrawable
316
+ }
317
+ }
318
+ } else {
319
+ // Create regular item
320
+ val item = menu.add(groupId, order, order, buildTitle(action))
321
+
322
+ // Store action ID in intent for retrieval
323
+ item.intent = android.content.Intent().apply {
324
+ putExtra("actionId", action.id)
325
+ }
326
+
327
+ // Set enabled state
328
+ item.isEnabled = !action.disabled
329
+
330
+ // Set icon
331
+ action.image?.let { imageName ->
332
+ getDrawableByName(imageName)?.let { drawable ->
333
+ val tintedDrawable = tintDrawable(drawable, action.imageColor)
334
+ item.icon = tintedDrawable
335
+ }
336
+ }
337
+
338
+ // Set checkable state
339
+ when (action.state) {
340
+ "on" -> {
341
+ item.isCheckable = true
342
+ item.isChecked = true
343
+ }
344
+ "mixed" -> {
345
+ item.isCheckable = true
346
+ item.isChecked = true // Android doesn't have "mixed", treat as checked
347
+ }
348
+ else -> {
349
+ item.isCheckable = false
350
+ }
351
+ }
352
+ }
353
+ order++
354
+ }
355
+ }
356
+
357
+ private fun buildTitle(action: Action): String {
358
+ // On Android, we can't easily style text as destructive in PopupMenu
359
+ // We could prefix with emoji or special character, but keeping it simple
360
+ return action.title
361
+ }
362
+
363
+ private fun getDrawableByName(name: String): Drawable? {
364
+ // First try as a drawable resource
365
+ val resourceId = context.resources.getIdentifier(
366
+ name,
367
+ "drawable",
368
+ context.packageName
369
+ )
370
+
371
+ if (resourceId != 0) {
372
+ return ContextCompat.getDrawable(context, resourceId)
373
+ }
374
+
375
+ // Try common Material icons (ic_ prefix)
376
+ val icResourceId = context.resources.getIdentifier(
377
+ "ic_$name",
378
+ "drawable",
379
+ context.packageName
380
+ )
381
+
382
+ if (icResourceId != 0) {
383
+ return ContextCompat.getDrawable(context, icResourceId)
384
+ }
385
+
386
+ Log.d(TAG, "Drawable not found: $name")
387
+ return null
388
+ }
389
+
390
+ private fun tintDrawable(drawable: Drawable, colorString: String?): Drawable {
391
+ if (colorString.isNullOrEmpty()) return drawable
392
+
393
+ val color = parseColor(colorString) ?: return drawable
394
+
395
+ val wrappedDrawable = DrawableCompat.wrap(drawable.mutate())
396
+ DrawableCompat.setTint(wrappedDrawable, color)
397
+ return wrappedDrawable
398
+ }
399
+
400
+ private fun parseColor(colorString: String): Int? {
401
+ return try {
402
+ // Support hex colors like "#FF0000" or "FF0000"
403
+ val hex = if (colorString.startsWith("#")) colorString else "#$colorString"
404
+ Color.parseColor(hex)
405
+ } catch (e: Exception) {
406
+ Log.d(TAG, "Failed to parse color: $colorString")
407
+ null
408
+ }
409
+ }
410
+
411
+ override fun onDetachedFromWindow() {
412
+ cancelPendingLongPress()
413
+ if (popupShowing) {
414
+ dismissProgrammatic = true
415
+ popupMenu?.dismiss()
416
+ }
417
+ super.onDetachedFromWindow()
418
+ }
419
+ }
@@ -0,0 +1,200 @@
1
+ package com.platformcomponents
2
+
3
+ import android.view.View
4
+ import com.facebook.react.bridge.ReadableArray
5
+ import com.facebook.react.bridge.ReadableMap
6
+ import com.facebook.react.uimanager.ThemedReactContext
7
+ import com.facebook.react.uimanager.UIManagerHelper
8
+ import com.facebook.react.uimanager.ViewGroupManager
9
+ import com.facebook.react.uimanager.ViewManagerDelegate
10
+ import com.facebook.react.uimanager.events.Event
11
+ import com.facebook.react.uimanager.events.RCTEventEmitter
12
+ import com.facebook.react.viewmanagers.PCContextMenuManagerDelegate
13
+ import com.facebook.react.viewmanagers.PCContextMenuManagerInterface
14
+
15
+ class PCContextMenuViewManager :
16
+ ViewGroupManager<PCContextMenuView>(),
17
+ PCContextMenuManagerInterface<PCContextMenuView> {
18
+
19
+ companion object {
20
+ private const val TAG = "PCContextMenu"
21
+ }
22
+
23
+ private val delegate: ViewManagerDelegate<PCContextMenuView> =
24
+ PCContextMenuManagerDelegate(this)
25
+
26
+ override fun getName(): String = "PCContextMenu"
27
+
28
+ override fun getDelegate(): ViewManagerDelegate<PCContextMenuView> = delegate
29
+
30
+ override fun createViewInstance(reactContext: ThemedReactContext): PCContextMenuView {
31
+ return PCContextMenuView(reactContext)
32
+ }
33
+
34
+ override fun addEventEmitters(reactContext: ThemedReactContext, view: PCContextMenuView) {
35
+ val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
36
+
37
+ view.onPressAction = { id, title ->
38
+ dispatcher?.dispatchEvent(PressActionEvent(view.id, id, title))
39
+ }
40
+
41
+ view.onMenuOpen = {
42
+ dispatcher?.dispatchEvent(MenuOpenEvent(view.id))
43
+ }
44
+
45
+ view.onMenuClose = {
46
+ dispatcher?.dispatchEvent(MenuCloseEvent(view.id))
47
+ }
48
+ }
49
+
50
+ override fun setTitle(view: PCContextMenuView, value: String?) {
51
+ view.applyMenuTitle(value)
52
+ }
53
+
54
+ override fun setActions(view: PCContextMenuView, value: ReadableArray?) {
55
+ val out = ArrayList<PCContextMenuView.Action>()
56
+ if (value != null) {
57
+ for (i in 0 until value.size()) {
58
+ val m = value.getMap(i) ?: continue
59
+ out.add(parseAction(m))
60
+ }
61
+ }
62
+ view.applyActions(out)
63
+ }
64
+
65
+ private fun parseAction(map: ReadableMap): PCContextMenuView.Action {
66
+ val id = map.getStringOrEmpty("id")
67
+ val title = map.getStringOrEmpty("title")
68
+ val subtitle = map.getStringOrNull("subtitle")
69
+ val image = map.getStringOrNull("image")
70
+ val imageColor = map.getStringOrNull("imageColor")
71
+ val state = map.getStringOrNull("state")
72
+
73
+ // Parse attributes
74
+ var destructive = false
75
+ var disabled = false
76
+ var hidden = false
77
+ if (map.hasKey("attributes") && !map.isNull("attributes")) {
78
+ val attrs = map.getMap("attributes")
79
+ if (attrs != null) {
80
+ destructive = attrs.getStringOrEmpty("destructive") == "true"
81
+ disabled = attrs.getStringOrEmpty("disabled") == "true"
82
+ hidden = attrs.getStringOrEmpty("hidden") == "true"
83
+ }
84
+ }
85
+
86
+ // Parse subactions recursively
87
+ val subactions = ArrayList<PCContextMenuView.Action>()
88
+ if (map.hasKey("subactions") && !map.isNull("subactions")) {
89
+ val subs = map.getArray("subactions")
90
+ if (subs != null) {
91
+ for (j in 0 until subs.size()) {
92
+ val subMap = subs.getMap(j) ?: continue
93
+ subactions.add(parseAction(subMap))
94
+ }
95
+ }
96
+ }
97
+
98
+ return PCContextMenuView.Action(
99
+ id = id,
100
+ title = title,
101
+ subtitle = subtitle,
102
+ image = image,
103
+ imageColor = imageColor,
104
+ destructive = destructive,
105
+ disabled = disabled,
106
+ hidden = hidden,
107
+ state = state,
108
+ subactions = subactions
109
+ )
110
+ }
111
+
112
+ private fun ReadableMap.getStringOrEmpty(key: String): String {
113
+ return if (hasKey(key) && !isNull(key)) getString(key) ?: "" else ""
114
+ }
115
+
116
+ private fun ReadableMap.getStringOrNull(key: String): String? {
117
+ return if (hasKey(key) && !isNull(key)) getString(key) else null
118
+ }
119
+
120
+ override fun setInteractivity(view: PCContextMenuView, value: String?) {
121
+ view.applyInteractivity(value)
122
+ }
123
+
124
+ override fun setTrigger(view: PCContextMenuView, value: String?) {
125
+ view.applyTrigger(value)
126
+ }
127
+
128
+ override fun setIos(view: PCContextMenuView, value: ReadableMap?) {
129
+ // Android ignores iOS config
130
+ }
131
+
132
+ override fun setAndroid(view: PCContextMenuView, value: ReadableMap?) {
133
+ if (value == null) {
134
+ view.applyAndroidAnchorPosition(null)
135
+ view.applyAndroidVisible(null)
136
+ return
137
+ }
138
+
139
+ val anchorPosition = if (value.hasKey("anchorPosition") && !value.isNull("anchorPosition")) {
140
+ value.getString("anchorPosition")
141
+ } else {
142
+ null
143
+ }
144
+ view.applyAndroidAnchorPosition(anchorPosition)
145
+
146
+ val visible = if (value.hasKey("visible") && !value.isNull("visible")) {
147
+ value.getString("visible")
148
+ } else {
149
+ null
150
+ }
151
+ view.applyAndroidVisible(visible)
152
+ }
153
+
154
+ // --- ViewGroupManager child management ---
155
+ override fun addView(parent: PCContextMenuView, child: View, index: Int) {
156
+ parent.addView(child, index)
157
+ }
158
+
159
+ override fun removeViewAt(parent: PCContextMenuView, index: Int) {
160
+ parent.removeViewAt(index)
161
+ }
162
+
163
+ override fun getChildCount(parent: PCContextMenuView): Int {
164
+ return parent.childCount
165
+ }
166
+
167
+ override fun getChildAt(parent: PCContextMenuView, index: Int): View? {
168
+ return parent.getChildAt(index)
169
+ }
170
+
171
+ // --- Events ---
172
+ private class PressActionEvent(
173
+ surfaceId: Int,
174
+ private val actionId: String,
175
+ private val actionTitle: String
176
+ ) : Event<PressActionEvent>(surfaceId) {
177
+ override fun getEventName(): String = "topPressAction"
178
+ override fun dispatch(rctEventEmitter: RCTEventEmitter) {
179
+ val payload = com.facebook.react.bridge.Arguments.createMap().apply {
180
+ putString("actionId", actionId)
181
+ putString("actionTitle", actionTitle)
182
+ }
183
+ rctEventEmitter.receiveEvent(viewTag, eventName, payload)
184
+ }
185
+ }
186
+
187
+ private class MenuOpenEvent(surfaceId: Int) : Event<MenuOpenEvent>(surfaceId) {
188
+ override fun getEventName(): String = "topMenuOpen"
189
+ override fun dispatch(rctEventEmitter: RCTEventEmitter) {
190
+ rctEventEmitter.receiveEvent(viewTag, eventName, com.facebook.react.bridge.Arguments.createMap())
191
+ }
192
+ }
193
+
194
+ private class MenuCloseEvent(surfaceId: Int) : Event<MenuCloseEvent>(surfaceId) {
195
+ override fun getEventName(): String = "topMenuClose"
196
+ override fun dispatch(rctEventEmitter: RCTEventEmitter) {
197
+ rctEventEmitter.receiveEvent(viewTag, eventName, com.facebook.react.bridge.Arguments.createMap())
198
+ }
199
+ }
200
+ }
@@ -341,12 +341,16 @@ class PCSelectionMenuView(context: Context) : FrameLayout(context), ReactScrollV
341
341
 
342
342
  if (mode == MaterialMode.M3) {
343
343
  // M3 exposed dropdown menu - the standard Material 3 way
344
+ // Must set box background mode BEFORE setting endIconMode to avoid IllegalStateException
344
345
  val til = TextInputLayout(context).apply {
345
346
  layoutParams = FrameLayout.LayoutParams(
346
347
  FrameLayout.LayoutParams.MATCH_PARENT,
347
348
  FrameLayout.LayoutParams.WRAP_CONTENT
348
349
  )
350
+ // Set box background mode first - required for END_ICON_DROPDOWN_MENU
351
+ boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE
349
352
  hint = placeholder
353
+ // Now safe to set the dropdown icon
350
354
  endIconMode = TextInputLayout.END_ICON_DROPDOWN_MENU
351
355
  }
352
356
 
@@ -13,6 +13,7 @@ class PlatformComponentsViewPackage : ReactPackage {
13
13
  return listOf(
14
14
  PCSelectionMenuViewManager(),
15
15
  PCDatePickerViewManager(),
16
+ PCContextMenuViewManager(),
16
17
  )
17
18
  }
18
19
 
package/app.plugin.cjs ADDED
@@ -0,0 +1,4 @@
1
+ // Expo config plugin entry point
2
+ // This file is used by Expo to locate the config plugin
3
+
4
+ module.exports = require('./plugin/build/index.cjs').default;
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "react-native-platform-components",
3
+ "platforms": ["ios", "android"]
4
+ }
@@ -0,0 +1,12 @@
1
+ // PCContextMenu.h
2
+
3
+ #import <React/RCTViewComponentView.h>
4
+ #import <UIKit/UIKit.h>
5
+
6
+ NS_ASSUME_NONNULL_BEGIN
7
+
8
+ @interface PCContextMenu : RCTViewComponentView
9
+
10
+ @end
11
+
12
+ NS_ASSUME_NONNULL_END