react-native-platform-components 0.6.0 → 0.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 (99) hide show
  1. package/README.md +211 -44
  2. package/android/src/main/java/com/platformcomponents/PCContextMenuView.kt +2 -2
  3. package/android/src/main/java/com/platformcomponents/PCSegmentedControlView.kt +241 -0
  4. package/android/src/main/java/com/platformcomponents/PCSegmentedControlViewManager.kt +105 -0
  5. package/android/src/main/java/com/platformcomponents/PCSelectionMenuView.kt +4 -0
  6. package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +1 -0
  7. package/app.plugin.cjs +4 -0
  8. package/expo-module.config.json +4 -0
  9. package/ios/PCContextMenu.swift +65 -22
  10. package/ios/PCDatePickerView.swift +28 -11
  11. package/ios/PCSegmentedControl.h +10 -0
  12. package/ios/PCSegmentedControl.mm +194 -0
  13. package/ios/PCSegmentedControl.swift +200 -0
  14. package/lib/commonjs/ContextMenu.js +118 -0
  15. package/lib/commonjs/ContextMenu.js.map +1 -0
  16. package/lib/commonjs/ContextMenuNativeComponent.ts +141 -0
  17. package/lib/commonjs/DatePicker.js +86 -0
  18. package/lib/commonjs/DatePicker.js.map +1 -0
  19. package/lib/commonjs/DatePickerNativeComponent.ts +69 -0
  20. package/lib/commonjs/SegmentedControl.js +93 -0
  21. package/lib/commonjs/SegmentedControl.js.map +1 -0
  22. package/lib/commonjs/SegmentedControlNativeComponent.ts +79 -0
  23. package/lib/commonjs/SelectionMenu.js +73 -0
  24. package/lib/commonjs/SelectionMenu.js.map +1 -0
  25. package/lib/commonjs/SelectionMenuNativeComponent.ts +97 -0
  26. package/lib/commonjs/index.js +61 -0
  27. package/lib/commonjs/index.js.map +1 -0
  28. package/lib/commonjs/package.json +1 -0
  29. package/lib/commonjs/sharedTypes.js +6 -0
  30. package/lib/commonjs/sharedTypes.js.map +1 -0
  31. package/lib/module/SegmentedControl.js +87 -0
  32. package/lib/module/SegmentedControl.js.map +1 -0
  33. package/lib/module/SegmentedControlNativeComponent.ts +79 -0
  34. package/lib/module/index.js +1 -0
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/typescript/commonjs/package.json +1 -0
  37. package/lib/typescript/commonjs/src/ContextMenu.d.ts.map +1 -0
  38. package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts.map +1 -0
  39. package/lib/typescript/commonjs/src/DatePicker.d.ts.map +1 -0
  40. package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/src/SegmentedControl.d.ts +62 -0
  42. package/lib/typescript/commonjs/src/SegmentedControl.d.ts.map +1 -0
  43. package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts +63 -0
  44. package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts.map +1 -0
  45. package/lib/typescript/commonjs/src/SelectionMenu.d.ts.map +1 -0
  46. package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts.map +1 -0
  47. package/lib/typescript/{src → commonjs/src}/index.d.ts +1 -0
  48. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  49. package/lib/typescript/commonjs/src/sharedTypes.d.ts.map +1 -0
  50. package/lib/typescript/module/src/ContextMenu.d.ts +79 -0
  51. package/lib/typescript/module/src/ContextMenu.d.ts.map +1 -0
  52. package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts +122 -0
  53. package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts.map +1 -0
  54. package/lib/typescript/module/src/DatePicker.d.ts +40 -0
  55. package/lib/typescript/module/src/DatePicker.d.ts.map +1 -0
  56. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts +54 -0
  57. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts.map +1 -0
  58. package/lib/typescript/module/src/SegmentedControl.d.ts +62 -0
  59. package/lib/typescript/module/src/SegmentedControl.d.ts.map +1 -0
  60. package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts +63 -0
  61. package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts.map +1 -0
  62. package/lib/typescript/module/src/SelectionMenu.d.ts +47 -0
  63. package/lib/typescript/module/src/SelectionMenu.d.ts.map +1 -0
  64. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts +78 -0
  65. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts.map +1 -0
  66. package/lib/typescript/module/src/index.d.ts +6 -0
  67. package/lib/typescript/module/src/index.d.ts.map +1 -0
  68. package/lib/typescript/module/src/sharedTypes.d.ts +12 -0
  69. package/lib/typescript/module/src/sharedTypes.d.ts.map +1 -0
  70. package/package.json +32 -12
  71. package/plugin/build/index.cjs +26 -0
  72. package/plugin/build/index.d.ts +22 -0
  73. package/plugin/build/index.d.ts.map +1 -0
  74. package/plugin/tsconfig.json +16 -0
  75. package/react-native.config.js +1 -0
  76. package/shared/PCSegmentedControlComponentDescriptors-custom.h +22 -0
  77. package/shared/PCSegmentedControlShadowNode-custom.cpp +54 -0
  78. package/shared/PCSegmentedControlShadowNode-custom.h +56 -0
  79. package/shared/PCSegmentedControlState-custom.h +62 -0
  80. package/shared/react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h +1 -0
  81. package/src/SegmentedControl.tsx +178 -0
  82. package/src/SegmentedControlNativeComponent.ts +79 -0
  83. package/src/index.tsx +1 -0
  84. package/lib/typescript/src/ContextMenu.d.ts.map +0 -1
  85. package/lib/typescript/src/ContextMenuNativeComponent.d.ts.map +0 -1
  86. package/lib/typescript/src/DatePicker.d.ts.map +0 -1
  87. package/lib/typescript/src/DatePickerNativeComponent.d.ts.map +0 -1
  88. package/lib/typescript/src/SelectionMenu.d.ts.map +0 -1
  89. package/lib/typescript/src/SelectionMenuNativeComponent.d.ts.map +0 -1
  90. package/lib/typescript/src/index.d.ts.map +0 -1
  91. package/lib/typescript/src/sharedTypes.d.ts.map +0 -1
  92. /package/lib/typescript/{src → commonjs/src}/ContextMenu.d.ts +0 -0
  93. /package/lib/typescript/{src → commonjs/src}/ContextMenuNativeComponent.d.ts +0 -0
  94. /package/lib/typescript/{src → commonjs/src}/DatePicker.d.ts +0 -0
  95. /package/lib/typescript/{src → commonjs/src}/DatePickerNativeComponent.d.ts +0 -0
  96. /package/lib/typescript/{src → commonjs/src}/SelectionMenu.d.ts +0 -0
  97. /package/lib/typescript/{src → commonjs/src}/SelectionMenuNativeComponent.d.ts +0 -0
  98. /package/lib/typescript/{src → commonjs/src}/sharedTypes.d.ts +0 -0
  99. /package/lib/typescript/{package.json → module/package.json} +0 -0
package/README.md CHANGED
@@ -3,55 +3,59 @@
3
3
  [![npm version](https://img.shields.io/npm/v/react-native-platform-components.svg)](https://www.npmjs.com/package/react-native-platform-components)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/react-native-platform-components.svg)](https://www.npmjs.com/package/react-native-platform-components)
5
5
 
6
+ High-quality **native UI components for React Native**, implemented with platform-first APIs and exposed through clean, typed JavaScript interfaces.
7
+
8
+ This library focuses on **true native behavior**, not JavaScript re-implementations.
9
+
6
10
  <table>
7
11
  <tr>
8
- <td valign="top">
9
- <table>
10
- <tr>
11
- <td align="center"><strong>iOS DatePicker</strong></td>
12
- <td align="center"><strong>Android DatePicker</strong></td>
13
- </tr>
14
- <tr>
15
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-datepicker.gif" height="350" /></td>
16
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-datepicker.gif" height="350" /></td>
17
- </tr>
18
- <tr>
19
- <td align="center"><strong>iOS ContextMenu</strong></td>
20
- <td align="center"><strong>Android ContextMenu</strong></td>
21
- </tr>
22
- <tr>
23
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-contextmenu.gif" height="350" /></td>
24
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-contextmenu.gif" height="350" /></td>
25
- </tr>
26
- <tr>
27
- <td align="center"><strong>iOS SelectionMenu</strong></td>
28
- <td align="center"><strong>Android SelectionMenu</strong></td>
29
- </tr>
30
- <tr>
31
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-selectionmenu.gif" height="350" /></td>
32
- <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-selectionmenu.gif" height="350" /></td>
33
- </tr>
34
- </table>
35
- </td>
36
- <td valign="top">
37
- <p>High-quality <strong>native UI components for React Native</strong>, implemented with platform-first APIs and exposed through clean, typed JavaScript interfaces.</p>
38
- <p>This library focuses on <strong>true native behavior</strong>, not JavaScript re-implementations — providing:</p>
39
- <ul>
40
- <li><strong>DatePicker</strong> – native date & time pickers with modal and embedded presentations</li>
41
- <li><strong>ContextMenu</strong> – native context menus with long-press activation (UIContextMenuInteraction on iOS, PopupMenu on Android)</li>
42
- <li><strong>SelectionMenu</strong> – native selection menus (Material on Android, system menus on iOS)</li>
43
- </ul>
44
- <p>The goal is to provide components that:</p>
45
- <ul>
46
- <li>Feel <strong>100% native</strong> on each platform</li>
47
- <li>Support modern platform design systems (Material 3 on Android, system pickers on iOS)</li>
48
- <li>Offer <strong>headless</strong> and <strong>inline</strong> modes for maximum layout control</li>
49
- <li>Integrate cleanly with <strong>React Native Codegen / Fabric</strong></li>
50
- </ul>
51
- </td>
12
+ <td align="center"><strong>iOS DatePicker</strong></td>
13
+ <td align="center"><strong>Android DatePicker</strong></td>
14
+ </tr>
15
+ <tr>
16
+ <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-datepicker.gif" height="550" /></td>
17
+ <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-datepicker.gif" height="550" /></td>
18
+ </tr>
19
+ <tr>
20
+ <td align="center"><strong>iOS ContextMenu</strong></td>
21
+ <td align="center"><strong>Android ContextMenu</strong></td>
22
+ </tr>
23
+ <tr>
24
+ <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-contextmenu.gif" height="550" /></td>
25
+ <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-contextmenu.gif" height="550" /></td>
26
+ </tr>
27
+ <tr>
28
+ <td align="center"><strong>iOS SelectionMenu</strong></td>
29
+ <td align="center"><strong>Android SelectionMenu</strong></td>
30
+ </tr>
31
+ <tr>
32
+ <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-selectionmenu.gif" height="550" /></td>
33
+ <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-selectionmenu.gif" height="550" /></td>
34
+ </tr>
35
+ <tr>
36
+ <td align="center"><strong>iOS SegmentedControl</strong></td>
37
+ <td align="center"><strong>Android SegmentedControl</strong></td>
38
+ </tr>
39
+ <tr>
40
+ <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/ios-segmentedcontrol.gif" height="550" /></td>
41
+ <td><img src="https://raw.githubusercontent.com/JarX-Concepts/react-native-platform-components/main/assets/android-segmentedcontrol.gif" height="550" /></td>
52
42
  </tr>
53
43
  </table>
54
44
 
45
+ ### Components
46
+
47
+ - **DatePicker** – native date & time pickers with modal and embedded presentations
48
+ - **ContextMenu** – native context menus with long-press activation (UIContextMenuInteraction on iOS, PopupMenu on Android)
49
+ - **SelectionMenu** – native selection menus (Material on Android, system menus on iOS)
50
+ - **SegmentedControl** – native segmented controls (UISegmentedControl on iOS, MaterialButtonToggleGroup on Android)
51
+
52
+ ### Goals
53
+
54
+ - Feel **100% native** on each platform
55
+ - Support modern platform design systems (Material 3 on Android, system pickers on iOS)
56
+ - Offer **headless** and **inline** modes for maximum layout control
57
+ - Integrate cleanly with **React Native Codegen / Fabric**
58
+
55
59
  ---
56
60
 
57
61
  ## Installation
@@ -78,6 +82,64 @@ pod install
78
82
  - Supports **Material 3** styling
79
83
  - No additional setup required beyond autolinking
80
84
 
85
+ ### Expo (Managed Workflow)
86
+
87
+ > **Note:** This library is **not supported in Expo Go**. It requires native code and must be used with [Expo Dev Client](https://docs.expo.dev/develop/development-builds/introduction/) or EAS Build.
88
+
89
+ ```sh
90
+ npx expo install react-native-platform-components
91
+ npx expo prebuild
92
+ npx expo run:ios
93
+ # or
94
+ npx expo run:android
95
+ ```
96
+
97
+ The library includes an Expo config plugin that handles all native configuration automatically. No manual native setup is required.
98
+
99
+ **EAS Build:**
100
+
101
+ ```sh
102
+ eas build --platform ios
103
+ eas build --platform android
104
+ ```
105
+
106
+ **Config Plugin:**
107
+
108
+ Add to your `app.json`:
109
+ ```json
110
+ {
111
+ "expo": {
112
+ "plugins": [
113
+ ["react-native-platform-components/app.plugin", {}]
114
+ ]
115
+ }
116
+ }
117
+ ```
118
+
119
+ For a complete working example, see the [`example-expo/`](./example-expo) directory.
120
+
121
+ ---
122
+
123
+ ## React Native New Architecture
124
+
125
+ This library is built for the **React Native New Architecture** (Fabric + TurboModules).
126
+
127
+ | Feature | Status |
128
+ |---------|--------|
129
+ | Fabric (New Renderer) | Supported |
130
+ | Codegen | Used for type-safe native bindings |
131
+ | TurboModules | N/A (view components only) |
132
+ | Old Architecture | Not supported |
133
+
134
+ **Tested with:**
135
+ - React Native 0.81+ (bare and Expo)
136
+ - Expo SDK 54+
137
+
138
+ **Requirements:**
139
+ - New Architecture must be enabled in your app
140
+ - For bare React Native: set `newArchEnabled=true` in `gradle.properties` (Android) and use the `RCT_NEW_ARCH_ENABLED` flag (iOS)
141
+ - For Expo: set `"newArchEnabled": true` in `app.json`
142
+
81
143
  ---
82
144
 
83
145
  ## Quick Start
@@ -267,6 +329,65 @@ export function Example() {
267
329
 
268
330
  ---
269
331
 
332
+ ### SegmentedControl
333
+
334
+ ```tsx
335
+ import { SegmentedControl } from 'react-native-platform-components';
336
+
337
+ const segments = [
338
+ { label: 'Day', value: 'day' },
339
+ { label: 'Week', value: 'week' },
340
+ { label: 'Month', value: 'month' },
341
+ ];
342
+
343
+ export function Example() {
344
+ const [selected, setSelected] = React.useState('day');
345
+
346
+ return (
347
+ <SegmentedControl
348
+ segments={segments}
349
+ selectedValue={selected}
350
+ onSelect={(value) => setSelected(value)}
351
+ />
352
+ );
353
+ }
354
+ ```
355
+
356
+ ### SegmentedControl (With Icons)
357
+
358
+ ```tsx
359
+ import { SegmentedControl } from 'react-native-platform-components';
360
+ import { Platform } from 'react-native';
361
+
362
+ const segments = [
363
+ {
364
+ label: 'List',
365
+ value: 'list',
366
+ icon: Platform.OS === 'ios' ? 'list.bullet' : 'list_bullet',
367
+ },
368
+ {
369
+ label: 'Grid',
370
+ value: 'grid',
371
+ icon: Platform.OS === 'ios' ? 'square.grid.2x2' : 'grid_view',
372
+ },
373
+ ];
374
+
375
+ export function Example() {
376
+ const [selected, setSelected] = React.useState('list');
377
+
378
+ return (
379
+ <SegmentedControl
380
+ segments={segments}
381
+ selectedValue={selected}
382
+ onSelect={(value) => setSelected(value)}
383
+ ios={{ apportionsSegmentWidthsByContent: true }}
384
+ />
385
+ );
386
+ }
387
+ ```
388
+
389
+ ---
390
+
270
391
  ## Components
271
392
 
272
393
  ## DatePicker
@@ -392,6 +513,50 @@ Native selection menu with **modal** and **embedded** modes.
392
513
 
393
514
  ---
394
515
 
516
+ ## SegmentedControl
517
+
518
+ Native segmented control using **UISegmentedControl** on iOS and **MaterialButtonToggleGroup** on Android.
519
+
520
+ ### Props
521
+
522
+ | Prop | Type | Description |
523
+ |------|------|-------------|
524
+ | `segments` | `SegmentedControlSegment[]` | Array of segments to display |
525
+ | `selectedValue` | `string \| null` | Currently selected segment's `value` |
526
+ | `disabled` | `boolean` | Disables the entire control |
527
+ | `onSelect` | `(value: string, index: number) => void` | Called when user selects a segment |
528
+
529
+ ### SegmentedControlSegment
530
+
531
+ | Property | Type | Description |
532
+ |----------|------|-------------|
533
+ | `label` | `string` | Display text for the segment |
534
+ | `value` | `string` | Unique value returned in callbacks |
535
+ | `disabled` | `boolean` | Disables this specific segment |
536
+ | `icon` | `string` | Icon name (SF Symbol on iOS, drawable on Android) |
537
+
538
+ ### iOS Props (`ios`)
539
+
540
+ | Prop | Type | Description |
541
+ |------|------|-------------|
542
+ | `momentary` | `boolean` | If true, segments don't show selected state |
543
+ | `apportionsSegmentWidthsByContent` | `boolean` | If true, segment widths are proportional to content |
544
+ | `selectedSegmentTintColor` | `string` | Tint color for selected segment (hex string) |
545
+
546
+ ### Android Props (`android`)
547
+
548
+ | Prop | Type | Description |
549
+ |------|------|-------------|
550
+ | `selectionRequired` | `boolean` | If true, one segment must always be selected |
551
+
552
+ ### Icon Support
553
+
554
+ Icons work the same as ContextMenu:
555
+ - **iOS**: Use SF Symbol names (e.g., `'list.bullet'`, `'square.grid.2x2'`)
556
+ - **Android**: Use drawable resource names (e.g., `'list_bullet'`, `'grid_view'`)
557
+
558
+ ---
559
+
395
560
  ## Design Philosophy
396
561
 
397
562
  - **Native first** — no JS re-implementation of pickers
@@ -496,6 +661,8 @@ const actions = [
496
661
 
497
662
  See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the repository and the development workflow.
498
663
 
664
+ **Have a component request?** If there's a native UI component you'd like to see added, [open an issue](https://github.com/JarX-Concepts/react-native-platform-components/issues/new) describing the component and its native APIs on iOS and Android.
665
+
499
666
  ## License
500
667
 
501
668
  MIT
@@ -9,12 +9,12 @@ import android.view.Menu
9
9
  import android.view.MotionEvent
10
10
  import android.view.View
11
11
  import android.view.ViewConfiguration
12
- import android.widget.FrameLayout
13
12
  import androidx.appcompat.widget.PopupMenu
14
13
  import androidx.core.content.ContextCompat
15
14
  import androidx.core.graphics.drawable.DrawableCompat
15
+ import com.facebook.react.views.view.ReactViewGroup
16
16
 
17
- class PCContextMenuView(context: Context) : FrameLayout(context) {
17
+ class PCContextMenuView(context: Context) : ReactViewGroup(context) {
18
18
 
19
19
  data class Action(
20
20
  val id: String,
@@ -0,0 +1,241 @@
1
+ package com.platformcomponents
2
+
3
+ import android.content.Context
4
+ import android.text.TextUtils
5
+ import android.view.View
6
+ import android.widget.FrameLayout
7
+ import com.facebook.react.bridge.WritableNativeMap
8
+ import com.facebook.react.uimanager.PixelUtil
9
+ import com.facebook.react.uimanager.StateWrapper
10
+ import com.facebook.react.views.scroll.ReactScrollViewHelper
11
+ import com.google.android.material.button.MaterialButton
12
+ import com.google.android.material.button.MaterialButtonToggleGroup
13
+
14
+ class PCSegmentedControlView(context: Context) : FrameLayout(context), ReactScrollViewHelper.HasStateWrapper {
15
+
16
+ data class Segment(
17
+ val label: String,
18
+ val value: String,
19
+ val disabled: Boolean,
20
+ val icon: String
21
+ )
22
+
23
+ companion object {
24
+ private const val TAG = "PCSegmentedControl"
25
+ }
26
+
27
+ // --- State Wrapper for Fabric state updates ---
28
+ override var stateWrapper: StateWrapper? = null
29
+
30
+ private var lastReportedWidth: Float = 0f
31
+ private var lastReportedHeight: Float = 0f
32
+
33
+ // --- Props ---
34
+ var segments: List<Segment> = emptyList()
35
+ var selectedValue: String = "" // sentinel for none
36
+ var interactivity: String = "enabled" // "enabled" | "disabled"
37
+ var selectionRequired: Boolean = false
38
+
39
+ // --- Events ---
40
+ var onSelect: ((index: Int, value: String) -> Unit)? = null
41
+
42
+ // --- UI ---
43
+ private var toggleGroup: MaterialButtonToggleGroup? = null
44
+ private val buttonIdToSegment: MutableMap<Int, Segment> = mutableMapOf()
45
+ private var suppressCallbacks = false
46
+
47
+ init {
48
+ minimumHeight = (PCConstants.MIN_TOUCH_TARGET_HEIGHT_DP * resources.displayMetrics.density).toInt()
49
+ rebuildUI()
50
+ }
51
+
52
+ // ---- Public apply* (called by manager) ----
53
+
54
+ fun applySegments(newSegments: List<Segment>) {
55
+ if (segments == newSegments) return
56
+ segments = newSegments
57
+ rebuildUI()
58
+ }
59
+
60
+ fun applySelectedValue(value: String) {
61
+ if (selectedValue == value) return
62
+ selectedValue = value
63
+ updateSelection()
64
+ }
65
+
66
+ fun applyInteractivity(value: String?) {
67
+ val newValue = if (value == "disabled") "disabled" else "enabled"
68
+ if (interactivity == newValue) return
69
+ interactivity = newValue
70
+ updateEnabled()
71
+ }
72
+
73
+ fun applyAndroidProps(required: Boolean) {
74
+ if (selectionRequired != required) {
75
+ selectionRequired = required
76
+ rebuildUI()
77
+ }
78
+ }
79
+
80
+ // ---- UI Building ----
81
+
82
+ private fun rebuildUI() {
83
+ removeAllViews()
84
+ buttonIdToSegment.clear()
85
+
86
+ val group = MaterialButtonToggleGroup(context).apply {
87
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
88
+ isSingleSelection = true
89
+ isSelectionRequired = selectionRequired
90
+ }
91
+
92
+ // Calculate if we need compact mode (many segments or long labels)
93
+ val totalLabelLength = segments.sumOf { it.label.length }
94
+ val useCompactMode = segments.size > 3 || totalLabelLength > 20
95
+
96
+ for ((index, segment) in segments.withIndex()) {
97
+ val button = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
98
+ id = View.generateViewId()
99
+ text = segment.label
100
+ isAllCaps = false // Preserve original text casing
101
+ contentDescription = segment.label // For accessibility and Detox matching
102
+ isEnabled = !segment.disabled && interactivity == "enabled"
103
+
104
+ // Enable text truncation with ellipsis when space is limited
105
+ ellipsize = TextUtils.TruncateAt.END
106
+ maxLines = 1
107
+
108
+ // Reduce horizontal padding in compact mode to fit more content
109
+ if (useCompactMode) {
110
+ val compactPadding = (8 * resources.displayMetrics.density).toInt()
111
+ setPaddingRelative(compactPadding, paddingTop, compactPadding, paddingBottom)
112
+ iconPadding = (4 * resources.displayMetrics.density).toInt()
113
+ }
114
+
115
+ // Set icon if available
116
+ if (segment.icon.isNotEmpty()) {
117
+ val resId = context.resources.getIdentifier(
118
+ segment.icon, "drawable", context.packageName
119
+ )
120
+ if (resId != 0) {
121
+ setIconResource(resId)
122
+ }
123
+ }
124
+
125
+ // Handle click to trigger selection (needed for Detox taps)
126
+ setOnClickListener {
127
+ if (!suppressCallbacks && isEnabled) {
128
+ group.check(id)
129
+ }
130
+ }
131
+ }
132
+
133
+ buttonIdToSegment[button.id] = segment
134
+ group.addView(button)
135
+ }
136
+
137
+ group.addOnButtonCheckedListener { _, checkedId, isChecked ->
138
+ if (suppressCallbacks) return@addOnButtonCheckedListener
139
+ if (!isChecked) return@addOnButtonCheckedListener
140
+
141
+ val segment = buttonIdToSegment[checkedId] ?: return@addOnButtonCheckedListener
142
+ val index = segments.indexOf(segment)
143
+ if (index >= 0) {
144
+ onSelect?.invoke(index, segment.value)
145
+ }
146
+ }
147
+
148
+ addView(group)
149
+ toggleGroup = group
150
+
151
+ updateSelection()
152
+ updateEnabled()
153
+ requestLayout()
154
+ }
155
+
156
+ private fun updateSelection() {
157
+ suppressCallbacks = true
158
+ val group = toggleGroup ?: return
159
+
160
+ if (selectedValue.isEmpty()) {
161
+ group.clearChecked()
162
+ } else {
163
+ for ((id, segment) in buttonIdToSegment) {
164
+ if (segment.value == selectedValue) {
165
+ group.check(id)
166
+ break
167
+ }
168
+ }
169
+ }
170
+ suppressCallbacks = false
171
+ }
172
+
173
+ private fun updateEnabled() {
174
+ val enabled = interactivity == "enabled"
175
+ alpha = if (enabled) 1f else 0.5f
176
+
177
+ val group = toggleGroup ?: return
178
+ for (i in 0 until group.childCount) {
179
+ val button = group.getChildAt(i) as? MaterialButton ?: continue
180
+ val segment = buttonIdToSegment[button.id] ?: continue
181
+ button.isEnabled = enabled && !segment.disabled
182
+ }
183
+ }
184
+
185
+ // ---- Measurement ----
186
+
187
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
188
+ // Measure children with UNSPECIFIED height to get intrinsic size
189
+ val unconstrainedHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
190
+ super.onMeasure(widthMeasureSpec, unconstrainedHeightSpec)
191
+
192
+ // Get the intrinsic height from children
193
+ val childHeight = if (toggleGroup != null) {
194
+ toggleGroup!!.measuredHeight
195
+ } else {
196
+ 0
197
+ }
198
+
199
+ // Use the maximum of child height and minimum touch target
200
+ val intrinsicHeight = childHeight.coerceAtLeast(minimumHeight)
201
+
202
+ // IMPORTANT: Always use intrinsic height regardless of Yoga constraints.
203
+ // Fabric may give us 0 height initially before state is updated.
204
+ // The state update from onLayout will trigger proper re-layout.
205
+ setMeasuredDimension(measuredWidth, intrinsicHeight)
206
+ }
207
+
208
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
209
+ super.onLayout(changed, left, top, right, bottom)
210
+ updateFrameSizeState()
211
+ }
212
+
213
+ /**
214
+ * Update Fabric state with the measured frame size.
215
+ * This allows the shadow node to use actual measured dimensions for Yoga layout.
216
+ */
217
+ private fun updateFrameSizeState() {
218
+ val wrapper = stateWrapper ?: return
219
+ val group = toggleGroup ?: return
220
+
221
+ // Measure the toggle group with exact width and unspecified height
222
+ val widthSpec = MeasureSpec.makeMeasureSpec(width.coerceAtLeast(1), MeasureSpec.EXACTLY)
223
+ val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
224
+ group.measure(widthSpec, heightSpec)
225
+
226
+ val widthDp = PixelUtil.toDIPFromPixel(width.toFloat())
227
+ val heightDp = PixelUtil.toDIPFromPixel(group.measuredHeight.toFloat())
228
+
229
+ // Only update if changed
230
+ if (widthDp != lastReportedWidth || heightDp != lastReportedHeight) {
231
+ lastReportedWidth = widthDp
232
+ lastReportedHeight = heightDp
233
+
234
+ val stateData = WritableNativeMap().apply {
235
+ putDouble("width", widthDp.toDouble())
236
+ putDouble("height", heightDp.toDouble())
237
+ }
238
+ wrapper.updateState(stateData)
239
+ }
240
+ }
241
+ }
@@ -0,0 +1,105 @@
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.ReactStylesDiffMap
6
+ import com.facebook.react.uimanager.SimpleViewManager
7
+ import com.facebook.react.uimanager.StateWrapper
8
+ import com.facebook.react.uimanager.ThemedReactContext
9
+ import com.facebook.react.uimanager.ViewManagerDelegate
10
+ import com.facebook.react.uimanager.UIManagerHelper
11
+ import com.facebook.react.uimanager.events.Event
12
+ import com.facebook.react.uimanager.events.RCTEventEmitter
13
+ import com.facebook.react.viewmanagers.PCSegmentedControlManagerDelegate
14
+ import com.facebook.react.viewmanagers.PCSegmentedControlManagerInterface
15
+
16
+ class PCSegmentedControlViewManager :
17
+ SimpleViewManager<PCSegmentedControlView>(),
18
+ PCSegmentedControlManagerInterface<PCSegmentedControlView> {
19
+
20
+ companion object {
21
+ private const val TAG = "PCSegmentedControl"
22
+ }
23
+
24
+ private val delegate: ViewManagerDelegate<PCSegmentedControlView> =
25
+ PCSegmentedControlManagerDelegate(this)
26
+
27
+ override fun getName(): String = "PCSegmentedControl"
28
+
29
+ override fun getDelegate(): ViewManagerDelegate<PCSegmentedControlView> = delegate
30
+
31
+ override fun createViewInstance(reactContext: ThemedReactContext): PCSegmentedControlView {
32
+ return PCSegmentedControlView(reactContext)
33
+ }
34
+
35
+ /**
36
+ * Pass the StateWrapper to the view so it can update Fabric state with measured dimensions.
37
+ */
38
+ override fun updateState(
39
+ view: PCSegmentedControlView,
40
+ props: ReactStylesDiffMap,
41
+ stateWrapper: StateWrapper
42
+ ): Any? {
43
+ view.stateWrapper = stateWrapper
44
+ return null
45
+ }
46
+
47
+ override fun addEventEmitters(reactContext: ThemedReactContext, view: PCSegmentedControlView) {
48
+ val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
49
+
50
+ view.onSelect = { index, value ->
51
+ dispatcher?.dispatchEvent(SelectEvent(view.id, index, value))
52
+ }
53
+ }
54
+
55
+ // segments: array of {label, value, disabled, icon}
56
+ override fun setSegments(view: PCSegmentedControlView, value: ReadableArray?) {
57
+ val out = ArrayList<PCSegmentedControlView.Segment>()
58
+ if (value != null) {
59
+ for (i in 0 until value.size()) {
60
+ val m = value.getMap(i) ?: continue
61
+ val label = if (m.hasKey("label") && !m.isNull("label")) m.getString("label") ?: "" else ""
62
+ val segValue = if (m.hasKey("value") && !m.isNull("value")) m.getString("value") ?: "" else ""
63
+ val disabled = m.hasKey("disabled") && !m.isNull("disabled") && m.getString("disabled") == "disabled"
64
+ val icon = if (m.hasKey("icon") && !m.isNull("icon")) m.getString("icon") ?: "" else ""
65
+ out.add(PCSegmentedControlView.Segment(label = label, value = segValue, disabled = disabled, icon = icon))
66
+ }
67
+ }
68
+ view.applySegments(out)
69
+ }
70
+
71
+ override fun setSelectedValue(view: PCSegmentedControlView, value: String?) {
72
+ // Spec sentinel: empty string means "no selection"
73
+ view.applySelectedValue(value ?: "")
74
+ }
75
+
76
+ override fun setInteractivity(view: PCSegmentedControlView, value: String?) {
77
+ view.applyInteractivity(value)
78
+ }
79
+
80
+ override fun setAndroid(view: PCSegmentedControlView, value: ReadableMap?) {
81
+ val selectionRequired = value != null && value.hasKey("selectionRequired") &&
82
+ !value.isNull("selectionRequired") && value.getString("selectionRequired") == "true"
83
+ view.applyAndroidProps(selectionRequired)
84
+ }
85
+
86
+ override fun setIos(view: PCSegmentedControlView, 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 value: String
95
+ ) : Event<SelectEvent>(surfaceId) {
96
+ override fun getEventName(): String = "topSelect"
97
+ override fun dispatch(rctEventEmitter: RCTEventEmitter) {
98
+ val payload = com.facebook.react.bridge.Arguments.createMap().apply {
99
+ putInt("index", index)
100
+ putString("value", value)
101
+ }
102
+ rctEventEmitter.receiveEvent(viewTag, eventName, payload)
103
+ }
104
+ }
105
+ }
@@ -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
 
@@ -14,6 +14,7 @@ class PlatformComponentsViewPackage : ReactPackage {
14
14
  PCSelectionMenuViewManager(),
15
15
  PCDatePickerViewManager(),
16
16
  PCContextMenuViewManager(),
17
+ PCSegmentedControlViewManager(),
17
18
  )
18
19
  }
19
20