react-native-platform-components 0.0.2

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 (63) hide show
  1. package/LICENSE +20 -0
  2. package/PlatformComponents.podspec +20 -0
  3. package/README.md +233 -0
  4. package/android/build.gradle +78 -0
  5. package/android/gradle.properties +5 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/com/platformcomponents/DateConstraints.kt +83 -0
  8. package/android/src/main/java/com/platformcomponents/Helper.kt +27 -0
  9. package/android/src/main/java/com/platformcomponents/PCDatePickerView.kt +684 -0
  10. package/android/src/main/java/com/platformcomponents/PCDatePickerViewManager.kt +149 -0
  11. package/android/src/main/java/com/platformcomponents/PCMaterialMode.kt +16 -0
  12. package/android/src/main/java/com/platformcomponents/PCSelectionMenuView.kt +410 -0
  13. package/android/src/main/java/com/platformcomponents/PCSelectionMenuViewManager.kt +114 -0
  14. package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +22 -0
  15. package/ios/PCDatePicker.h +11 -0
  16. package/ios/PCDatePicker.mm +248 -0
  17. package/ios/PCDatePickerView.swift +405 -0
  18. package/ios/PCSelectionMenu.h +10 -0
  19. package/ios/PCSelectionMenu.mm +182 -0
  20. package/ios/PCSelectionMenu.swift +434 -0
  21. package/lib/module/DatePicker.js +74 -0
  22. package/lib/module/DatePicker.js.map +1 -0
  23. package/lib/module/DatePickerNativeComponent.ts +68 -0
  24. package/lib/module/SelectionMenu.js +79 -0
  25. package/lib/module/SelectionMenu.js.map +1 -0
  26. package/lib/module/SelectionMenu.web.js +57 -0
  27. package/lib/module/SelectionMenu.web.js.map +1 -0
  28. package/lib/module/SelectionMenuNativeComponent.ts +106 -0
  29. package/lib/module/index.js +6 -0
  30. package/lib/module/index.js.map +1 -0
  31. package/lib/module/package.json +1 -0
  32. package/lib/module/sharedTypes.js +4 -0
  33. package/lib/module/sharedTypes.js.map +1 -0
  34. package/lib/typescript/package.json +1 -0
  35. package/lib/typescript/src/DatePicker.d.ts +38 -0
  36. package/lib/typescript/src/DatePicker.d.ts.map +1 -0
  37. package/lib/typescript/src/DatePickerNativeComponent.d.ts +53 -0
  38. package/lib/typescript/src/DatePickerNativeComponent.d.ts.map +1 -0
  39. package/lib/typescript/src/SelectionMenu.d.ts +50 -0
  40. package/lib/typescript/src/SelectionMenu.d.ts.map +1 -0
  41. package/lib/typescript/src/SelectionMenu.web.d.ts +19 -0
  42. package/lib/typescript/src/SelectionMenu.web.d.ts.map +1 -0
  43. package/lib/typescript/src/SelectionMenuNativeComponent.d.ts +85 -0
  44. package/lib/typescript/src/SelectionMenuNativeComponent.d.ts.map +1 -0
  45. package/lib/typescript/src/index.d.ts +4 -0
  46. package/lib/typescript/src/index.d.ts.map +1 -0
  47. package/lib/typescript/src/sharedTypes.d.ts +10 -0
  48. package/lib/typescript/src/sharedTypes.d.ts.map +1 -0
  49. package/package.json +178 -0
  50. package/shared/PCDatePickerComponentDescriptors-custom.h +52 -0
  51. package/shared/PCDatePickerShadowNode-custom.cpp +1 -0
  52. package/shared/PCDatePickerShadowNode-custom.h +27 -0
  53. package/shared/PCDatePickerState-custom.h +13 -0
  54. package/shared/PCSelectionMenuComponentDescriptors-custom.h +25 -0
  55. package/shared/PCSelectionMenuShadowNode-custom.cpp +36 -0
  56. package/shared/PCSelectionMenuShadowNode-custom.h +46 -0
  57. package/src/DatePicker.tsx +146 -0
  58. package/src/DatePickerNativeComponent.ts +68 -0
  59. package/src/SelectionMenu.tsx +170 -0
  60. package/src/SelectionMenu.web.tsx +93 -0
  61. package/src/SelectionMenuNativeComponent.ts +106 -0
  62. package/src/index.tsx +3 -0
  63. package/src/sharedTypes.ts +14 -0
@@ -0,0 +1,149 @@
1
+ package com.platformcomponents
2
+
3
+ import com.facebook.react.bridge.ReadableMap
4
+ import com.facebook.react.uimanager.SimpleViewManager
5
+ import com.facebook.react.uimanager.ThemedReactContext
6
+ import com.facebook.react.uimanager.ViewManagerDelegate
7
+ import com.facebook.react.uimanager.UIManagerHelper
8
+ import com.facebook.react.uimanager.events.Event
9
+ import com.facebook.react.uimanager.events.RCTEventEmitter
10
+ import com.facebook.react.viewmanagers.PCDatePickerManagerDelegate
11
+ import com.facebook.react.viewmanagers.PCDatePickerManagerInterface
12
+
13
+ class PCDatePickerViewManager :
14
+ SimpleViewManager<PCDatePickerView>(),
15
+ PCDatePickerManagerInterface<PCDatePickerView> {
16
+
17
+ private val delegate: ViewManagerDelegate<PCDatePickerView> =
18
+ PCDatePickerManagerDelegate(this)
19
+
20
+ override fun getName(): String = "PCDatePicker"
21
+
22
+ override fun getDelegate(): ViewManagerDelegate<PCDatePickerView> = delegate
23
+
24
+ override fun createViewInstance(reactContext: ThemedReactContext): PCDatePickerView {
25
+ return PCDatePickerView(reactContext)
26
+ }
27
+
28
+ override fun addEventEmitters(reactContext: ThemedReactContext, view: PCDatePickerView) {
29
+ val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
30
+
31
+ view.onConfirm = { tsMs: Long ->
32
+ dispatcher?.dispatchEvent(ConfirmEvent(view.id, tsMs.toDouble()))
33
+
34
+ dispatcher?.dispatchEvent(CancelEvent(view.id))
35
+ }
36
+ view.onCancel = {
37
+ dispatcher?.dispatchEvent(CancelEvent(view.id))
38
+ }
39
+ }
40
+
41
+ // --- Common props ---
42
+
43
+ override fun setMode(view: PCDatePickerView, value: String?) {
44
+ view.applyMode(value)
45
+ }
46
+
47
+ override fun setPresentation(view: PCDatePickerView, value: String?) {
48
+ view.applyPresentation(value)
49
+ }
50
+
51
+ override fun setVisible(view: PCDatePickerView, value: String?) {
52
+ view.applyVisible(value)
53
+ }
54
+
55
+ override fun setLocale(view: PCDatePickerView, value: String?) {
56
+ view.applyLocale(value)
57
+ }
58
+
59
+ override fun setTimeZoneName(view: PCDatePickerView, value: String?) {
60
+ view.applyTimeZoneName(value)
61
+ }
62
+
63
+ // WithDefault<double,-1> comes through as primitive Double
64
+ override fun setDateMs(view: PCDatePickerView, value: Double) {
65
+ view.applyDateMs(if (value >= 0.0) value.toLong() else null)
66
+ }
67
+
68
+ override fun setMinDateMs(view: PCDatePickerView, value: Double) {
69
+ view.applyMinDateMs(if (value >= 0.0) value.toLong() else null)
70
+ }
71
+
72
+ override fun setMaxDateMs(view: PCDatePickerView, value: Double) {
73
+ view.applyMaxDateMs(if (value >= 0.0) value.toLong() else null)
74
+ }
75
+
76
+ // --- platform objects ---
77
+
78
+ override fun setAndroid(view: PCDatePickerView, value: ReadableMap?) {
79
+ if (value == null) {
80
+ view.applyAndroidConfig(null, null, null, null, null)
81
+ return
82
+ }
83
+
84
+ val firstDay =
85
+ if (value.hasKey("firstDayOfWeek") && !value.isNull("firstDayOfWeek"))
86
+ value.getInt("firstDayOfWeek")
87
+ else null
88
+
89
+ val materialRaw =
90
+ if (value.hasKey("material") && !value.isNull("material"))
91
+ value.getString("material")
92
+ else null
93
+
94
+ // ✅ Only allow "system" or "m3" (anything else -> system)
95
+ val material =
96
+ when (materialRaw) {
97
+ "m3" -> "m3"
98
+ "system", null -> "system"
99
+ else -> "system"
100
+ }
101
+
102
+ val title =
103
+ if (value.hasKey("dialogTitle") && !value.isNull("dialogTitle"))
104
+ value.getString("dialogTitle")
105
+ else null
106
+
107
+ val pos =
108
+ if (value.hasKey("positiveButtonTitle") && !value.isNull("positiveButtonTitle"))
109
+ value.getString("positiveButtonTitle")
110
+ else null
111
+
112
+ val neg =
113
+ if (value.hasKey("negativeButtonTitle") && !value.isNull("negativeButtonTitle"))
114
+ value.getString("negativeButtonTitle")
115
+ else null
116
+
117
+ view.applyAndroidConfig(firstDay, material, title, pos, neg)
118
+ }
119
+
120
+ override fun setIos(view: PCDatePickerView, value: ReadableMap?) {
121
+ // Android ignores iOS config
122
+ }
123
+
124
+ // --- Events (Fabric -> JS) ---
125
+
126
+ private class ConfirmEvent(
127
+ surfaceId: Int,
128
+ private val ts: Double
129
+ ) : Event<ConfirmEvent>(surfaceId) {
130
+ override fun getEventName(): String = "topConfirm"
131
+ override fun dispatch(rctEventEmitter: RCTEventEmitter) {
132
+ val payload = com.facebook.react.bridge.Arguments.createMap().apply {
133
+ putDouble("timestampMs", ts)
134
+ }
135
+ rctEventEmitter.receiveEvent(viewTag, eventName, payload)
136
+ }
137
+ }
138
+
139
+ private class CancelEvent(surfaceId: Int) : Event<CancelEvent>(surfaceId) {
140
+ override fun getEventName(): String = "topClosed"
141
+ override fun dispatch(rctEventEmitter: RCTEventEmitter) {
142
+ rctEventEmitter.receiveEvent(
143
+ viewTag,
144
+ eventName,
145
+ com.facebook.react.bridge.Arguments.createMap()
146
+ )
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,16 @@
1
+ package com.platformcomponents
2
+
3
+ /**
4
+ * Android rendering strategy for components that optionally use Material Components.
5
+ *
6
+ * - SYSTEM: Use platform/AppCompat widgets & dialogs (no Material-only UI assumptions).
7
+ * - M3: Use Material 3 components where applicable.
8
+ */
9
+ internal enum class PCMaterialMode { SYSTEM, M3 }
10
+
11
+ internal fun parseMaterialMode(value: String?): PCMaterialMode =
12
+ when (value) {
13
+ "m3" -> PCMaterialMode.M3
14
+ "system" -> PCMaterialMode.SYSTEM
15
+ else -> PCMaterialMode.SYSTEM
16
+ }
@@ -0,0 +1,410 @@
1
+ package com.platformcomponents
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.ContextWrapper
6
+ import android.text.InputType
7
+ import android.view.View
8
+ import android.view.ViewGroup
9
+ import android.widget.AdapterView
10
+ import android.widget.ArrayAdapter
11
+ import android.widget.FrameLayout
12
+ import android.widget.LinearLayout
13
+ import android.widget.Spinner
14
+ import android.content.DialogInterface
15
+ import androidx.fragment.app.FragmentActivity
16
+ import com.facebook.react.uimanager.ThemedReactContext
17
+ import com.google.android.material.dialog.MaterialAlertDialogBuilder
18
+ import com.google.android.material.textfield.MaterialAutoCompleteTextView
19
+ import com.google.android.material.textfield.TextInputLayout
20
+
21
+ class PCSelectionMenuView(context: Context) : FrameLayout(context) {
22
+
23
+ data class Option(val label: String, val data: String)
24
+
25
+ // --- Props ---
26
+ var options: List<Option> = emptyList()
27
+ var selectedData: String = "" // sentinel for none
28
+
29
+ var interactivity: String = "enabled" // "enabled" | "disabled"
30
+ var placeholder: String? = null
31
+
32
+ var anchorMode: String = "headless" // "inline" | "headless"
33
+ var visible: String = "closed" // "open" | "closed" (headless only)
34
+
35
+ // Kept for backward compatibility with your interface; headless uses Spinner regardless.
36
+ var presentation: String = "auto" // "auto" | "popover" | "sheet"
37
+
38
+ // Only used to choose inline rendering style.
39
+ var androidMaterial: String? = "system" // "system" | "m3"
40
+
41
+ // --- Events ---
42
+ var onSelect: ((index: Int, label: String, data: String) -> Unit)? = null
43
+ var onRequestClose: (() -> Unit)? = null
44
+
45
+ // --- Inline UI ---
46
+ private var inlineLayout: TextInputLayout? = null
47
+ private var inlineText: MaterialAutoCompleteTextView? = null
48
+ private var inlineSpinner: Spinner? = null
49
+
50
+ // --- Headless UI (true picker) ---
51
+ private var headlessSpinner: Spinner? = null
52
+ private var headlessDismissHooked: Boolean = false
53
+
54
+ private var suppressSpinnerSelection = false
55
+
56
+ init {
57
+ minimumHeight = 0
58
+ minimumWidth = 0
59
+ rebuildUI()
60
+ }
61
+
62
+ private val minInlineHeightPx: Int by lazy {
63
+ (56f * resources.displayMetrics.density).toInt() // M3 default touch target
64
+ }
65
+
66
+ // Headless needs a non-zero anchor rect for dropdown
67
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
68
+ if (anchorMode == "headless") {
69
+ val w = MeasureSpec.getSize(widthMeasureSpec)
70
+ setMeasuredDimension(if (w > 0) w else 1, 1)
71
+ return
72
+ }
73
+
74
+ // Inline: measure children, but never allow a collapsed height
75
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
76
+
77
+ val measuredH = measuredHeight
78
+ if (measuredH < minInlineHeightPx) {
79
+ setMeasuredDimension(measuredWidth, minInlineHeightPx)
80
+ }
81
+ }
82
+
83
+ // ---- Public apply* (called by manager) ----
84
+
85
+ fun applyOptions(newOptions: List<Option>) {
86
+ options = newOptions
87
+ refreshAdapters()
88
+ refreshSelections()
89
+ }
90
+
91
+ fun applySelectedData(data: String?) {
92
+ selectedData = data ?: ""
93
+ refreshSelections()
94
+ }
95
+
96
+ fun applyInteractivity(value: String?) {
97
+ interactivity = if (value == "disabled") "disabled" else "enabled"
98
+ updateEnabledState()
99
+
100
+ // If disabled while open, request close.
101
+ if (interactivity != "enabled" && visible == "open") {
102
+ onRequestClose?.invoke()
103
+ }
104
+ }
105
+
106
+ fun applyPlaceholder(value: String?) {
107
+ if (placeholder == value) return
108
+ placeholder = value
109
+ inlineLayout?.hint = placeholder
110
+ }
111
+
112
+ fun applyAnchorMode(value: String?) {
113
+ val newMode = when (value) {
114
+ "inline", "headless" -> value
115
+ else -> "headless"
116
+ }
117
+ if (anchorMode == newMode) return
118
+ anchorMode = newMode
119
+ rebuildUI()
120
+ }
121
+
122
+ fun applyVisible(value: String?) {
123
+ visible = when (value) {
124
+ "open", "closed" -> value
125
+ else -> "closed"
126
+ }
127
+
128
+ if (anchorMode != "headless") return
129
+
130
+ if (visible == "open") {
131
+ presentHeadlessIfNeeded()
132
+ } else {
133
+ // We can't reliably force-close a Spinner dropdown, but we can at least signal state.
134
+ // Most apps will set visible=closed after onSelect/onRequestClose anyway.
135
+ }
136
+ }
137
+
138
+ fun applyPresentation(value: String?) {
139
+ presentation = when (value) {
140
+ "auto", "popover", "sheet" -> value
141
+ else -> "auto"
142
+ }
143
+ }
144
+
145
+ fun applyAndroidMaterial(value: String?) {
146
+ val newValue = value ?: "system"
147
+ if (androidMaterial == newValue) return
148
+ androidMaterial = newValue
149
+ if (anchorMode == "inline") rebuildUI()
150
+ }
151
+
152
+ // ---- UI building ----
153
+
154
+ private fun rebuildUI() {
155
+ removeAllViews()
156
+ inlineLayout = null
157
+ inlineText = null
158
+ inlineSpinner = null
159
+ headlessSpinner = null
160
+ headlessDismissHooked = false
161
+
162
+ // Headless should be invisible but anchorable.
163
+ alpha = if (anchorMode == "headless") 0f else 1f
164
+
165
+ if (anchorMode == "inline") {
166
+ buildInline()
167
+ } else {
168
+ buildHeadless()
169
+ }
170
+
171
+ refreshAdapters()
172
+ refreshSelections()
173
+ updateEnabledState()
174
+ requestLayout()
175
+ }
176
+
177
+ private fun buildInline() {
178
+ val mode = parseMaterial(androidMaterial)
179
+
180
+ if (mode == MaterialMode.M3) {
181
+ // M3 inline = exposed dropdown look, but forced read-only
182
+ val til = TextInputLayout(context).apply {
183
+ layoutParams = FrameLayout.LayoutParams(
184
+ FrameLayout.LayoutParams.MATCH_PARENT,
185
+ FrameLayout.LayoutParams.MATCH_PARENT
186
+ )
187
+ hint = placeholder
188
+ }
189
+
190
+ val actv = MaterialAutoCompleteTextView(til.context).apply {
191
+ layoutParams = LinearLayout.LayoutParams(
192
+ ViewGroup.LayoutParams.MATCH_PARENT,
193
+ ViewGroup.LayoutParams.WRAP_CONTENT
194
+ )
195
+ isSingleLine = true
196
+ threshold = 0
197
+
198
+ // behave like picker
199
+ inputType = InputType.TYPE_NULL
200
+ keyListener = null
201
+ isCursorVisible = false
202
+ isFocusable = false
203
+ isFocusableInTouchMode = false
204
+
205
+ setOnClickListener {
206
+ if (interactivity == "enabled") showDropDown()
207
+ }
208
+
209
+ setOnItemClickListener { _, _, position, _ ->
210
+ val opt = options.getOrNull(position) ?: return@setOnItemClickListener
211
+ selectedData = opt.data
212
+ onSelect?.invoke(position, opt.label, opt.data)
213
+ }
214
+ }
215
+
216
+ til.addView(actv)
217
+ addView(til)
218
+ inlineLayout = til
219
+ inlineText = actv
220
+ } else {
221
+ // SYSTEM inline = native spinner
222
+ val sp = Spinner(context, Spinner.MODE_DROPDOWN).apply {
223
+ layoutParams = FrameLayout.LayoutParams(
224
+ FrameLayout.LayoutParams.MATCH_PARENT,
225
+ FrameLayout.LayoutParams.WRAP_CONTENT
226
+ )
227
+ }
228
+
229
+ sp.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
230
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
231
+ if (suppressSpinnerSelection) return
232
+ val opt = options.getOrNull(position) ?: return
233
+ if (opt.data == selectedData) return
234
+ selectedData = opt.data
235
+ onSelect?.invoke(position, opt.label, opt.data)
236
+ }
237
+ override fun onNothingSelected(parent: AdapterView<*>?) = Unit
238
+ }
239
+
240
+ addView(sp)
241
+ inlineSpinner = sp
242
+ }
243
+ }
244
+
245
+ private fun buildHeadless() {
246
+ // Headless = TRUE picker using Spinner dropdown
247
+ val sp = Spinner(context, Spinner.MODE_DROPDOWN).apply {
248
+ layoutParams = FrameLayout.LayoutParams(1, 1)
249
+ // keep it anchorable but not interactive unless we programmatically open it
250
+ isClickable = true
251
+ isFocusable = false
252
+ isFocusableInTouchMode = false
253
+ }
254
+
255
+ sp.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
256
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
257
+ if (suppressSpinnerSelection) return
258
+ val opt = options.getOrNull(position) ?: return
259
+
260
+ selectedData = opt.data
261
+ onSelect?.invoke(position, opt.label, opt.data)
262
+
263
+ // After selection, close contract.
264
+ onRequestClose?.invoke()
265
+ }
266
+
267
+ override fun onNothingSelected(parent: AdapterView<*>?) = Unit
268
+ }
269
+
270
+ addView(sp)
271
+ headlessSpinner = sp
272
+ }
273
+
274
+ private fun updateEnabledState() {
275
+ val enabled = interactivity == "enabled"
276
+ inlineLayout?.isEnabled = enabled
277
+ inlineText?.isEnabled = enabled
278
+ inlineSpinner?.isEnabled = enabled
279
+ headlessSpinner?.isEnabled = enabled
280
+ }
281
+
282
+ private fun refreshAdapters() {
283
+ val labels = options.map { it.label }
284
+
285
+ inlineText?.let { actv ->
286
+ actv.setAdapter(ArrayAdapter(actv.context, android.R.layout.simple_list_item_1, labels))
287
+ }
288
+
289
+ fun applySpinnerAdapter(sp: Spinner) {
290
+ val adapter = ArrayAdapter(sp.context, android.R.layout.simple_spinner_item, labels)
291
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
292
+ sp.adapter = adapter
293
+ }
294
+
295
+ inlineSpinner?.let { applySpinnerAdapter(it) }
296
+ headlessSpinner?.let { applySpinnerAdapter(it) }
297
+ }
298
+
299
+ private fun refreshSelections() {
300
+ val idx = options.indexOfFirst { it.data == selectedData }
301
+ val label = if (idx >= 0) options[idx].label else ""
302
+
303
+ inlineText?.setText(label, false)
304
+
305
+ fun setSpinnerSelection(sp: Spinner) {
306
+ if (options.isEmpty()) return
307
+ val target = if (idx >= 0) idx else 0
308
+ if (sp.selectedItemPosition == target) return
309
+
310
+ suppressSpinnerSelection = true
311
+ try {
312
+ sp.setSelection(target, false)
313
+ } finally {
314
+ suppressSpinnerSelection = false
315
+ }
316
+ }
317
+
318
+ inlineSpinner?.let { setSpinnerSelection(it) }
319
+ headlessSpinner?.let { setSpinnerSelection(it) }
320
+ }
321
+
322
+ // ---- Headless open ----
323
+
324
+ private fun presentHeadlessIfNeeded() {
325
+ val sp = headlessSpinner ?: return
326
+ if (interactivity != "enabled") {
327
+ onRequestClose?.invoke()
328
+ return
329
+ }
330
+ post {
331
+ if (!isAttachedToWindow) {
332
+ onRequestClose?.invoke()
333
+ return@post
334
+ }
335
+
336
+ // Make dropdown match the anchor view width
337
+ val anchorW = this@PCSelectionMenuView.width
338
+ if (anchorW > 0) {
339
+ sp.dropDownWidth = anchorW
340
+ }
341
+
342
+ // Align dropdown’s left edge with the anchor’s left edge.
343
+ // (X positioning comes from the anchor itself; this is just extra explicit.)
344
+ sp.dropDownHorizontalOffset = 0
345
+ sp.dropDownVerticalOffset = 0
346
+
347
+ hookSpinnerDismiss(sp)
348
+ sp.performClick()
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Spinner doesn't expose dismiss callbacks publicly. Many Android builds use an internal
354
+ * DropdownPopup (ListPopupWindow) stored in a private field. We hook it if possible.
355
+ *
356
+ * If this fails, selection still works, but you may not get an "onRequestClose" when dismissed.
357
+ */
358
+ private fun hookSpinnerDismiss(sp: Spinner) {
359
+ if (headlessDismissHooked) return
360
+
361
+ try {
362
+ val f = Spinner::class.java.getDeclaredField("mPopup")
363
+ f.isAccessible = true
364
+ val popupObj = f.get(sp)
365
+
366
+ // AOSP DropdownPopup extends ListPopupWindow on many versions.
367
+ val setOnDismiss = popupObj?.javaClass?.methods?.firstOrNull { m ->
368
+ m.name == "setOnDismissListener" && m.parameterTypes.size == 1
369
+ }
370
+
371
+ if (setOnDismiss != null) {
372
+ val listener = DialogInterface.OnDismissListener {
373
+ onRequestClose?.invoke()
374
+ }
375
+ setOnDismiss.invoke(popupObj, listener)
376
+ headlessDismissHooked = true
377
+ }
378
+ } catch (_: Throwable) {
379
+ // ignore; no dismiss hook available
380
+ }
381
+ }
382
+
383
+ // ---- Helpers ----
384
+
385
+ private enum class MaterialMode { SYSTEM, M3 }
386
+
387
+ private fun parseMaterial(value: String?): MaterialMode =
388
+ when (value) {
389
+ "m3" -> MaterialMode.M3
390
+ "system", null -> MaterialMode.SYSTEM
391
+ else -> MaterialMode.SYSTEM
392
+ }
393
+
394
+ private fun findFragmentActivity(): FragmentActivity? {
395
+ val trc = context as? ThemedReactContext
396
+ val a1 = trc?.currentActivity
397
+ if (a1 is FragmentActivity) return a1
398
+
399
+ var c: Context? = context
400
+ while (c is ContextWrapper) {
401
+ if (c is FragmentActivity) return c
402
+ val base = (c as ContextWrapper).baseContext
403
+ if (base == c) break
404
+ c = base
405
+ }
406
+
407
+ val a2 = (context as? Activity)
408
+ return a2 as? FragmentActivity
409
+ }
410
+ }
@@ -0,0 +1,114 @@
1
+ package com.platformcomponents
2
+
3
+ import com.facebook.react.bridge.ReadableArray
4
+ import com.facebook.react.bridge.ReadableMap
5
+ import com.facebook.react.uimanager.SimpleViewManager
6
+ import com.facebook.react.uimanager.ThemedReactContext
7
+ import com.facebook.react.uimanager.ViewManagerDelegate
8
+ import com.facebook.react.uimanager.UIManagerHelper
9
+ import com.facebook.react.uimanager.events.Event
10
+ import com.facebook.react.uimanager.events.RCTEventEmitter
11
+ import com.facebook.react.viewmanagers.PCSelectionMenuManagerDelegate
12
+ import com.facebook.react.viewmanagers.PCSelectionMenuManagerInterface
13
+
14
+ class PCSelectionMenuViewManager :
15
+ SimpleViewManager<PCSelectionMenuView>(),
16
+ PCSelectionMenuManagerInterface<PCSelectionMenuView> {
17
+
18
+ private val delegate: ViewManagerDelegate<PCSelectionMenuView> =
19
+ PCSelectionMenuManagerDelegate(this)
20
+
21
+ override fun getName(): String = "PCSelectionMenu"
22
+
23
+ override fun getDelegate(): ViewManagerDelegate<PCSelectionMenuView> = delegate
24
+
25
+ override fun createViewInstance(reactContext: ThemedReactContext): PCSelectionMenuView {
26
+ return PCSelectionMenuView(reactContext)
27
+ }
28
+
29
+ override fun addEventEmitters(reactContext: ThemedReactContext, view: PCSelectionMenuView) {
30
+ val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
31
+
32
+ view.onSelect = { index, label, data ->
33
+ dispatcher?.dispatchEvent(SelectEvent(view.id, index, label, data))
34
+ }
35
+
36
+ view.onRequestClose = {
37
+ dispatcher?.dispatchEvent(RequestCloseEvent(view.id))
38
+ }
39
+ }
40
+
41
+ // options: array of {label,data}
42
+ override fun setOptions(view: PCSelectionMenuView, value: ReadableArray?) {
43
+ val out = ArrayList<PCSelectionMenuView.Option>()
44
+ if (value != null) {
45
+ for (i in 0 until value.size()) {
46
+ val m = value.getMap(i) ?: continue
47
+ val label = if (m.hasKey("label") && !m.isNull("label")) m.getString("label") ?: "" else ""
48
+ val data = if (m.hasKey("data") && !m.isNull("data")) m.getString("data") ?: "" else ""
49
+ out.add(PCSelectionMenuView.Option(label = label, data = data))
50
+ }
51
+ }
52
+ view.applyOptions(out)
53
+ }
54
+
55
+ override fun setSelectedData(view: PCSelectionMenuView, value: String?) {
56
+ // Spec sentinel: empty string means "no selection"
57
+ view.applySelectedData(value ?: "")
58
+ }
59
+
60
+ override fun setInteractivity(view: PCSelectionMenuView, value: String?) {
61
+ view.applyInteractivity(value)
62
+ }
63
+
64
+ override fun setPlaceholder(view: PCSelectionMenuView, value: String?) {
65
+ view.applyPlaceholder(value)
66
+ }
67
+
68
+ override fun setAnchorMode(view: PCSelectionMenuView, value: String?) {
69
+ view.applyAnchorMode(value)
70
+ }
71
+
72
+ override fun setVisible(view: PCSelectionMenuView, value: String?) {
73
+ view.applyVisible(value)
74
+ }
75
+
76
+ override fun setPresentation(view: PCSelectionMenuView, value: String?) {
77
+ view.applyPresentation(value)
78
+ }
79
+
80
+ override fun setAndroid(view: PCSelectionMenuView, value: ReadableMap?) {
81
+ val material =
82
+ if (value != null && value.hasKey("material") && !value.isNull("material")) value.getString("material") else null
83
+ view.applyAndroidMaterial(material)
84
+ }
85
+
86
+ override fun setIos(view: PCSelectionMenuView, value: ReadableMap?) {
87
+ // Android ignores iOS config
88
+ }
89
+
90
+ // --- Events ---
91
+ private class SelectEvent(
92
+ surfaceId: Int,
93
+ private val index: Int,
94
+ private val label: String,
95
+ private val data: String
96
+ ) : Event<SelectEvent>(surfaceId) {
97
+ override fun getEventName(): String = "topSelect"
98
+ override fun dispatch(rctEventEmitter: RCTEventEmitter) {
99
+ val payload = com.facebook.react.bridge.Arguments.createMap().apply {
100
+ putInt("index", index)
101
+ putString("label", label)
102
+ putString("data", data)
103
+ }
104
+ rctEventEmitter.receiveEvent(viewTag, eventName, payload)
105
+ }
106
+ }
107
+
108
+ private class RequestCloseEvent(surfaceId: Int) : Event<RequestCloseEvent>(surfaceId) {
109
+ override fun getEventName(): String = "topRequestClose"
110
+ override fun dispatch(rctEventEmitter: RCTEventEmitter) {
111
+ rctEventEmitter.receiveEvent(viewTag, eventName, com.facebook.react.bridge.Arguments.createMap())
112
+ }
113
+ }
114
+ }