craft-native 0.0.296 → 0.0.301

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.
@@ -2736,6 +2736,51 @@ private class CraftNativeIconView(context: Context) : TextView(context) {
2736
2736
  }
2737
2737
  }
2738
2738
 
2739
+ /** A platform EditText with the native end clear affordance and TalkBack action. */
2740
+ private class CraftNativeTextField(context: Context) : EditText(context) {
2741
+ companion object { val CLEAR_ACTION_ID = View.generateViewId() }
2742
+ var clearButtonMode = "never"
2743
+ var onClearRequest: (() -> Unit)? = null
2744
+ private var clearPressed = false
2745
+ private fun showsClear() = isEnabled && keyListener != null && text?.isNotEmpty() == true && when (clearButtonMode) {
2746
+ "always" -> true
2747
+ "while-editing" -> isFocused
2748
+ "unless-editing" -> !isFocused
2749
+ else -> false
2750
+ }
2751
+ fun refreshClearButton() {
2752
+ val icon = if (showsClear()) context.getDrawable(android.R.drawable.ic_menu_close_clear_cancel)?.mutate() else null
2753
+ icon?.setTint(currentHintTextColor)
2754
+ setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, icon, null)
2755
+ }
2756
+ override fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int) {
2757
+ super.onTextChanged(text, start, lengthBefore, lengthAfter)
2758
+ refreshClearButton()
2759
+ }
2760
+ override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: android.graphics.Rect?) {
2761
+ super.onFocusChanged(focused, direction, previouslyFocusedRect)
2762
+ refreshClearButton()
2763
+ }
2764
+ override fun onTouchEvent(event: MotionEvent): Boolean {
2765
+ val inClear = showsClear() && if (layoutDirection == View.LAYOUT_DIRECTION_RTL) event.x <= compoundPaddingLeft else event.x >= width - compoundPaddingRight
2766
+ if (event.actionMasked == MotionEvent.ACTION_DOWN && inClear) { clearPressed = true; return true }
2767
+ if (clearPressed) {
2768
+ if (event.actionMasked == MotionEvent.ACTION_UP) { clearPressed = false; if (inClear) onClearRequest?.invoke(); return true }
2769
+ if (event.actionMasked == MotionEvent.ACTION_CANCEL) clearPressed = false
2770
+ return true
2771
+ }
2772
+ return super.onTouchEvent(event)
2773
+ }
2774
+ override fun onInitializeAccessibilityNodeInfo(info: android.view.accessibility.AccessibilityNodeInfo) {
2775
+ super.onInitializeAccessibilityNodeInfo(info)
2776
+ if (showsClear()) info.addAction(android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction(CLEAR_ACTION_ID, "Clear text"))
2777
+ }
2778
+ override fun performAccessibilityAction(action: Int, arguments: Bundle?): Boolean {
2779
+ if (action == CLEAR_ACTION_ID && showsClear()) { onClearRequest?.invoke(); return true }
2780
+ return super.performAccessibilityAction(action, arguments)
2781
+ }
2782
+ }
2783
+
2739
2784
  private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2740
2785
  val content = CraftNativeFlexLayout(context)
2741
2786
  private val blocker = View(context)
@@ -2745,6 +2790,16 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2745
2790
  var onRequestClose: ((String) -> Unit)? = null
2746
2791
  var viewportStyle = JSONObject()
2747
2792
  var declarationPath = ""
2793
+ var sheetFraction: Float? = null
2794
+
2795
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
2796
+ val fraction = sheetFraction
2797
+ val params = content.layoutParams as LayoutParams
2798
+ params.height = if (fraction == null) LayoutParams.MATCH_PARENT else (View.MeasureSpec.getSize(heightMeasureSpec) * fraction).toInt()
2799
+ params.gravity = Gravity.BOTTOM
2800
+ content.layoutParams = params
2801
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
2802
+ }
2748
2803
 
2749
2804
  var transparent: Boolean = false
2750
2805
  set(value) {
@@ -3095,18 +3150,28 @@ internal class CraftNativeInputEcho {
3095
3150
  private var rendered: String? = null
3096
3151
  private var initialized = false
3097
3152
 
3098
- fun record(value: String) {
3153
+ private var eventCount = 0
3154
+
3155
+ fun record(value: String): Int {
3099
3156
  pending.addLast(value)
3100
3157
  while (pending.size > 64) pending.removeFirst()
3158
+ return ++eventCount
3101
3159
  }
3102
3160
 
3103
- fun accept(value: String?, nativeValue: String): Boolean {
3161
+ fun accept(value: String?, nativeValue: String, acknowledged: Int? = null): Boolean {
3104
3162
  if (value == null) {
3105
3163
  initialized = false
3106
3164
  rendered = null
3107
3165
  pending.clear()
3108
3166
  return false
3109
3167
  }
3168
+ if (acknowledged != null && acknowledged >= 0) {
3169
+ if (acknowledged < eventCount) return false
3170
+ pending.clear()
3171
+ initialized = true
3172
+ rendered = value
3173
+ return nativeValue != value
3174
+ }
3110
3175
  // A retained field can render the same prop more than once, after
3111
3176
  // its echo was consumed. That repeated prop must not overwrite a
3112
3177
  // keystroke that has not made the native/JS round trip yet.
@@ -4866,6 +4931,7 @@ class MainActivity : AppCompatActivity() {
4866
4931
  put("viewScroll")
4867
4932
  put("viewReparent")
4868
4933
  put("viewNonModalPresentation")
4934
+ put("nativePlatformControls")
4869
4935
  }
4870
4936
 
4871
4937
  private fun appVersion(): Pair<String, String> = runCatching {
@@ -5360,7 +5426,7 @@ class MainActivity : AppCompatActivity() {
5360
5426
  previousId == null || previousId == protocolId || !screen.mutations.containsNode(previousId)
5361
5427
  }
5362
5428
  val previous = protocolId?.let { previousControls["id:$it"] } ?: keyedPrevious
5363
- val reusableInput = previous as? EditText
5429
+ val reusableInput = previous as? CraftNativeTextField
5364
5430
  val control = when (type) {
5365
5431
  "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
5366
5432
  includeFontPadding = false
@@ -5465,7 +5531,7 @@ class MainActivity : AppCompatActivity() {
5465
5531
  }
5466
5532
  }
5467
5533
  }
5468
- "TextInput" -> (reusableInput ?: EditText(this)).apply {
5534
+ "TextInput" -> (reusableInput ?: CraftNativeTextField(this)).apply {
5469
5535
  // Setting filters, the input type or single-line mode makes
5470
5536
  // TextView set its text again, which puts the caret back at
5471
5537
  // the start: typing then came out backwards. Each is set only
@@ -5531,9 +5597,21 @@ class MainActivity : AppCompatActivity() {
5531
5597
  // them after keyed reconciliation.
5532
5598
  val autofillHint = autofillHintFor(props)
5533
5599
  if (autofillHint == null) setAutofillHints(*emptyArray()) else setAutofillHints(autofillHint)
5600
+ clearButtonMode = craftNativeOptionalString(props.opt("clearButtonMode")) ?: "never"
5601
+ onClearRequest = {
5602
+ if (isEnabled && keyListener != null) {
5603
+ screen.suppressedTextChanges.add(this)
5604
+ setText("")
5605
+ val eventCount = screen.inputEchoes.getOrPut(this) { CraftNativeInputEcho() }.record("")
5606
+ screen.handlers[this]?.let { current ->
5607
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("text", "").put("clear", true).put("eventCount", eventCount)))
5608
+ }
5609
+ }
5610
+ }
5611
+ refreshClearButton()
5534
5612
  val controlledValue = props.opt("value") as? String
5535
5613
  val echoes = screen.inputEchoes.getOrPut(this) { CraftNativeInputEcho() }
5536
- if (echoes.accept(controlledValue, text.toString()) && controlledValue != null) {
5614
+ if (echoes.accept(controlledValue, text.toString(), craftNativeInt(props.opt("mostRecentEventCount"), -1).takeIf { it >= 0 }) && controlledValue != null) {
5537
5615
  screen.suppressedTextChanges.add(this)
5538
5616
  setTextPreservingSelection(this, controlledValue)
5539
5617
  }
@@ -5596,10 +5674,10 @@ class MainActivity : AppCompatActivity() {
5596
5674
  (screen.inputIdentities[this@apply] ?: identity).let { screen.drafts[it] = updated }
5597
5675
  if (screen.suppressedTextChanges.remove(this@apply)) return
5598
5676
  val current = screen.handlers[this@apply] ?: return
5599
- screen.inputEchoes.getOrPut(this@apply) { CraftNativeInputEcho() }.record(updated)
5677
+ val eventCount = screen.inputEchoes.getOrPut(this@apply) { CraftNativeInputEcho() }.record(updated)
5600
5678
  send(screen, "EVENT", JSONObject()
5601
5679
  .put("handlerName", current)
5602
- .put("nativeEvent", JSONObject().put("text", updated)))
5680
+ .put("nativeEvent", JSONObject().put("text", updated).put("eventCount", eventCount)))
5603
5681
  }
5604
5682
  override fun afterTextChanged(s: Editable?) {}
5605
5683
  }
@@ -5607,6 +5685,57 @@ class MainActivity : AppCompatActivity() {
5607
5685
  addTextChangedListener(watcher)
5608
5686
  }
5609
5687
  }
5688
+ "SegmentedControl" -> (previous as? android.widget.RadioGroup ?: android.widget.RadioGroup(this)).apply {
5689
+ orientation = android.widget.LinearLayout.HORIZONTAL
5690
+ // Reconciliation is silent. Only the platform's user selection
5691
+ // listener below emits a value, with the stable option value.
5692
+ setOnCheckedChangeListener(null)
5693
+ val items = props.optJSONArray("items") ?: JSONArray()
5694
+ val valuesChanged = childCount != items.length() || (0 until items.length()).any { index ->
5695
+ getChildAt(index)?.tag != craftNativePickerValue(items.optJSONObject(index)?.opt("value")).orEmpty()
5696
+ }
5697
+ if (valuesChanged) removeAllViews()
5698
+ val requested = props.opt("value") as? String ?: craftNativePickerValue(props.opt("value")) ?: props.opt("selectedValue") as? String ?: craftNativePickerValue(props.opt("selectedValue"))
5699
+ val disabled = craftNativeBoolean(props.opt("disabled"))
5700
+ val foreground = color(craftNativeOptionalString(style.opt("color")).orEmpty(), Color.BLACK)
5701
+ val values = mutableMapOf<Int, Pair<String, Int>>()
5702
+ for (index in 0 until items.length()) {
5703
+ val item = items.optJSONObject(index) ?: continue
5704
+ val value = craftNativePickerValue(item.opt("value")) ?: ""
5705
+ val segment = (getChildAt(index) as? android.widget.RadioButton ?: android.widget.RadioButton(this@MainActivity)).apply {
5706
+ if (id == View.NO_ID) id = View.generateViewId()
5707
+ tag = value
5708
+ text = craftNativeOptionalString(item.opt("label")) ?: value
5709
+ contentDescription = craftNativeOptionalString(item.opt("spoken")) ?: text
5710
+ isEnabled = !disabled && !craftNativeBoolean(item.opt("disabled"))
5711
+ buttonDrawable = null
5712
+ gravity = Gravity.CENTER
5713
+ setTextColor(foreground)
5714
+ textSize = 13f
5715
+ minHeight = (32 * resources.displayMetrics.density).toInt()
5716
+ setPadding((8 * resources.displayMetrics.density).toInt(), 0, (8 * resources.displayMetrics.density).toInt(), 0)
5717
+ val selected = GradientDrawable().apply {
5718
+ setColor(color(craftNativeOptionalString(props.opt("tintColor")).orEmpty(), 0x33767680))
5719
+ cornerRadius = 7 * resources.displayMetrics.density
5720
+ }
5721
+ background = android.graphics.drawable.StateListDrawable().apply {
5722
+ addState(intArrayOf(android.R.attr.state_checked), selected)
5723
+ addState(intArrayOf(), ColorDrawable(Color.TRANSPARENT))
5724
+ }
5725
+ isChecked = value == requested
5726
+ }
5727
+ values[segment.id] = value to index
5728
+ if (segment.parent == null) addView(segment, android.widget.RadioGroup.LayoutParams(0, android.view.ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
5729
+ }
5730
+ isEnabled = !disabled
5731
+ val handler = craftNativeEventName(events, "onValueChange", "onChange")
5732
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
5733
+ setOnCheckedChangeListener { _, checkedId ->
5734
+ val current = screen.handlers[this] ?: return@setOnCheckedChangeListener
5735
+ val (value, index) = values[checkedId] ?: return@setOnCheckedChangeListener
5736
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value).put("index", index)))
5737
+ }
5738
+ }
5610
5739
  "Picker" -> (previous as? Spinner ?: Spinner(this)).apply {
5611
5740
  val itemOptions = props.optJSONArray("items")?.let { items ->
5612
5741
  (0 until items.length()).mapNotNull { index ->
@@ -5676,6 +5805,14 @@ class MainActivity : AppCompatActivity() {
5676
5805
  // the screen root. Keep the authored layout style with that
5677
5806
  // promoted child so non-modal absolute coordinates remain in
5678
5807
  // the screen viewport rather than the old clipped parent.
5808
+ val sheetStyle = craftNativeOptionalString(props.opt("presentationStyle"))
5809
+ val detents = props.optJSONArray("sheetDetents")
5810
+ val firstDetent = detents?.optString(0)?.takeIf { it == "medium" || it == "large" } ?: "medium"
5811
+ val selectedDetent = craftNativeOptionalString(props.opt("selectedDetent")) ?: firstDetent
5812
+ val fraction = if (blocksBackground && sheetStyle in setOf("pageSheet", "formSheet")) {
5813
+ if (selectedDetent == "large") 0.92f else 0.5f
5814
+ } else null
5815
+ if (sheetFraction != fraction) { sheetFraction = fraction; requestLayout() }
5679
5816
  viewportStyle = style
5680
5817
  declarationPath = path
5681
5818
  screen.view.setChildStyle(this, style, resources.displayMetrics.density)
@@ -5767,7 +5904,10 @@ class MainActivity : AppCompatActivity() {
5767
5904
  intArrayOf(trueColor ?: resolvedFalse, resolvedFalse),
5768
5905
  )
5769
5906
  }
5770
- } ?: run { trackTintList = null }
5907
+ } ?: run {
5908
+ val tint = craftNativeOptionalString(style.opt("color"))?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5909
+ trackTintList = tint?.let { ColorStateList(arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), intArrayOf(it, Color.GRAY)) }
5910
+ }
5771
5911
  craftNativeOptionalString(props.opt("thumbColor"))
5772
5912
  ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5773
5913
  ?.let { thumbTintList = ColorStateList.valueOf(it) }
@@ -5853,7 +5993,7 @@ class MainActivity : AppCompatActivity() {
5853
5993
  val animating = craftNativeBoolean(props.opt("animating"), true)
5854
5994
  isIndeterminate = animating
5855
5995
  visibility = if (!animating && craftNativeBoolean(props.opt("hidesWhenStopped"), true)) View.GONE else View.VISIBLE
5856
- indeterminateTintList = craftNativeOptionalString(props.opt("color"))
5996
+ indeterminateTintList = (craftNativeOptionalString(props.opt("color")) ?: craftNativeOptionalString(style.opt("color")))
5857
5997
  ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5858
5998
  ?.let { ColorStateList.valueOf(it) }
5859
5999
  }
@@ -6085,7 +6225,7 @@ class MainActivity : AppCompatActivity() {
6085
6225
  val underlayHideHandler = if (type == "TouchableHighlight") craftNativeEventName(events, "onHideUnderlay").takeIf { it.isNotBlank() } else null
6086
6226
  if (underlayShowHandler != null) screen.pressUnderlayShowHandlers[control] = underlayShowHandler else screen.pressUnderlayShowHandlers.remove(control)
6087
6227
  if (underlayHideHandler != null) screen.pressUnderlayHideHandlers[control] = underlayHideHandler else screen.pressUnderlayHideHandlers.remove(control)
6088
- if (type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView" && type != "StatusBar" && type != "RefreshControl" && type != "Map") {
6228
+ if (type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "SegmentedControl" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView" && type != "StatusBar" && type != "RefreshControl" && type != "Map") {
6089
6229
  val handler = craftNativeEventName(events, "onPress", "onClick")
6090
6230
  val disabled = craftNativeBoolean(props.opt("disabled"))
6091
6231
  val target = listOf(props.opt("href"), props.opt("to"))
@@ -6250,6 +6390,7 @@ class MainActivity : AppCompatActivity() {
6250
6390
  "link" -> "Link"
6251
6391
  "checkbox" -> "Checkbox"
6252
6392
  "textinput", "input" -> "TextInput"
6393
+ "segmentedcontrol", "segmented-control" -> "SegmentedControl"
6253
6394
  "picker", "select" -> "Picker"
6254
6395
  "datepicker", "date-picker", "date" -> "DatePicker"
6255
6396
  "modal" -> "Modal"
@@ -7079,7 +7220,7 @@ class MainActivity : AppCompatActivity() {
7079
7220
  }
7080
7221
  val state = props.optJSONObject("accessibilityState")
7081
7222
  val disabled = craftNativeBoolean(props.opt("disabled")) || craftNativeBoolean(state?.opt("disabled"))
7082
- if (type !in setOf("Button", "Link", "Checkbox", "TextInput", "Picker", "DatePicker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
7223
+ if (type !in setOf("Button", "Link", "Checkbox", "TextInput", "Picker", "SegmentedControl", "DatePicker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
7083
7224
  view.isEnabled = !disabled
7084
7225
  } else if (disabled) {
7085
7226
  view.isEnabled = false
package/dist/cli.js CHANGED
@@ -2945,7 +2945,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
2945
2945
  `);
2946
2946
  }
2947
2947
  // package.json
2948
- var version = "0.0.296";
2948
+ var version = "0.0.301";
2949
2949
 
2950
2950
  // bin/cli.ts
2951
2951
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
@@ -721,6 +721,9 @@ export interface PressableProps extends ViewProps {
721
721
  * TextInput component props.
722
722
  */
723
723
  export interface TextInputProps extends BaseProps {
724
+ /** Runtime acknowledgement of the latest native edit; stale controlled echoes are ignored. */
725
+ mostRecentEventCount?: number;
726
+ clearButtonMode?: 'never' | 'while-editing' | 'unless-editing' | 'always';
724
727
  /** Style object or array */
725
728
  style?: StyleValue<TextStyle>;
726
729
  /** Current value */
@@ -831,6 +834,20 @@ export interface PickerProps extends BaseProps {
831
834
  onChange?: (value: string | number, index: number) => void;
832
835
  disabled?: boolean;
833
836
  }
837
+ /** Platform-owned segmented selection (UISegmentedControl / Android RadioGroup). */
838
+ export interface SegmentedControlProps extends BaseProps {
839
+ items: Array<PickerOption & {
840
+ disabled?: boolean;
841
+ spoken?: string;
842
+ }>;
843
+ value?: string | number;
844
+ disabled?: boolean;
845
+ tintColor?: string;
846
+ onValueChange?: (event: {
847
+ value: string;
848
+ index: number;
849
+ }) => void;
850
+ }
834
851
  export interface DatePickerChange {
835
852
  value: string;
836
853
  iso: string;
@@ -890,6 +907,10 @@ export interface ModalProps extends BaseProps {
890
907
  nonModal?: boolean;
891
908
  /** When non-modal, consume an outside tap and call onRequestClose. */
892
909
  dismissOnOutsideTap?: boolean;
910
+ presentationStyle?: 'fullScreen' | 'pageSheet' | 'formSheet' | 'overFullScreen';
911
+ sheetDetents?: Array<'medium' | 'large'>;
912
+ selectedDetent?: 'medium' | 'large';
913
+ grabberVisible?: boolean;
893
914
  animationType?: 'none' | 'fade' | 'slide';
894
915
  onShow?: () => void;
895
916
  onDismiss?: () => void;
package/dist/index.cjs CHANGED
@@ -374,7 +374,7 @@ function craftBinaryNotFoundMessage(triedPath) {
374
374
  `);
375
375
  }
376
376
  // package.json
377
- var version = "0.0.296";
377
+ var version = "0.0.301";
378
378
 
379
379
  // src/sdk-version.ts
380
380
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.296";
120
+ var version = "0.0.301";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -42,7 +42,7 @@ enum CraftNativeActions {
42
42
  static func capabilities(config: CraftConfig) -> [String] {
43
43
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
44
44
  // as it arrives) need nothing the app has to declare.
45
- var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation"]
45
+ var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation", "nativePlatformControls"]
46
46
  values.append("storageCompareAndSet")
47
47
  values.append("storageSetIfAbsent")
48
48
  values.append("viewScroll")
@@ -2578,6 +2578,7 @@ extension UIView {
2578
2578
  var responder: UIResponder? = next
2579
2579
  while let current = responder {
2580
2580
  if let screen = current as? CraftNativeScreenController { return screen }
2581
+ if let sheet = current as? CraftNativeSheetController { return sheet.screen }
2581
2582
  responder = current.next
2582
2583
  }
2583
2584
  return nil
@@ -3976,13 +3977,141 @@ private final class CraftNativeImageBackgroundView: UIImageView {
3976
3977
  }
3977
3978
  }
3978
3979
 
3979
- private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3980
+ private final class CraftNativeSegmentElement: UIAccessibilityElement {
3981
+ weak var control: CraftNativeSegmentedControl?
3982
+ let index: Int
3983
+ init(control: CraftNativeSegmentedControl, index: Int) {
3984
+ self.control = control
3985
+ self.index = index
3986
+ super.init(accessibilityContainer: control)
3987
+ }
3988
+ override func accessibilityActivate() -> Bool {
3989
+ guard let control, control.isEnabled, control.values.indices.contains(index), control.isEnabledForSegment(at: index) else { return false }
3990
+ if control.selectedSegmentIndex != index {
3991
+ control.selectedSegmentIndex = index
3992
+ control.sendActions(for: .valueChanged)
3993
+ }
3994
+ return true
3995
+ }
3996
+ }
3997
+
3998
+ private final class CraftNativeSegmentedControl: UISegmentedControl {
3999
+ var values: [String] = []
4000
+ private var segments: [CraftNativeSegmentElement] = []
4001
+ override var accessibilityElements: [Any]? {
4002
+ get { segments }
4003
+ set { /* The native segments, not the labelled group, own activation. */ }
4004
+ }
4005
+ func configureAccessibility(items: [[String: Any]]) {
4006
+ if segments.count != items.count { segments = items.indices.map { CraftNativeSegmentElement(control: self, index: $0) } }
4007
+ for (index, item) in items.enumerated() {
4008
+ segments[index].accessibilityLabel = item["spoken"] as? String ?? item["label"] as? String ?? values[index]
4009
+ }
4010
+ refreshAccessibilityState()
4011
+ setNeedsLayout()
4012
+ }
4013
+ func refreshAccessibilityState() {
4014
+ for (index, segment) in segments.enumerated() {
4015
+ segment.accessibilityTraits = [.button]
4016
+ if selectedSegmentIndex == index { segment.accessibilityTraits.insert(.selected) }
4017
+ if !isEnabled || !isEnabledForSegment(at: index) { segment.accessibilityTraits.insert(.notEnabled) }
4018
+ }
4019
+ }
4020
+ override func layoutSubviews() {
4021
+ super.layoutSubviews()
4022
+ guard !segments.isEmpty else { return }
4023
+ let width = bounds.width / CGFloat(segments.count)
4024
+ for (index, segment) in segments.enumerated() {
4025
+ let visualIndex = effectiveUserInterfaceLayoutDirection == .rightToLeft ? segments.count - index - 1 : index
4026
+ segment.accessibilityFrameInContainerSpace = CGRect(x: CGFloat(visualIndex) * width, y: 0, width: width, height: bounds.height)
4027
+ }
4028
+ }
4029
+ }
4030
+
4031
+ private final class CraftNativeSheetController: UIViewController {
4032
+ weak var screen: CraftNativeScreenController?
4033
+ weak var content: CraftNativeFlowView?
4034
+ override func viewDidLayoutSubviews() {
4035
+ super.viewDidLayoutSubviews()
4036
+ content?.frame = view.bounds.inset(by: view.safeAreaInsets)
4037
+ }
4038
+ }
4039
+
4040
+ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate, UISheetPresentationControllerDelegate {
3980
4041
  let contentStack = CraftNativeFlowView()
3981
4042
  private let blocker = UIView()
3982
4043
  private let backdropTap = UITapGestureRecognizer()
3983
4044
  private var renderedVisible = false
3984
4045
  private var animationGeneration = 0
3985
4046
  var onRequestClose: ((String) -> Void)?
4047
+ weak var sheetPresenter: CraftNativeScreenController?
4048
+ var presentationStyle = "overFullScreen"
4049
+ var sheetDetents: [String] = ["medium", "large"]
4050
+ var selectedDetent: String?
4051
+ var grabberVisible = true
4052
+ private var sheetController: CraftNativeSheetController?
4053
+ var isPresentedAsSheet: Bool { sheetController != nil }
4054
+
4055
+ private func configureSheet(_ controller: CraftNativeSheetController) {
4056
+ controller.isModalInPresentation = onRequestClose == nil
4057
+ guard let sheet = controller.sheetPresentationController else { return }
4058
+ let choices = sheetDetents.filter { $0 == "medium" || $0 == "large" }
4059
+ let values = choices.isEmpty ? ["large"] : Array(Set(choices)).sorted { $0 == "medium" && $1 == "large" }
4060
+ sheet.detents = values.map { $0 == "medium" ? .medium() : .large() }
4061
+ sheet.prefersGrabberVisible = grabberVisible
4062
+ if let selectedDetent, values.contains(selectedDetent) {
4063
+ sheet.selectedDetentIdentifier = selectedDetent == "medium" ? .medium : .large
4064
+ }
4065
+ sheet.delegate = self
4066
+ }
4067
+
4068
+ private func restoreSheetContent(_ controller: CraftNativeSheetController) {
4069
+ guard sheetController === controller else { return }
4070
+ contentStack.removeFromSuperview()
4071
+ addSubview(contentStack)
4072
+ sheetController = nil
4073
+ setNeedsLayout()
4074
+ }
4075
+
4076
+ private func setSheetVisible(_ visible: Bool, animated: Bool) {
4077
+ renderedVisible = visible
4078
+ isHidden = true // UIKit owns the scrim, safe area, grabber and pan.
4079
+ guard visible else {
4080
+ guard let controller = sheetController else { return }
4081
+ controller.view.endEditing(true)
4082
+ controller.dismiss(animated: animated) { [weak self, weak controller] in
4083
+ guard let self, let controller else { return }
4084
+ self.restoreSheetContent(controller)
4085
+ if self.renderedVisible { self.setSheetVisible(true, animated: animated) }
4086
+ }
4087
+ return
4088
+ }
4089
+ if let controller = sheetController { configureSheet(controller); return }
4090
+ // A newly rendered modal is attached to its screen after this pass.
4091
+ DispatchQueue.main.async { [weak self] in
4092
+ guard let self, self.renderedVisible, self.sheetController == nil,
4093
+ let presenter = self.sheetPresenter, presenter.view.window != nil else { return }
4094
+ let controller = CraftNativeSheetController()
4095
+ controller.screen = presenter
4096
+ controller.modalPresentationStyle = self.presentationStyle == "formSheet" ? .formSheet : .pageSheet
4097
+ controller.view.backgroundColor = .systemBackground
4098
+ self.contentStack.removeFromSuperview()
4099
+ controller.view.addSubview(self.contentStack)
4100
+ controller.content = self.contentStack
4101
+ self.sheetController = controller
4102
+ self.configureSheet(controller)
4103
+ var top: UIViewController = presenter
4104
+ while let presented = top.presentedViewController { top = presented }
4105
+ top.present(controller, animated: animated)
4106
+ }
4107
+ }
4108
+
4109
+ func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
4110
+ guard let controller = presentationController.presentedViewController as? CraftNativeSheetController else { return }
4111
+ restoreSheetContent(controller)
4112
+ renderedVisible = false
4113
+ onRequestClose?("swipe")
4114
+ }
3986
4115
 
3987
4116
  var transparent = false {
3988
4117
  didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
@@ -4020,6 +4149,10 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
4020
4149
  /// press bookkeeping once turned that off for a view with no handler of
4021
4150
  /// its own, and a shown modal let every tap through to the screen behind.
4022
4151
  func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
4152
+ if blocksBackground && (presentationStyle == "pageSheet" || presentationStyle == "formSheet") {
4153
+ setSheetVisible(visible, animated: animationType != "none")
4154
+ return
4155
+ }
4023
4156
  if visible { isUserInteractionEnabled = true }
4024
4157
  if renderedVisible == visible && isHidden == !visible {
4025
4158
  if visible, alpha != opacity { alpha = opacity }
@@ -4072,6 +4205,12 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
4072
4205
  }
4073
4206
 
4074
4207
  func resetForTeardown() {
4208
+ renderedVisible = false
4209
+ sheetPresenter = nil
4210
+ if let controller = sheetController {
4211
+ controller.dismiss(animated: false)
4212
+ restoreSheetContent(controller)
4213
+ }
4075
4214
  craftScreenController?.forgetFocusedInput(in: self)
4076
4215
  endEditing(true)
4077
4216
  animationGeneration += 1
@@ -4133,6 +4272,7 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
4133
4272
  override func layoutSubviews() {
4134
4273
  super.layoutSubviews()
4135
4274
  blocker.frame = bounds
4275
+ guard sheetController == nil else { return }
4136
4276
  // The scrim covers the whole screen; what the modal holds stays
4137
4277
  // clear of the bars and the home indicator it is drawn over.
4138
4278
  contentStack.frame = bounds.inset(by: UIEdgeInsets(top: contentInsets.top, left: 0, bottom: contentInsets.bottom, right: 0))
@@ -5895,7 +6035,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5895
6035
  @discardableResult
5896
6036
  func handleBackForModal() -> Bool {
5897
6037
  let modal = view.subviews.reversed().compactMap { $0 as? CraftNativeModalView }.first {
5898
- !$0.isHidden && $0.alpha > 0.01 && ($0.blocksBackground || $0.dismissOnOutsideTap)
6038
+ ($0.isPresentedAsSheet || (!$0.isHidden && $0.alpha > 0.01)) && ($0.blocksBackground || $0.dismissOnOutsideTap)
5899
6039
  }
5900
6040
  guard let modal else { return false }
5901
6041
  let hasHandler = modal.onRequestClose != nil
@@ -7937,15 +8077,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7937
8077
  break
7938
8078
  }
7939
8079
  let field = result as! UITextField
7940
- field.placeholder = props["placeholder"] as? String
7941
- if let placeholder = field.placeholder,
8080
+ if let placeholder = props["placeholder"] as? String,
7942
8081
  let placeholderColor = color(props["placeholderTextColor"]) {
7943
8082
  field.attributedPlaceholder = NSAttributedString(
7944
8083
  string: placeholder,
7945
8084
  attributes: [.foregroundColor: placeholderColor]
7946
8085
  )
7947
8086
  } else {
8087
+ // UIKit shares these properties: resetting the attributed
8088
+ // value after the plain one also erases the visible hint.
7948
8089
  field.attributedPlaceholder = nil
8090
+ field.placeholder = props["placeholder"] as? String
7949
8091
  }
7950
8092
  field.tintColor = color(props["selectionColor"])
7951
8093
  let fieldId = ObjectIdentifier(field)
@@ -7957,6 +8099,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7957
8099
  textMaxLengths.removeValue(forKey: fieldId)
7958
8100
  }
7959
8101
  field.delegate = self
8102
+ switch props["clearButtonMode"] as? String {
8103
+ case "while-editing": field.clearButtonMode = .whileEditing
8104
+ case "unless-editing": field.clearButtonMode = .unlessEditing
8105
+ case "always": field.clearButtonMode = .always
8106
+ default: field.clearButtonMode = .never
8107
+ }
7960
8108
  (field as? CraftNativeTextField)?.padding = paddingInsets(style)
7961
8109
  field.textColor = color(style["color"]) ?? .label
7962
8110
  field.font = textFont(style, default: .systemFont(ofSize: 16))
@@ -7977,7 +8125,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7977
8125
  if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
7978
8126
  applyAutofill(to: field, props: props)
7979
8127
  if let value = props["value"] as? String {
7980
- updateField(field, value: value)
8128
+ updateField(field, value: value, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
7981
8129
  } else if previous?.view !== field {
7982
8130
  updateField(field, value: inputDrafts[current.identity] ?? props["defaultValue"] as? String)
7983
8131
  }
@@ -7989,6 +8137,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7989
8137
  if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
7990
8138
  DispatchQueue.main.async { _ = field.becomeFirstResponder() }
7991
8139
  }
8140
+ case "SegmentedControl":
8141
+ let control = result as! CraftNativeSegmentedControl
8142
+ let items = props["items"] as? [[String: Any]] ?? []
8143
+ let labels = items.map { $0["label"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
8144
+ // UIKit owns the segments; only rebuild when the choices change.
8145
+ // Setting a controlled value never dispatches valueChanged.
8146
+ if control.numberOfSegments != labels.count || labels.enumerated().contains(where: { control.titleForSegment(at: $0.offset) != $0.element }) {
8147
+ control.removeAllSegments()
8148
+ for (index, label) in labels.enumerated() { control.insertSegment(withTitle: label, at: index, animated: false) }
8149
+ }
8150
+ control.values = items.map { $0["value"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
8151
+ for (index, item) in items.enumerated() { control.setEnabled(item["disabled"] as? Bool != true, forSegmentAt: index) }
8152
+ let selected = props["value"] as? String ?? craftNativePickerValue(props["value"]) ?? props["selectedValue"] as? String ?? craftNativePickerValue(props["selectedValue"])
8153
+ control.selectedSegmentIndex = control.values.firstIndex(where: { $0 == selected }) ?? UISegmentedControl.noSegment
8154
+ control.isEnabled = props["disabled"] as? Bool != true
8155
+ control.selectedSegmentTintColor = color(props["tintColor"])
8156
+ let titleAttributes: [NSAttributedString.Key: Any]? = color(style["color"]).map { [.foregroundColor: $0] }
8157
+ control.setTitleTextAttributes(titleAttributes, for: .normal)
8158
+ control.setTitleTextAttributes(titleAttributes, for: .selected)
8159
+ control.configureAccessibility(items: items)
8160
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: control)
7992
8161
  case "Picker":
7993
8162
  let picker = result as! UIPickerView
7994
8163
  let itemOptions = (props["items"] as? [[String: Any]])?.compactMap { item -> (value: String, label: String)? in
@@ -8051,6 +8220,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8051
8220
  case "Modal":
8052
8221
  let modal = result as! CraftNativeModalView
8053
8222
  let focusBeforeContent = firstResponder(in: rootStack) ?? lastFocusedInput
8223
+ modal.sheetPresenter = self
8224
+ modal.presentationStyle = props["presentationStyle"] as? String ?? "overFullScreen"
8225
+ modal.sheetDetents = props["sheetDetents"] as? [String] ?? ["medium", "large"]
8226
+ modal.selectedDetent = props["selectedDetent"] as? String
8227
+ modal.grabberVisible = props["grabberVisible"] as? Bool != false
8228
+ modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
8229
+ { [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
8230
+ }
8054
8231
  modal.transparent = props["transparent"] as? Bool == true
8055
8232
  modal.blocksBackground = props["nonModal"] as? Bool != true
8056
8233
  modal.dismissOnOutsideTap = props["dismissOnOutsideTap"] as? Bool == true
@@ -8124,7 +8301,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8124
8301
  toggle.onTintColor = color(track["true"])
8125
8302
  toggle.tintColor = color(track["false"])
8126
8303
  } else {
8127
- toggle.onTintColor = nil
8304
+ toggle.onTintColor = color(style["color"])
8128
8305
  toggle.tintColor = nil
8129
8306
  }
8130
8307
  toggle.thumbTintColor = color(props["thumbColor"])
@@ -8162,7 +8339,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8162
8339
  }
8163
8340
  indicator.indicatorScale = scale
8164
8341
  applyTransform(style["transform"], to: indicator)
8165
- indicator.color = color(props["color"]) ?? .tintColor
8342
+ indicator.color = color(props["color"]) ?? color(style["color"]) ?? .tintColor
8166
8343
  indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
8167
8344
  if props["animating"] as? Bool == false {
8168
8345
  indicator.stopAnimating()
@@ -8335,7 +8512,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8335
8512
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
8336
8513
  }
8337
8514
  updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
8338
- if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
8515
+ if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "SegmentedControl" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
8339
8516
  let disabled = props["disabled"] as? Bool == true
8340
8517
  updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
8341
8518
  updatePressFeedback(
@@ -8391,6 +8568,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8391
8568
  field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
8392
8569
  field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
8393
8570
  return field
8571
+ case "SegmentedControl":
8572
+ let control = CraftNativeSegmentedControl()
8573
+ control.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
8574
+ return control
8394
8575
  case "Picker":
8395
8576
  let picker = UIPickerView()
8396
8577
  picker.dataSource = self
@@ -8457,6 +8638,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8457
8638
  case "link": return "Link"
8458
8639
  case "checkbox": return "Checkbox"
8459
8640
  case "textinput", "input": return "TextInput"
8641
+ case "segmentedcontrol", "segmented-control": return "SegmentedControl"
8460
8642
  case "picker", "select": return "Picker"
8461
8643
  case "datepicker", "date-picker", "date": return "DatePicker"
8462
8644
  case "modal": return "Modal"
@@ -8806,6 +8988,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8806
8988
  submitHandlers.removeValue(forKey: id)
8807
8989
  inputIdentities.removeValue(forKey: id)
8808
8990
  unechoedText.removeValue(forKey: id)
8991
+ inputEventCounts.removeValue(forKey: id)
8809
8992
  if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
8810
8993
  longPressHandlers.removeValue(forKey: id)
8811
8994
  textMaxLengths.removeValue(forKey: id)
@@ -9023,19 +9206,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9023
9206
  }
9024
9207
  }
9025
9208
 
9026
- /// Text a field sent its script that the script has not echoed back yet,
9027
- /// while the script runs on its own thread: a value that matches an older
9028
- /// one is the script catching up, not a new value, and applying it would
9029
- /// undo what was typed since.
9209
+ /// Legacy runtimes echo values without a revision. Keep their bounded
9210
+ /// history on either script thread; bridge delivery may still be delayed.
9030
9211
  private var unechoedText: [ObjectIdentifier: [String]] = [:]
9212
+ private var inputEventCounts: [ObjectIdentifier: Int] = [:]
9031
9213
 
9032
- private func noteSentText(_ text: String, for view: UIView) {
9033
- guard CraftNativeScriptThread.isBackground else { return }
9214
+ @discardableResult
9215
+ private func noteSentText(_ text: String, for view: UIView) -> Int {
9034
9216
  let id = ObjectIdentifier(view)
9217
+ let count = (inputEventCounts[id] ?? 0) + 1
9218
+ inputEventCounts[id] = count
9035
9219
  var sent = unechoedText[id] ?? []
9036
9220
  sent.append(text)
9037
9221
  if sent.count > 32 { sent.removeFirst(sent.count - 32) }
9038
9222
  unechoedText[id] = sent
9223
+ return count
9039
9224
  }
9040
9225
 
9041
9226
  /// Whether `value` is a stale echo of text the view has moved past: it
@@ -9053,9 +9238,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9053
9238
  return later > 0
9054
9239
  }
9055
9240
 
9056
- private func updateField(_ field: UITextField, value: String?) {
9241
+ private func acceptsText(_ value: String, for view: UIView, acknowledged: Int?) -> Bool {
9242
+ if let acknowledged, acknowledged >= 0 {
9243
+ guard acknowledged >= (inputEventCounts[ObjectIdentifier(view)] ?? 0) else { return false }
9244
+ unechoedText.removeValue(forKey: ObjectIdentifier(view))
9245
+ return true // A current explicit reset may equal an older edit.
9246
+ }
9247
+ return !isStaleEcho(value, for: view)
9248
+ }
9249
+
9250
+ private func updateField(_ field: UITextField, value: String?, acknowledged: Int? = nil) {
9057
9251
  guard let value = value, field.markedTextRange == nil else { return }
9058
- if isStaleEcho(value, for: field) || field.text == value { return }
9252
+ if !acceptsText(value, for: field, acknowledged: acknowledged) || field.text == value { return }
9059
9253
  let selection = field.selectedTextRange
9060
9254
  let start = selection.map { field.offset(from: field.beginningOfDocument, to: $0.start) }
9061
9255
  let end = selection.map { field.offset(from: field.beginningOfDocument, to: $0.end) }
@@ -9067,9 +9261,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9067
9261
  }
9068
9262
  }
9069
9263
 
9070
- private func updateTextView(_ textView: UITextView, value: String?) {
9264
+ private func updateTextView(_ textView: UITextView, value: String?, acknowledged: Int? = nil) {
9071
9265
  guard let value = value, textView.markedTextRange == nil else { return }
9072
- if isStaleEcho(value, for: textView) || textView.text == value { return }
9266
+ if !acceptsText(value, for: textView, acknowledged: acknowledged) || textView.text == value { return }
9073
9267
  let selection = textView.selectedTextRange
9074
9268
  let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
9075
9269
  let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
@@ -9132,7 +9326,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9132
9326
  craftTextView.placeholderColor = color(props["placeholderTextColor"])
9133
9327
  craftTextView.placeholderFont = textView.font
9134
9328
  }
9135
- updateTextView(textView, value: (props["value"] as? String) ?? defaultValue)
9329
+ updateTextView(textView, value: (props["value"] as? String) ?? defaultValue, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
9136
9330
  if textView.markedTextRange == nil {
9137
9331
  let attributes = inputTextAttributes(style, font: textView.font ?? .systemFont(ofSize: 16), alignment: textView.textAlignment)
9138
9332
  let styled = NSAttributedString(string: textView.text ?? "", attributes: attributes)
@@ -9836,7 +10030,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9836
10030
  let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
9837
10031
  // UIPickerView exposes adjustable component wheels. Making the
9838
10032
  // picker itself a leaf hides those wheels and their selected values.
9839
- let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type)
10033
+ let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl"].contains(type)
9840
10034
  || ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
9841
10035
  || controlledVideo
9842
10036
  view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
@@ -9939,6 +10133,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9939
10133
  /// Return on a `returnKeyType="next"` field the screen does not handle
9940
10134
  /// itself (no `@submit`) goes to the next field down the screen, keeping
9941
10135
  /// the keyboard up. Every other Return ends editing and fires `@submit`.
10136
+ func textFieldShouldClear(_ textField: UITextField) -> Bool {
10137
+ guard textField.isEnabled else { return false }
10138
+ textField.text = ""
10139
+ let id = ObjectIdentifier(textField)
10140
+ inputIdentities[id].map { inputDrafts[$0] = "" }
10141
+ let eventCount = noteSentText("", for: textField)
10142
+ if let handler = handlers[id] {
10143
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": "", "clear": true, "eventCount": eventCount]])
10144
+ }
10145
+ return false // The model receives exactly one edit, including lazy models.
10146
+ }
10147
+
9942
10148
  func textFieldShouldReturn(_ textField: UITextField) -> Bool {
9943
10149
  guard textField.returnKeyType == .next, submitHandlers[ObjectIdentifier(textField)] == nil,
9944
10150
  let next = inputAfter(textField) else { return true }
@@ -9985,8 +10191,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9985
10191
  (textView as? CraftNativeTextView)?.refreshPlaceholder()
9986
10192
  keepCaretVisible(in: textView)
9987
10193
  guard let handler = handlers[id] else { return }
9988
- noteSentText(textView.text ?? "", for: textView)
9989
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
10194
+ let eventCount = noteSentText(textView.text ?? "", for: textView)
10195
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? "", "eventCount": eventCount]])
9990
10196
  }
9991
10197
 
9992
10198
  func textViewDidChangeSelection(_ textView: UITextView) {
@@ -10042,14 +10248,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10042
10248
  inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
10043
10249
  let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
10044
10250
  guard let handler = handlers[id] else { return }
10045
- noteSentText(sender.text ?? "", for: sender)
10046
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
10251
+ let eventCount = noteSentText(sender.text ?? "", for: sender)
10252
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? "", "eventCount": eventCount]])
10047
10253
  restoreFocus(focused)
10048
10254
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
10049
10255
  self?.restoreFocus(focused)
10050
10256
  }
10051
10257
  }
10052
10258
 
10259
+ @objc private func segmentChanged(_ sender: CraftNativeSegmentedControl) {
10260
+ let index = sender.selectedSegmentIndex
10261
+ guard sender.isEnabled, sender.values.indices.contains(index), sender.isEnabledForSegment(at: index),
10262
+ let handler = handlers[ObjectIdentifier(sender)] else { return }
10263
+ sender.refreshAccessibilityState()
10264
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.values[index], "index": index]])
10265
+ }
10266
+
10053
10267
  @objc private func switchChanged(_ sender: UISwitch) {
10054
10268
  let id = ObjectIdentifier(sender)
10055
10269
  switchUserValues[id] = sender.isOn
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.296",
3
+ "version": "0.0.301",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",