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.
- package/LICENSE +20 -0
- package/PlatformComponents.podspec +20 -0
- package/README.md +233 -0
- package/android/build.gradle +78 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/platformcomponents/DateConstraints.kt +83 -0
- package/android/src/main/java/com/platformcomponents/Helper.kt +27 -0
- package/android/src/main/java/com/platformcomponents/PCDatePickerView.kt +684 -0
- package/android/src/main/java/com/platformcomponents/PCDatePickerViewManager.kt +149 -0
- package/android/src/main/java/com/platformcomponents/PCMaterialMode.kt +16 -0
- package/android/src/main/java/com/platformcomponents/PCSelectionMenuView.kt +410 -0
- package/android/src/main/java/com/platformcomponents/PCSelectionMenuViewManager.kt +114 -0
- package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +22 -0
- package/ios/PCDatePicker.h +11 -0
- package/ios/PCDatePicker.mm +248 -0
- package/ios/PCDatePickerView.swift +405 -0
- package/ios/PCSelectionMenu.h +10 -0
- package/ios/PCSelectionMenu.mm +182 -0
- package/ios/PCSelectionMenu.swift +434 -0
- package/lib/module/DatePicker.js +74 -0
- package/lib/module/DatePicker.js.map +1 -0
- package/lib/module/DatePickerNativeComponent.ts +68 -0
- package/lib/module/SelectionMenu.js +79 -0
- package/lib/module/SelectionMenu.js.map +1 -0
- package/lib/module/SelectionMenu.web.js +57 -0
- package/lib/module/SelectionMenu.web.js.map +1 -0
- package/lib/module/SelectionMenuNativeComponent.ts +106 -0
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/sharedTypes.js +4 -0
- package/lib/module/sharedTypes.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/DatePicker.d.ts +38 -0
- package/lib/typescript/src/DatePicker.d.ts.map +1 -0
- package/lib/typescript/src/DatePickerNativeComponent.d.ts +53 -0
- package/lib/typescript/src/DatePickerNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/SelectionMenu.d.ts +50 -0
- package/lib/typescript/src/SelectionMenu.d.ts.map +1 -0
- package/lib/typescript/src/SelectionMenu.web.d.ts +19 -0
- package/lib/typescript/src/SelectionMenu.web.d.ts.map +1 -0
- package/lib/typescript/src/SelectionMenuNativeComponent.d.ts +85 -0
- package/lib/typescript/src/SelectionMenuNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +4 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/sharedTypes.d.ts +10 -0
- package/lib/typescript/src/sharedTypes.d.ts.map +1 -0
- package/package.json +178 -0
- package/shared/PCDatePickerComponentDescriptors-custom.h +52 -0
- package/shared/PCDatePickerShadowNode-custom.cpp +1 -0
- package/shared/PCDatePickerShadowNode-custom.h +27 -0
- package/shared/PCDatePickerState-custom.h +13 -0
- package/shared/PCSelectionMenuComponentDescriptors-custom.h +25 -0
- package/shared/PCSelectionMenuShadowNode-custom.cpp +36 -0
- package/shared/PCSelectionMenuShadowNode-custom.h +46 -0
- package/src/DatePicker.tsx +146 -0
- package/src/DatePickerNativeComponent.ts +68 -0
- package/src/SelectionMenu.tsx +170 -0
- package/src/SelectionMenu.web.tsx +93 -0
- package/src/SelectionMenuNativeComponent.ts +106 -0
- package/src/index.tsx +3 -0
- 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
|
+
}
|