craft-native 0.0.207 → 0.0.210

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.
@@ -389,6 +389,10 @@ internal object CraftNativePrelude {
389
389
  routePrelude: String,
390
390
  ): String = """
391
391
  $routePrelude
392
+ // JavaScriptSandbox has no DOM window, but browser-safe SDK bridges
393
+ // address the host through `window.craft`. Keep native screens
394
+ // compatible without pretending a document exists.
395
+ if (typeof globalThis.window === 'undefined') globalThis.window = globalThis;
392
396
  (function() {
393
397
  var forward = null;
394
398
  var queued = [];
@@ -567,7 +571,7 @@ internal object CraftNativePrelude {
567
571
  * The bar above a screen (back, title, the screen's buttons) and the tab bar
568
572
  * below it, in plain Android views. A screen sets its bar with
569
573
  * `craft.navigation.setOptions` (title, largeTitle, hidden, rightButtons,
570
- * tabBarHidden); the tabs come from the app's `tabs`.
574
+ * tabAccessory, tabBarHidden); the tabs come from the app's `tabs`.
571
575
  */
572
576
  internal class CraftNativeChrome(context: Context, private val tabs: List<CraftNativeTab>) : LinearLayout(context) {
573
577
  val content = FrameLayout(context)
@@ -578,6 +582,11 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
578
582
  private val largeTitleView = TextView(context)
579
583
  private val buttons = LinearLayout(context)
580
584
  private val tabBar = LinearLayout(context)
585
+ private val tabRow = LinearLayout(context)
586
+ private val accessory = FrameLayout(context)
587
+ private val accessoryIcon = ImageView(context)
588
+ private val accessoryBadge = TextView(context)
589
+ private var accessorySpec: JSONObject? = null
581
590
  private val tabViews = mutableListOf<Pair<TextView, TextView>>()
582
591
  private val tabIconText = mutableMapOf<String, String>()
583
592
  private val tabImageURLs = mutableMapOf<String, String>()
@@ -596,6 +605,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
596
605
  var onBack: (() -> Unit)? = null
597
606
  var onButton: ((String) -> Unit)? = null
598
607
  var onTab: ((Int) -> Unit)? = null
608
+ var onAccessory: ((String) -> Unit)? = null
599
609
  var onTabSwipeStart: ((Float, Float, Float) -> Boolean)? = null
600
610
  var onTabSwipeMove: ((Float) -> Unit)? = null
601
611
  var onTabSwipeEnd: ((Float, Boolean) -> Unit)? = null
@@ -660,8 +670,22 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
660
670
  tabBar.addView(cell, LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f))
661
671
  tabViews.add(icon to label)
662
672
  }
663
- addView(tabBar, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(64)))
664
- tabBar.visibility = if (tabs.isEmpty()) View.GONE else View.VISIBLE
673
+ tabRow.orientation = HORIZONTAL
674
+ tabRow.gravity = Gravity.CENTER_VERTICAL
675
+ tabRow.setPadding(dp(12), dp(4), dp(12), dp(4))
676
+ tabRow.addView(tabBar, LayoutParams(0, dp(64), 1f))
677
+ accessory.addView(accessoryIcon, FrameLayout.LayoutParams(dp(26), dp(26), Gravity.CENTER))
678
+ accessoryBadge.textSize = 10f
679
+ accessoryBadge.gravity = Gravity.CENTER
680
+ accessoryBadge.setTextColor(Color.WHITE)
681
+ accessoryBadge.setPadding(dp(4), 0, dp(4), 0)
682
+ accessoryBadge.background = GradientDrawable().apply { setColor(Color.RED); cornerRadius = dp(9).toFloat() }
683
+ accessory.addView(accessoryBadge, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(18), Gravity.TOP or Gravity.END))
684
+ accessory.setOnClickListener { accessorySpec?.optString("path")?.let { onAccessory?.invoke(it) } }
685
+ accessory.visibility = View.GONE
686
+ tabRow.addView(accessory, LayoutParams(dp(64), dp(64)).apply { marginStart = dp(8) })
687
+ addView(tabRow, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(72)))
688
+ tabRow.visibility = if (tabs.isEmpty()) View.GONE else View.VISIBLE
665
689
  refreshColors()
666
690
  }
667
691
 
@@ -671,7 +695,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
671
695
  val overHorizontalScroll = onTabSwipeShouldDefer?.invoke(event.x, event.y) == true
672
696
  horizontalScrollDeferred = overHorizontalScroll
673
697
  if (overHorizontalScroll) parent?.requestDisallowInterceptTouchEvent(true)
674
- swipeTracking = tabs.size > 1 && tabSwipeEnabled && !overHorizontalScroll && tabBar.visibility == View.VISIBLE && event.y < tabBar.top
698
+ swipeTracking = tabs.size > 1 && tabSwipeEnabled && !overHorizontalScroll && tabRow.visibility == View.VISIBLE && event.y < tabRow.top
675
699
  swipeIntercepted = false
676
700
  swipeDownX = event.x
677
701
  swipeDownY = event.y
@@ -742,7 +766,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
742
766
  buttons.addView(navButton(button))
743
767
  }
744
768
  val tabBarHidden = options.optBoolean("tabBarHidden", false) || depth > 0 && options.optBoolean("tabBarHiddenWhenPushed", false)
745
- tabBar.visibility = if (tabs.isEmpty() || tabBarHidden) View.GONE else View.VISIBLE
769
+ tabRow.visibility = if (tabs.isEmpty() || tabBarHidden) View.GONE else View.VISIBLE
770
+ accessorySpec = options.optJSONObject("tabAccessory")?.takeIf {
771
+ val path = it.optString("path")
772
+ path.startsWith("/") && !path.startsWith("//") && it.has("title")
773
+ }
774
+ accessory.visibility = if (accessorySpec == null) View.GONE else View.VISIBLE
775
+ accessorySpec?.let {
776
+ accessory.contentDescription = it.optString("accessibilityLabel").ifBlank { it.optString("title") } + it.optString("badge").takeIf { badge -> badge.isNotBlank() && badge != "null" }?.let { badge -> ", $badge" }.orEmpty()
777
+ accessoryBadge.text = it.optString("badge").takeUnless { badge -> badge == "null" }.orEmpty().take(8)
778
+ accessoryBadge.visibility = if (accessoryBadge.text.isEmpty()) View.GONE else View.VISIBLE
779
+ }
746
780
  tabSwipeEnabled = options.optBoolean("tabSwipeEnabled", true)
747
781
  refreshColors()
748
782
  }
@@ -801,13 +835,10 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
801
835
  titleView.setTextColor(text)
802
836
  largeTitleView.setTextColor(text)
803
837
  backButton.imageTintList = ColorStateList.valueOf(accent)
804
- tabBar.setBackgroundColor(if (dark) Color.parseColor("#121212") else Color.parseColor("#F9F9F9"))
805
- val divider = GradientDrawable().apply { setColor(if (dark) Color.parseColor("#262626") else Color.parseColor("#D8D8DC")) }
806
- tabBar.foreground = null
807
- tabBar.background = android.graphics.drawable.LayerDrawable(arrayOf(
808
- divider,
809
- GradientDrawable().apply { setColor(if (dark) Color.parseColor("#121212") else Color.parseColor("#F9F9F9")) },
810
- )).apply { setLayerInset(1, 0, maxOf(1, (density / 2).toInt()), 0, 0) }
838
+ val surface = if (dark) Color.parseColor("#1C1C1E") else Color.parseColor("#F9F9F9")
839
+ tabBar.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
840
+ accessory.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
841
+ accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) }
811
842
  tabViews.forEachIndexed { index, (icon, label) ->
812
843
  val color = if (index == selectedTab) accent else muted
813
844
  val number = tabIconText[tabs[index].id]
@@ -256,16 +256,9 @@ private data class CraftNativeLayoutStyle(
256
256
  aspectRatio = decimal("aspectRatio")?.takeIf { it.isFinite() && it > 0.0 },
257
257
  minWidth = length("minWidth"), maxWidth = length("maxWidth"),
258
258
  minHeight = length("minHeight"), maxHeight = length("maxHeight"),
259
- position = raw.opt("position")
260
- ?.takeUnless { it == JSONObject.NULL }
261
- ?.toString()
262
- ?.takeIf { it.isNotBlank() }
263
- ?: "relative",
259
+ position = craftNativeOptionalString(raw.opt("position")) ?: "relative",
264
260
  top = number("top"), right = number("right"), bottom = number("bottom"), left = number("left"),
265
- alignSelf = raw.opt("alignSelf")
266
- ?.takeUnless { it == JSONObject.NULL }
267
- ?.toString()
268
- ?.takeIf { it.isNotBlank() && it != "auto" },
261
+ alignSelf = craftNativeOptionalString(raw.opt("alignSelf"))?.takeUnless { it == "auto" },
269
262
  flexGrow = decimal("flexGrow") ?: flex?.coerceAtLeast(0.0) ?: 0.0,
270
263
  flexShrink = decimal("flexShrink") ?: flex?.let { if (it != 0.0) 1.0 else 0.0 },
271
264
  flexBasis = length("flexBasis")
@@ -383,11 +376,8 @@ private class CraftNativeSvgView(context: Context) : View(context) {
383
376
  fun merge(node: JSONObject): SvgPaint {
384
377
  val props = node.optJSONObject("props") ?: JSONObject()
385
378
  val style = node.optJSONObject("style") ?: JSONObject()
386
- fun value(name: String): String? = when {
387
- style.has(name) && !style.isNull(name) -> style.optString(name)
388
- props.has(name) && !props.isNull(name) -> props.optString(name)
389
- else -> null
390
- }
379
+ fun value(name: String): String? = craftNativeOptionalString(style.opt(name))
380
+ ?: craftNativeOptionalString(props.opt(name))
391
381
  fun raw(name: String): Any? = when {
392
382
  style.has(name) && !style.isNull(name) -> style.opt(name)
393
383
  props.has(name) && !props.isNull(name) -> props.opt(name)
@@ -486,7 +476,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
486
476
  for (index in 0 until children.length()) {
487
477
  val node = children.optJSONObject(index) ?: continue
488
478
  val style = node.optJSONObject("style") ?: JSONObject()
489
- if (style.optString("display") == "none") continue
479
+ if (craftNativeOptionalString(style.opt("display")) == "none") continue
490
480
  val paint = inherited.merge(node)
491
481
  when (node.optString("type")) {
492
482
  "G" -> drawNodes(canvas, node.optJSONArray("children") ?: JSONArray(), paint)
@@ -582,7 +572,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
582
572
  }
583
573
  var x = craftNativeNumber(props.opt("x"))?.toFloat()?.takeIf { it.isFinite() } ?: 0f
584
574
  val y = craftNativeNumber(props.opt("y"))?.toFloat()?.takeIf { it.isFinite() } ?: 0f
585
- when (props.optString("textAnchor")) {
575
+ when (craftNativeOptionalString(props.opt("textAnchor"))) {
586
576
  "middle" -> x -= paint.measureText(text) / 2f
587
577
  "end" -> x -= paint.measureText(text)
588
578
  }
@@ -728,12 +718,15 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
728
718
  val explicitRegion = region(pendingProps.optJSONObject("region"))
729
719
  val initialRegion = region(pendingProps.optJSONObject("initialRegion"))
730
720
  if (explicitRegion != null) {
731
- moveCamera(explicitRegion)
721
+ // A controlled region is an exact camera request. The padding
722
+ // used for fitToBounds is framing chrome, not part of the region
723
+ // contract, and would otherwise zoom every Android screen out.
724
+ moveCamera(explicitRegion, padding = 0)
732
725
  initialCameraKey = null
733
726
  } else if (initialRegion != null) {
734
727
  val key = "region:${pendingProps.optJSONObject("initialRegion")}"
735
728
  if (initialCameraKey != key) {
736
- moveCamera(initialRegion)
729
+ moveCamera(initialRegion, padding = 0)
737
730
  initialCameraKey = key
738
731
  }
739
732
  } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
@@ -755,9 +748,9 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
755
748
  }
756
749
  }
757
750
 
758
- private fun moveCamera(bounds: LatLngBounds) {
751
+ private fun moveCamera(bounds: LatLngBounds, padding: Int = 24) {
759
752
  post {
760
- if (width > 0 && height > 0) googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 24))
753
+ if (width > 0 && height > 0) googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding))
761
754
  }
762
755
  }
763
756
 
@@ -1027,14 +1020,15 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1027
1020
  }
1028
1021
 
1029
1022
  fun configure(style: JSONObject, density: Float) {
1030
- orientation = if (style.optString("flexDirection") in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
1031
- reverse = style.optString("flexDirection").endsWith("-reverse")
1032
- val flexWrap = style.optString("flexWrap")
1033
- wrap = flexWrap == "wrap" || flexWrap == "wrap-reverse" || style.optBoolean("flexWrap", false)
1023
+ val flexDirection = craftNativeOptionalString(style.opt("flexDirection")).orEmpty()
1024
+ orientation = if (flexDirection in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
1025
+ reverse = flexDirection.endsWith("-reverse")
1026
+ val flexWrap = craftNativeOptionalString(style.opt("flexWrap")).orEmpty()
1027
+ wrap = flexWrap == "wrap" || flexWrap == "wrap-reverse" || craftNativeBoolean(style.opt("flexWrap"))
1034
1028
  wrapReverse = flexWrap == "wrap-reverse"
1035
- alignItems = style.optString("alignItems", "stretch")
1036
- alignContent = style.optString("alignContent", "flex-start")
1037
- justifyContent = style.optString("justifyContent", "flex-start")
1029
+ alignItems = craftNativeOptionalString(style.opt("alignItems")) ?: "stretch"
1030
+ alignContent = craftNativeOptionalString(style.opt("alignContent")) ?: "flex-start"
1031
+ justifyContent = craftNativeOptionalString(style.opt("justifyContent")) ?: "flex-start"
1038
1032
  fun spacing(name: String, fallback: Float): Float {
1039
1033
  val value = craftNativeNumber(style.opt(name)) ?: Double.NaN
1040
1034
  val pixels = value * density
@@ -1044,7 +1038,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1044
1038
  val gap = spacing("gap", 0f)
1045
1039
  rowGap = spacing("rowGap", gap)
1046
1040
  columnGap = spacing("columnGap", gap)
1047
- grid = style.optString("display") == "grid"
1041
+ grid = craftNativeOptionalString(style.opt("display")) == "grid"
1048
1042
  gridColumns = craftNativeGridColumnCount(
1049
1043
  if (style.has("gridTemplateColumns")) style.opt("gridTemplateColumns") else style.opt("gridColumns")
1050
1044
  )
@@ -2095,7 +2089,7 @@ private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(co
2095
2089
  }
2096
2090
 
2097
2091
  fun configure(props: JSONObject, style: JSONObject, density: Float) {
2098
- behavior = props.optString("behavior", "padding").takeIf { it in setOf("height", "position", "padding") } ?: "padding"
2092
+ behavior = craftNativeOptionalString(props.opt("behavior"))?.takeIf { it in setOf("height", "position", "padding") } ?: "padding"
2099
2093
  val authoredOffset = craftNativeFiniteDouble(props, "keyboardVerticalOffset", 0.0)
2100
2094
  val pixelOffset = (authoredOffset * density.toDouble()).takeIf { it.isFinite() } ?: 0.0
2101
2095
  keyboardVerticalOffset = pixelOffset.roundToInt()
@@ -3958,6 +3952,7 @@ class MainActivity : AppCompatActivity() {
3958
3952
  val bars = CraftNativeChrome(this, routes.tabs)
3959
3953
  bars.layoutParams = FrameLayout.LayoutParams(-1, -1)
3960
3954
  bars.onBack = { onBackPressedDispatcherCompat() }
3955
+ bars.onAccessory = { path -> navigateOpen(path) }
3961
3956
  bars.onButton = { id -> visibleScreen?.takeIf { it.active }?.let { send(it, "NAV_BUTTON", JSONObject().put("id", id)) } }
3962
3957
  bars.onTab = { index ->
3963
3958
  haptic()
@@ -4945,7 +4940,7 @@ class MainActivity : AppCompatActivity() {
4945
4940
  "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
4946
4941
  includeFontPadding = false
4947
4942
  maxLines = craftNativeInt(props.opt("numberOfLines"), 0).takeIf { it > 0 } ?: Int.MAX_VALUE
4948
- ellipsize = when (props.optString("ellipsizeMode")) {
4943
+ ellipsize = when (craftNativeOptionalString(props.opt("ellipsizeMode"))) {
4949
4944
  "head" -> TextUtils.TruncateAt.START
4950
4945
  "middle" -> TextUtils.TruncateAt.MIDDLE
4951
4946
  "tail" -> TextUtils.TruncateAt.END
@@ -5248,7 +5243,7 @@ class MainActivity : AppCompatActivity() {
5248
5243
  // from the previous hidden state when it patches props.
5249
5244
  craftNativeBoolean(props.opt("visible"))
5250
5245
  } else {
5251
- style.has("display") && style.optString("display") != "none"
5246
+ style.has("display") && craftNativeOptionalString(style.opt("display")) != "none"
5252
5247
  }
5253
5248
  val wasVisible = screen.modalVisibility[this]
5254
5249
  screen.modalVisibility[this] = visible
@@ -5480,7 +5475,7 @@ class MainActivity : AppCompatActivity() {
5480
5475
  drawing = node
5481
5476
  }
5482
5477
  "ScrollView", "SectionList" -> (previous as? CraftNativeScrollView ?: CraftNativeScrollView(this)).apply {
5483
- val horizontal = craftNativeBoolean(props.opt("horizontal")) || style.optString("flexDirection") in setOf("row", "row-reverse")
5478
+ val horizontal = craftNativeBoolean(props.opt("horizontal")) || craftNativeOptionalString(style.opt("flexDirection")) in setOf("row", "row-reverse")
5484
5479
  setHorizontal(horizontal)
5485
5480
  setNativeNestedScrollingEnabled(true)
5486
5481
  setScrollIndicators(
@@ -5488,7 +5483,7 @@ class MainActivity : AppCompatActivity() {
5488
5483
  craftNativeBoolean(props.opt("showsHorizontalScrollIndicator"), true),
5489
5484
  )
5490
5485
  setScrollEnabled(craftNativeBoolean(props.opt("scrollEnabled"), true))
5491
- setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
5486
+ setKeyboardShouldPersistTaps(craftNativeOptionalString(props.opt("keyboardShouldPersistTaps")) ?: "never")
5492
5487
  setBounces(craftNativeBoolean(props.opt("bounces"), true))
5493
5488
  setAlwaysBounce(
5494
5489
  craftNativeBoolean(props.opt("alwaysBounceVertical"), !horizontal),
@@ -5550,7 +5545,7 @@ class MainActivity : AppCompatActivity() {
5550
5545
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
5551
5546
  }
5552
5547
  },
5553
- keyboardDismissMode = props.optString("keyboardDismissMode"),
5548
+ keyboardDismissMode = craftNativeOptionalString(props.opt("keyboardDismissMode")).orEmpty(),
5554
5549
  dismissKeyboard = {
5555
5550
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
5556
5551
  ?.hideSoftInputFromWindow(nativeScroll.windowToken, 0)
@@ -5587,9 +5582,9 @@ class MainActivity : AppCompatActivity() {
5587
5582
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
5588
5583
  }, craftNativeBoolean(propRefresh.opt("refreshing"), craftNativeBoolean(props.opt("refreshing"))))
5589
5584
  setRefreshAppearance(propRefresh, refreshTintFallback(screen))
5590
- nativeList.setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
5585
+ nativeList.setKeyboardShouldPersistTaps(craftNativeOptionalString(props.opt("keyboardShouldPersistTaps")) ?: "never")
5591
5586
  nativeList.setContentContainerStyle(props.optJSONObject("contentContainerStyle"), resources.displayMetrics.density)
5592
- nativeList.setKeyboardDismissMode(props.optString("keyboardDismissMode")) {
5587
+ nativeList.setKeyboardDismissMode(craftNativeOptionalString(props.opt("keyboardDismissMode")).orEmpty()) {
5593
5588
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
5594
5589
  ?.hideSoftInputFromWindow(windowToken, 0)
5595
5590
  }
@@ -5598,6 +5593,7 @@ class MainActivity : AppCompatActivity() {
5598
5593
  "View", "SafeAreaView" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
5599
5594
  configureContainer(this, style)
5600
5595
  if (type == "SafeAreaView") setSafeAreaEdges(props.optJSONArray("edges"))
5596
+ else setWindowSafeInsets(0, 0, 0, 0)
5601
5597
  reconcileChildren(screen, this, children, identity, next, style, previousControls)
5602
5598
  }
5603
5599
  "KeyboardAvoidingView" -> (previous as? CraftNativeKeyboardAvoidingView ?: CraftNativeKeyboardAvoidingView(this)).apply {
@@ -5744,7 +5740,7 @@ class MainActivity : AppCompatActivity() {
5744
5740
  if (type == "ActivityIndicator") {
5745
5741
  val animating = craftNativeBoolean(props.opt("animating"), true)
5746
5742
  val hidesWhenStopped = craftNativeBoolean(props.opt("hidesWhenStopped"), true)
5747
- control.visibility = if (style.optString("display") == "none" || (!animating && hidesWhenStopped)) {
5743
+ control.visibility = if (craftNativeOptionalString(style.opt("display")) == "none" || (!animating && hidesWhenStopped)) {
5748
5744
  View.GONE
5749
5745
  } else {
5750
5746
  View.VISIBLE
@@ -5865,7 +5861,7 @@ class MainActivity : AppCompatActivity() {
5865
5861
  layout.setChildStyle(childView, child.optJSONObject("style") ?: JSONObject(), resources.displayMetrics.density)
5866
5862
  }
5867
5863
  }
5868
- if (style.optString("flexDirection").endsWith("-reverse")) desired.reverse()
5864
+ if (craftNativeOptionalString(style.opt("flexDirection"))?.endsWith("-reverse") == true) desired.reverse()
5869
5865
  val desiredSet = desired.toSet()
5870
5866
  for ((index, childView) in desired.withIndex()) {
5871
5867
  if (layout.getChildAt(index) !== childView) {
@@ -5992,7 +5988,7 @@ class MainActivity : AppCompatActivity() {
5992
5988
  }
5993
5989
  applyTransform(view, style.optJSONArray("transform"))
5994
5990
  if (view !is CraftNativeModalView) {
5995
- view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
5991
+ view.visibility = if (craftNativeOptionalString(style.opt("display")) == "none") View.GONE else View.VISIBLE
5996
5992
  }
5997
5993
  view.elevation = dp(craftNativeNonNegativeDouble(style, "elevation", 0.0)).toFloat()
5998
5994
  view.z = craftNativeFiniteFloat(style, "zIndex", 0f)
@@ -6001,13 +5997,13 @@ class MainActivity : AppCompatActivity() {
6001
5997
  // background paints over the decoded picture (only rounded corners
6002
5998
  // showed it). Keep the outline/borders, with a transparent fill.
6003
5999
  val backgroundColor = if (view is CraftNativeVideoView) Color.TRANSPARENT
6004
- else color(style.optString("backgroundColor"), Color.TRANSPARENT)
6000
+ else color(craftNativeOptionalString(style.opt("backgroundColor")).orEmpty(), Color.TRANSPARENT)
6005
6001
  val border = craftNativeNonNegativeDouble(style, "borderWidth", 0.0)
6006
6002
  val radius = craftNativeNonNegativeDouble(style, "borderRadius", 0.0)
6007
- val gradientDirection = style.optString("gradientDirection").takeIf { it.isNotBlank() }
6008
- val gradientFrom = style.optString("gradientFrom").takeIf { it.isNotBlank() }
6009
- val gradientVia = style.optString("gradientVia").takeIf { it.isNotBlank() }
6010
- val gradientTo = style.optString("gradientTo").takeIf { it.isNotBlank() }
6003
+ val gradientDirection = craftNativeOptionalString(style.opt("gradientDirection"))
6004
+ val gradientFrom = craftNativeOptionalString(style.opt("gradientFrom"))
6005
+ val gradientVia = craftNativeOptionalString(style.opt("gradientVia"))
6006
+ val gradientTo = craftNativeOptionalString(style.opt("gradientTo"))
6011
6007
  val gradientColors = if (gradientDirection != null && gradientFrom != null && gradientTo != null) {
6012
6008
  listOfNotNull(
6013
6009
  color(gradientFrom, Color.TRANSPARENT),
@@ -6025,7 +6021,7 @@ class MainActivity : AppCompatActivity() {
6025
6021
  listOf(radius, radius)
6026
6022
  }.toFloatArray()
6027
6023
  val sideColors = listOf("borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor")
6028
- .map { color(style.optString(it).ifBlank { style.optString("borderColor") }, Color.TRANSPARENT) }
6024
+ .map { color(craftNativeOptionalString(style.opt(it)) ?: craftNativeOptionalString(style.opt("borderColor")).orEmpty(), Color.TRANSPARENT) }
6029
6025
  .toIntArray()
6030
6026
  val defaultBackground = if (defaultBackgrounds.containsKey(view)) {
6031
6027
  defaultBackgrounds[view]
@@ -6039,7 +6035,7 @@ class MainActivity : AppCompatActivity() {
6039
6035
  cornerRadii,
6040
6036
  sideWidths,
6041
6037
  sideColors,
6042
- style.optString("borderStyle").takeIf { it == "dashed" || it == "dotted" },
6038
+ craftNativeOptionalString(style.opt("borderStyle"))?.takeIf { it == "dashed" || it == "dotted" },
6043
6039
  gradientDirection,
6044
6040
  gradientColors,
6045
6041
  )
@@ -6051,8 +6047,8 @@ class MainActivity : AppCompatActivity() {
6051
6047
  } else setColor(backgroundColor)
6052
6048
  if (hasCornerRadii) setCornerRadii(cornerRadii) else cornerRadius = dp(radius).toFloat()
6053
6049
  if (border > 0) {
6054
- val borderColor = color(style.optString("borderColor"), Color.TRANSPARENT)
6055
- when (style.optString("borderStyle")) {
6050
+ val borderColor = color(craftNativeOptionalString(style.opt("borderColor")).orEmpty(), Color.TRANSPARENT)
6051
+ when (craftNativeOptionalString(style.opt("borderStyle"))) {
6056
6052
  "dashed" -> setStroke(dp(border), borderColor, dp(border * 3).toFloat(), dp(border * 2).toFloat())
6057
6053
  "dotted" -> setStroke(dp(border), borderColor, dp(border).toFloat(), dp(border * 1.5).toFloat())
6058
6054
  else -> setStroke(dp(border), borderColor)
@@ -6065,13 +6061,14 @@ class MainActivity : AppCompatActivity() {
6065
6061
  // and the web.
6066
6062
  null
6067
6063
  } else defaultBackground
6068
- view.clipToOutline = style.optString("overflow") == "hidden"
6069
- if (view is ViewGroup) view.clipChildren = style.optString("overflow") == "hidden"
6064
+ val overflow = craftNativeOptionalString(style.opt("overflow"))
6065
+ view.clipToOutline = overflow == "hidden"
6066
+ if (view is ViewGroup) view.clipChildren = overflow == "hidden"
6070
6067
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
6071
6068
  (style.has("shadowColor") || style.has("shadowOpacity") || style.has("shadowRadius"))
6072
6069
  ) {
6073
6070
  val shadowOpacity = craftNativeFiniteFloat(style, "shadowOpacity", 1f).coerceIn(0f, 1f)
6074
- val shadow = color(style.optString("shadowColor"), Color.BLACK)
6071
+ val shadow = color(craftNativeOptionalString(style.opt("shadowColor")).orEmpty(), Color.BLACK)
6075
6072
  val alphaShadow = Color.argb((Color.alpha(shadow) * shadowOpacity).roundToInt(), Color.red(shadow), Color.green(shadow), Color.blue(shadow))
6076
6073
  view.outlineAmbientShadowColor = alphaShadow
6077
6074
  view.outlineSpotShadowColor = alphaShadow
@@ -6169,7 +6166,7 @@ class MainActivity : AppCompatActivity() {
6169
6166
  }
6170
6167
 
6171
6168
  private fun configureText(view: TextView, value: String, style: JSONObject) {
6172
- view.text = when (style.optString("textTransform")) {
6169
+ view.text = when (craftNativeOptionalString(style.opt("textTransform"))) {
6173
6170
  "uppercase" -> value.uppercase()
6174
6171
  "lowercase" -> value.lowercase()
6175
6172
  "capitalize" -> value.split(' ').joinToString(" ") { word -> word.replaceFirstChar(Char::uppercase) }
@@ -6279,21 +6276,22 @@ class MainActivity : AppCompatActivity() {
6279
6276
  }
6280
6277
 
6281
6278
  private fun configureTextStyle(view: TextView, style: JSONObject) {
6282
- view.setTextColor(color(style.optString("color"), defaultTextColor))
6279
+ view.setTextColor(color(craftNativeOptionalString(style.opt("color")).orEmpty(), defaultTextColor))
6283
6280
  view.setTextSize(TypedValue.COMPLEX_UNIT_SP, craftNativeFiniteFloat(style, "fontSize", 16f))
6284
- val fontWeight = style.opt("fontWeight") as? String
6281
+ val fontWeight = craftNativeFontWeight(style.opt("fontWeight"))
6285
6282
  val weight = when (fontWeight) {
6286
6283
  "bold" -> 700
6287
6284
  "100", "200", "300", "400", "500", "600", "700", "800", "900" -> style.optInt("fontWeight")
6288
6285
  else -> 400
6289
6286
  }
6290
- val italic = style.optString("fontStyle") == "italic"
6291
- val family = style.optString("fontFamily").takeIf { it.isNotBlank() }
6287
+ val italic = craftNativeOptionalString(style.opt("fontStyle")) == "italic"
6288
+ val family = craftNativeOptionalString(style.opt("fontFamily"))?.takeIf { it.isNotBlank() }
6292
6289
  view.typeface = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
6293
6290
  Typeface.create(Typeface.create(family, Typeface.NORMAL), weight, italic)
6294
6291
  } else Typeface.create(family, (if (weight >= 600) Typeface.BOLD else Typeface.NORMAL) or
6295
6292
  (if (italic) Typeface.ITALIC else Typeface.NORMAL))
6296
- view.textAlignment = when (style.optString("textAlign")) {
6293
+ val textAlign = craftNativeOptionalString(style.opt("textAlign"))
6294
+ view.textAlignment = when (textAlign) {
6297
6295
  "left" -> View.TEXT_ALIGNMENT_TEXT_START
6298
6296
  "right" -> View.TEXT_ALIGNMENT_TEXT_END
6299
6297
  "center" -> View.TEXT_ALIGNMENT_CENTER
@@ -6301,7 +6299,7 @@ class MainActivity : AppCompatActivity() {
6301
6299
  else -> View.TEXT_ALIGNMENT_INHERIT
6302
6300
  }
6303
6301
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
6304
- view.justificationMode = if (style.optString("textAlign") == "justify") {
6302
+ view.justificationMode = if (textAlign == "justify") {
6305
6303
  Layout.JUSTIFICATION_MODE_INTER_WORD
6306
6304
  } else {
6307
6305
  Layout.JUSTIFICATION_MODE_NONE
@@ -6315,7 +6313,7 @@ class MainActivity : AppCompatActivity() {
6315
6313
  craftNativeFiniteDouble(style, "lineHeight", Double.NaN).takeIf { it.isFinite() }?.let { dp(it) - view.textSize } ?: 0f,
6316
6314
  1f
6317
6315
  )
6318
- val shadowColor = style.optString("textShadowColor").takeIf { it.isNotBlank() }
6316
+ val shadowColor = craftNativeOptionalString(style.opt("textShadowColor"))?.takeIf { it.isNotBlank() }
6319
6317
  if (shadowColor == null) {
6320
6318
  view.paint.clearShadowLayer()
6321
6319
  } else {
@@ -6332,16 +6330,16 @@ class MainActivity : AppCompatActivity() {
6332
6330
  }
6333
6331
 
6334
6332
  private fun applyTextDecoration(view: TextView, style: JSONObject) {
6335
- val line = style.optString("textDecorationLine")
6333
+ val line = craftNativeOptionalString(style.opt("textDecorationLine")).orEmpty()
6336
6334
  if (view is EditText) {
6337
6335
  // A field's own text, spanned in place: replacing it would move
6338
6336
  // the caret to the start and tell the screen the text changed.
6339
6337
  val editable = view.text ?: return
6340
6338
  editable.getSpans(0, editable.length, CraftTextDecorationSpan::class.java).forEach(editable::removeSpan)
6341
6339
  if (line.isBlank() || line == "none" || editable.isEmpty()) return
6342
- val fieldColor = style.optString("textDecorationColor").takeIf { it.isNotBlank() }?.let { color(it, Color.TRANSPARENT) }
6340
+ val fieldColor = craftNativeOptionalString(style.opt("textDecorationColor"))?.takeIf { it.isNotBlank() }?.let { color(it, Color.TRANSPARENT) }
6343
6341
  editable.setSpan(
6344
- CraftTextDecorationSpan(line, style.optString("textDecorationStyle").ifBlank { "solid" }, fieldColor),
6342
+ CraftTextDecorationSpan(line, craftNativeOptionalString(style.opt("textDecorationStyle")).orEmpty().ifBlank { "solid" }, fieldColor),
6345
6343
  0,
6346
6344
  editable.length,
6347
6345
  Spannable.SPAN_INCLUSIVE_INCLUSIVE,
@@ -6353,14 +6351,14 @@ class MainActivity : AppCompatActivity() {
6353
6351
  if (view.text is Spannable) view.text = text
6354
6352
  return
6355
6353
  }
6356
- val decorationColor = style.optString("textDecorationColor")
6354
+ val decorationColor = craftNativeOptionalString(style.opt("textDecorationColor")).orEmpty()
6357
6355
  .takeIf { it.isNotBlank() }
6358
6356
  ?.let { color(it, Color.TRANSPARENT) }
6359
6357
  val decorated = SpannableString(text)
6360
6358
  decorated.setSpan(
6361
6359
  CraftTextDecorationSpan(
6362
6360
  line,
6363
- style.optString("textDecorationStyle").ifBlank { "solid" },
6361
+ craftNativeOptionalString(style.opt("textDecorationStyle")).orEmpty().ifBlank { "solid" },
6364
6362
  decorationColor,
6365
6363
  ),
6366
6364
  0,
@@ -6568,8 +6566,16 @@ class MainActivity : AppCompatActivity() {
6568
6566
  info.roleDescription = role.takeIf { it !in setOf("view", "text", "textinput", "scrollview") }
6569
6567
  info.hintText = host.tooltipText
6570
6568
  info.isEnabled = host.isEnabled && !disabled
6571
- info.isSelected = craftNativeBoolean(state?.opt("selected"))
6572
- info.isChecked = craftNativeBoolean(state?.opt("checked"))
6569
+ // Keep the native widget state when no accessibilityState was
6570
+ // authored. Overwriting it with the strict false fallback
6571
+ // made a checked Switch/CheckBox announce as unchecked unless
6572
+ // the screen redundantly mirrored its value into metadata.
6573
+ if (state?.has("selected") == true) {
6574
+ info.isSelected = craftNativeBoolean(state.opt("selected"))
6575
+ }
6576
+ if (state?.has("checked") == true) {
6577
+ info.isChecked = craftNativeBoolean(state.opt("checked"))
6578
+ }
6573
6579
  }
6574
6580
  })
6575
6581
  ViewCompat.setImportantForAccessibility(
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.207";
3427
+ var version = "0.0.210";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
@@ -245,7 +245,7 @@ export interface TextStyleProps {
245
245
  /** Font style */
246
246
  fontStyle?: 'normal' | 'italic';
247
247
  /** Font weight */
248
- fontWeight?: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
248
+ fontWeight?: number | 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
249
249
  /** Letter spacing */
250
250
  letterSpacing?: number;
251
251
  /** Line height */
@@ -287,6 +287,8 @@ export interface ImageStyleProps {
287
287
  * Combined Image style type.
288
288
  */
289
289
  export type ImageStyle = ViewStyle & ImageStyleProps;
290
+ /** A native style may be nested or conditionally omitted in any component's `style` prop. */
291
+ export type StyleValue<T> = T | StyleValue<T>[] | null | false | undefined;
290
292
  /**
291
293
  * Base props for all components.
292
294
  */
@@ -320,7 +322,7 @@ export interface AccessibilityState {
320
322
  */
321
323
  export interface ViewProps extends BaseProps {
322
324
  /** Style object or array */
323
- style?: ViewStyle | ViewStyle[];
325
+ style?: StyleValue<ViewStyle>;
324
326
  /** Press handler */
325
327
  onPress?: () => void;
326
328
  /** DOM-compatible press alias accepted by native hosts. */
@@ -356,19 +358,23 @@ export interface RefreshControlProps extends BaseProps {
356
358
  */
357
359
  export interface TextProps extends BaseProps {
358
360
  /** Style object or array */
359
- style?: TextStyle | TextStyle[];
361
+ style?: StyleValue<TextStyle>;
360
362
  /** Number of lines before truncation */
361
363
  numberOfLines?: number;
362
364
  /** Ellipsize mode */
363
365
  ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip';
364
366
  /** Press handler */
365
367
  onPress?: () => void;
368
+ /** DOM-compatible press alias accepted by native hosts. */
369
+ onClick?: () => void;
366
370
  /** Long press handler */
367
371
  onLongPress?: () => void;
368
372
  }
369
373
  /** Native button/link action props. */
370
374
  export interface ButtonProps extends BaseProps {
371
375
  title?: string;
376
+ /** Text and layout styling applied by the native button hosts. */
377
+ style?: StyleValue<TextStyle>;
372
378
  onPress?: () => void;
373
379
  onClick?: () => void;
374
380
  disabled?: boolean;
@@ -393,10 +399,17 @@ export interface TouchableHighlightProps extends ViewProps {
393
399
  /**
394
400
  * Image source type.
395
401
  */
396
- export type ImageSource = {
402
+ export type ImageSource = string | {
397
403
  uri: string;
398
404
  headers?: Record<string, string>;
399
405
  };
406
+ export interface ImageErrorEvent {
407
+ nativeEvent: {
408
+ error: {
409
+ message: string;
410
+ };
411
+ };
412
+ }
400
413
  /**
401
414
  * Image component props.
402
415
  */
@@ -404,7 +417,7 @@ export interface ImageProps extends BaseProps {
404
417
  /** Image source */
405
418
  source: ImageSource;
406
419
  /** Style object or array */
407
- style?: ImageStyle | ImageStyle[];
420
+ style?: StyleValue<ImageStyle>;
408
421
  /** Resize mode */
409
422
  resizeMode?: 'cover' | 'contain' | 'stretch' | 'center';
410
423
  /** Load start handler */
@@ -413,19 +426,21 @@ export interface ImageProps extends BaseProps {
413
426
  onLoad?: () => void;
414
427
  /** Load end handler */
415
428
  onLoadEnd?: () => void;
416
- /** Error handler */
417
- onError?: (error: Error) => void;
429
+ /** Error handler; native failures are exposed as nativeEvent.error.message. */
430
+ onError?: (event: ImageErrorEvent) => void;
418
431
  /** Default source while loading */
419
432
  defaultSource?: ImageSource;
420
433
  /** Alt text for accessibility */
421
434
  alt?: string;
422
435
  }
423
436
  export interface IconProps extends BaseProps {
437
+ style?: StyleValue<TextStyle>;
424
438
  name?: string;
425
439
  symbol?: string;
426
440
  color?: string;
427
441
  }
428
442
  export interface VideoProps extends BaseProps {
443
+ style?: StyleValue<ViewStyle>;
429
444
  source?: string | {
430
445
  uri: string;
431
446
  };
@@ -472,6 +487,7 @@ export interface MapRegion {
472
487
  };
473
488
  }
474
489
  export interface MapProps extends BaseProps {
490
+ style?: StyleValue<ViewStyle>;
475
491
  region?: MapRegion;
476
492
  /** Initial camera region; applied once, then controlled by subsequent props. */
477
493
  initialRegion?: MapRegion;
@@ -512,7 +528,7 @@ export interface ImageBackgroundProps extends ImageProps {
512
528
  }
513
529
  /** Shared paint and accessibility props for DOM-free native SVG nodes. */
514
530
  export interface SvgPaintProps extends BaseProps {
515
- style?: ViewStyle | ViewStyle[];
531
+ style?: StyleValue<ViewStyle>;
516
532
  fill?: string;
517
533
  stroke?: string;
518
534
  strokeWidth?: number;
@@ -629,7 +645,7 @@ export interface PressableProps extends ViewProps {
629
645
  */
630
646
  export interface TextInputProps extends BaseProps {
631
647
  /** Style object or array */
632
- style?: TextStyle | TextStyle[];
648
+ style?: StyleValue<TextStyle>;
633
649
  /** Current value */
634
650
  value?: string;
635
651
  /** Default value */
@@ -680,6 +696,7 @@ export interface TextInputProps extends BaseProps {
680
696
  export type KeyboardType = 'default' | 'email-address' | 'numeric' | 'phone-pad' | 'number-pad' | 'decimal-pad' | 'url' | 'web-search' | 'visible-password';
681
697
  export type ReturnKeyType = 'done' | 'go' | 'next' | 'search' | 'send' | 'join' | 'route' | 'continue' | 'emergency-call' | 'default';
682
698
  export interface SwitchProps extends BaseProps {
699
+ style?: StyleValue<ViewStyle>;
683
700
  /** Controlled boolean state; malformed native bridge values stay off. */
684
701
  value?: boolean;
685
702
  onValueChange?: (value: boolean) => void;
@@ -693,6 +710,7 @@ export interface SwitchProps extends BaseProps {
693
710
  ios_backgroundColor?: string;
694
711
  }
695
712
  export interface CheckboxProps extends BaseProps {
713
+ style?: StyleValue<ViewStyle>;
696
714
  /** Controlled boolean state; malformed native bridge values stay off. */
697
715
  value?: boolean;
698
716
  checked?: boolean;
@@ -704,6 +722,7 @@ export interface CheckboxProps extends BaseProps {
704
722
  color?: string;
705
723
  }
706
724
  export interface SliderProps extends BaseProps {
725
+ style?: StyleValue<ViewStyle>;
707
726
  value?: number;
708
727
  minimumValue?: number;
709
728
  maximumValue?: number;
@@ -722,6 +741,7 @@ export interface PickerOption {
722
741
  value: string | number;
723
742
  }
724
743
  export interface PickerProps extends BaseProps {
744
+ style?: StyleValue<ViewStyle>;
725
745
  /** Typed options; child options remain supported when omitted. */
726
746
  items?: PickerOption[];
727
747
  selectedValue?: string | number;
@@ -735,7 +755,8 @@ export interface DatePickerChange {
735
755
  timestamp: number;
736
756
  }
737
757
  export interface DatePickerProps extends BaseProps {
738
- mode?: 'date' | 'time' | 'datetime' | 'countdown';
758
+ style?: StyleValue<ViewStyle>;
759
+ mode?: 'date' | 'time' | 'datetime' | 'datetime-local' | 'countdown';
739
760
  value?: string | number;
740
761
  minimumDate?: string | number;
741
762
  maximumDate?: string | number;
@@ -745,6 +766,7 @@ export interface DatePickerProps extends BaseProps {
745
766
  disabled?: boolean;
746
767
  }
747
768
  export interface ActivityIndicatorProps extends BaseProps {
769
+ style?: StyleValue<ViewStyle>;
748
770
  color?: string;
749
771
  animating?: boolean;
750
772
  hidesWhenStopped?: boolean;
@@ -782,6 +804,7 @@ export interface SectionListSection<T> {
782
804
  export interface SectionListProps<T> extends ScrollViewProps {
783
805
  }
784
806
  export interface ModalProps extends BaseProps {
807
+ style?: StyleValue<ViewStyle>;
785
808
  visible?: boolean;
786
809
  transparent?: boolean;
787
810
  animationType?: 'none' | 'fade' | 'slide';
@@ -893,9 +916,9 @@ export declare const StyleSheet: {
893
916
  */
894
917
  create<T extends Record<string, ViewStyle | TextStyle | ImageStyle>>(styles: T): T;
895
918
  /**
896
- * Flatten an array of styles into a single object.
919
+ * Flatten nested style arrays and ignore falsy conditional entries.
897
920
  */
898
- flatten<T>(style: T | T[] | undefined): T | undefined;
921
+ flatten<T>(style: StyleValue<T>): T | undefined;
899
922
  /**
900
923
  * Get the absolute fill style (full parent).
901
924
  */
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.207";
312
+ var version = "0.0.210";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
@@ -8625,10 +8625,19 @@ var StyleSheet = {
8625
8625
  flatten(style) {
8626
8626
  if (!style)
8627
8627
  return;
8628
- if (Array.isArray(style)) {
8629
- return Object.assign({}, ...style);
8630
- }
8631
- return style;
8628
+ const flattened = {};
8629
+ const merge = (value) => {
8630
+ if (!value)
8631
+ return;
8632
+ if (Array.isArray(value)) {
8633
+ value.forEach(merge);
8634
+ return;
8635
+ }
8636
+ if (typeof value === "object")
8637
+ Object.assign(flattened, value);
8638
+ };
8639
+ merge(style);
8640
+ return flattened;
8632
8641
  },
8633
8642
  absoluteFill: {
8634
8643
  position: "absolute",
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.207";
60
+ var version = "0.0.210";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -8384,10 +8384,19 @@ var StyleSheet = {
8384
8384
  flatten(style) {
8385
8385
  if (!style)
8386
8386
  return;
8387
- if (Array.isArray(style)) {
8388
- return Object.assign({}, ...style);
8389
- }
8390
- return style;
8387
+ const flattened = {};
8388
+ const merge = (value) => {
8389
+ if (!value)
8390
+ return;
8391
+ if (Array.isArray(value)) {
8392
+ value.forEach(merge);
8393
+ return;
8394
+ }
8395
+ if (typeof value === "object")
8396
+ Object.assign(flattened, value);
8397
+ };
8398
+ merge(style);
8399
+ return flattened;
8391
8400
  },
8392
8401
  absoluteFill: {
8393
8402
  position: "absolute",
@@ -621,6 +621,7 @@ final class CraftChrome: ObservableObject {
621
621
  }
622
622
 
623
623
  @Published var tabs: [Tab] = []
624
+ @Published var tabAccessory: Tab?
624
625
  @Published var selected: String?
625
626
  @Published var tabBarVisible = false
626
627
  @Published var splashVisible = true
@@ -688,6 +689,16 @@ final class CraftChrome: ObservableObject {
688
689
  tabs[index].badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
689
690
  }
690
691
  }
692
+ /// A destination beside the tab pill, outside the tab stacks and swipe order.
693
+ func updateTabAccessory(_ item: [String: Any]?) {
694
+ guard let item, let path = item["path"] as? String,
695
+ path.hasPrefix("/"), !path.hasPrefix("//"),
696
+ let title = item["title"] as? String else { tabAccessory = nil; return }
697
+ tabAccessory = Tab(id: path, title: title, symbol: item["symbol"] as? String ?? "message",
698
+ badge: (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) },
699
+ accessibilityLabel: item["accessibilityLabel"] as? String)
700
+ }
701
+
691
702
  func setHiddenByScreen(_ hidden: Bool) {
692
703
  guard hiddenByScreen != hidden else { return }
693
704
  hiddenByScreen = hidden
@@ -913,7 +924,33 @@ struct CraftTabBarView: View {
913
924
 
914
925
  var body: some View {
915
926
  if chrome.tabBarVisible && !chrome.tabs.isEmpty {
916
- bar
927
+ HStack(spacing: 8) {
928
+ bar
929
+ if let accessory = chrome.tabAccessory {
930
+ Button { CraftHybrid.shared.open(accessory.id) } label: {
931
+ surface {
932
+ Image(systemName: accessory.symbol)
933
+ .font(.system(size: iconSize))
934
+ .foregroundStyle(Color.primary)
935
+ .frame(width: 62, height: 62)
936
+ }
937
+ .overlay(alignment: .topTrailing) {
938
+ if let badge = accessory.badge {
939
+ Text(badge)
940
+ .font(.system(size: badgeSize, weight: .bold))
941
+ .foregroundStyle(.white)
942
+ .padding(.horizontal, 4)
943
+ .frame(minWidth: 18, minHeight: 18)
944
+ .background(Capsule().fill(Color.red))
945
+ }
946
+ }
947
+ }
948
+ .buttonStyle(.plain)
949
+ .accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
950
+ .accessibilityValue(accessory.badge ?? "")
951
+ .accessibilityIdentifier("craft-tab-accessory")
952
+ }
953
+ }
917
954
  .padding(.horizontal, 16)
918
955
  .padding(.bottom, 4)
919
956
  .background(GeometryReader { proxy in
@@ -932,24 +969,27 @@ struct CraftTabBarView: View {
932
969
  chrome.layoutChanged(max(0, screen - proxy.frame(in: .global).minY))
933
970
  }
934
971
 
935
- @ViewBuilder private var bar: some View {
972
+ private var bar: some View {
973
+ surface { items.padding(4) }
974
+ }
975
+
976
+ /// Both the tab capsule and its circular accessory use the same material,
977
+ /// outline and shadow. A square capsule forms the accessory's circle.
978
+ @ViewBuilder private func surface<Content: View>(@ViewBuilder content: () -> Content) -> some View {
936
979
  #if compiler(>=6.2)
937
980
  if #available(iOS 26.0, *) {
938
981
  GlassEffectContainer {
939
- items
940
- .padding(4)
982
+ content()
941
983
  .glassEffect(.regular.interactive(), in: .capsule)
942
984
  }
943
985
  } else {
944
- items
945
- .padding(4)
986
+ content()
946
987
  .background(.ultraThinMaterial, in: Capsule())
947
988
  .overlay(Capsule().strokeBorder(Color.white.opacity(0.35), lineWidth: 0.5))
948
989
  .shadow(color: .black.opacity(0.12), radius: 18, y: 6)
949
990
  }
950
991
  #else
951
- items
952
- .padding(4)
992
+ content()
953
993
  .background(.ultraThinMaterial, in: Capsule())
954
994
  .overlay(Capsule().strokeBorder(Color.white.opacity(0.35), lineWidth: 0.5))
955
995
  .shadow(color: .black.opacity(0.12), radius: 18, y: 6)
@@ -3959,11 +3959,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3959
3959
  // Each screen says whether it wants the tab bar, so one that hid it
3960
3960
  // never leaves it hidden for the next.
3961
3961
  CraftChrome.shared.setHiddenByScreen(tabBarHidden)
3962
+ CraftChrome.shared.updateTabAccessory(tabAccessory)
3962
3963
  refreshTraitDefaults()
3963
3964
  }
3964
3965
 
3965
3966
  override func viewWillDisappear(_ animated: Bool) {
3966
3967
  super.viewWillDisappear(animated)
3968
+ // The accessory belongs to the visible native screen. Clear the
3969
+ // shared shell state before a hybrid/web route appears, otherwise a
3970
+ // destination from the outgoing screen remains tappable on top of it.
3971
+ CraftChrome.shared.updateTabAccessory(nil)
3967
3972
  // Popped (or its stack dismissed) while the bar was away: it comes
3968
3973
  // back for whatever shows next, a web page included.
3969
3974
  if tabBarHidden, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
@@ -4146,6 +4151,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4146
4151
  let appVersion = Self.jsonString(info?["CFBundleShortVersionString"] as? String ?? "")
4147
4152
  let appBuild = Self.jsonString(info?["CFBundleVersion"] as? String ?? "")
4148
4153
  jsContext.evaluateScript("""
4154
+ // JavaScriptCore has no DOM window, but browser-safe SDK bridges
4155
+ // address the host through `window.craft`. Keep native screens
4156
+ // compatible without pretending a document exists.
4157
+ if (typeof globalThis.window === "undefined") { globalThis.window = globalThis; }
4149
4158
  globalThis.__stxNativeCallback = null;
4150
4159
  globalThis.__stxNativeBridge = {
4151
4160
  platform: "ios",
@@ -4274,7 +4283,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4274
4283
  /// `craft.secureStorage.set` wrote.
4275
4284
  /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
4276
4285
  /// tabBarHidden, swipeBack, contentSwipeBack, tabSwipeEnabled,
4277
- /// backgroundColor, navigationBarSolid, rightButtons })`;
4286
+ /// backgroundColor, navigationBarSolid, rightButtons, tabAccessory })`;
4278
4287
  /// a right button's tap is the event `navButton`.
4279
4288
  ///
4280
4289
  /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
@@ -4529,6 +4538,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4529
4538
  private var statusBarHiddenOverride: Bool?
4530
4539
  /// The app's tab bar away while this screen shows (`tabBarHidden`).
4531
4540
  private var tabBarHidden = false
4541
+ private var tabAccessory: [String: Any]?
4532
4542
  /// Whether a back-swipe may leave this screen at all, the edge's
4533
4543
  /// included (`swipeBack: false`): a screen that steps through its own
4534
4544
  /// pages with a Back of its own (a first-run tour's questions) would
@@ -4575,6 +4585,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4575
4585
  }
4576
4586
  // The app script has no screen, so no bar and no tab bar to change.
4577
4587
  guard !isAppScript else { return }
4588
+ if options.keys.contains("tabAccessory") {
4589
+ tabAccessory = options["tabAccessory"] as? [String: Any]
4590
+ if navigationController?.topViewController === self, view.window != nil {
4591
+ CraftChrome.shared.updateTabAccessory(tabAccessory)
4592
+ }
4593
+ }
4578
4594
  if options.keys.contains("title") {
4579
4595
  navigationItem.title = options["title"] as? String
4580
4596
  titleFromOptions = true
@@ -5719,7 +5735,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5719
5735
  ? nil : number(props["activeOpacity"]) ?? 0.5
5720
5736
  pressable.pressBaseOpacity = number(style["opacity"]) ?? 1
5721
5737
  pressable.pressUnderlayColor = type == "TouchableHighlight" && !disabled ? color(props["underlayColor"]) : nil
5722
- pressable.pressBaseBackgroundColor = pressable.backgroundColor
5723
5738
  case "Switch":
5724
5739
  let toggle = result as! UISwitch
5725
5740
  toggle.isOn = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
@@ -6677,6 +6692,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6677
6692
  updateDimension(width, constraint: &node.widthConstraint, anchor: view.widthAnchor)
6678
6693
  updateDimension(height, constraint: &node.heightConstraint, anchor: view.heightAnchor)
6679
6694
  view.backgroundColor = color(style["backgroundColor"]) ?? .clear
6695
+ // Every flow container can receive press feedback, including ordinary
6696
+ // Views. Capture its current authored fill before feedback cleanup or
6697
+ // release restores it; otherwise avatars and button wrappers go clear.
6698
+ (view as? CraftNativeFlowView)?.pressBaseBackgroundColor = view.backgroundColor
6680
6699
  // A field the style dresses itself (`border-0`, a border or a
6681
6700
  // background, as a search field inside its own pill is) draws no
6682
6701
  // system box of its own; a bare one keeps the rounded rectangle.
@@ -6928,7 +6947,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6928
6947
  private func textFont(_ style: [String: Any], default fallback: UIFont) -> UIFont {
6929
6948
  let size = number(style["fontSize"]) ?? fallback.pointSize
6930
6949
  let weight: UIFont.Weight
6931
- switch style["fontWeight"] as? String {
6950
+ switch craftNativeFontWeight(style["fontWeight"]) {
6932
6951
  case "100": weight = .ultraLight
6933
6952
  case "200": weight = .thin
6934
6953
  case "300": weight = .light
@@ -6939,11 +6958,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6939
6958
  case "900": weight = .black
6940
6959
  default: weight = .regular
6941
6960
  }
6942
- var font = (style["fontFamily"] as? String).flatMap { UIFont(name: $0, size: size) }
6943
- ?? .systemFont(ofSize: size, weight: weight)
6961
+ // Android's TextView uses SP, so its native text scales with the
6962
+ // user's content-size setting. Apply the same Dynamic Type multiplier
6963
+ // here before assigning the font to labels, buttons, and inputs.
6964
+ let scaledSize = UIFontMetrics(forTextStyle: .body).scaledValue(
6965
+ for: size,
6966
+ compatibleWith: traitCollection
6967
+ )
6968
+ var font = (style["fontFamily"] as? String).flatMap { UIFont(name: $0, size: scaledSize) }
6969
+ ?? .systemFont(ofSize: scaledSize, weight: weight)
6944
6970
  if style["fontStyle"] as? String == "italic",
6945
6971
  let descriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) {
6946
- font = UIFont(descriptor: descriptor, size: size)
6972
+ font = UIFont(descriptor: descriptor, size: scaledSize)
6947
6973
  }
6948
6974
  return font
6949
6975
  }
@@ -7143,7 +7169,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7143
7169
  || controlledVideo
7144
7170
  view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
7145
7171
  view.accessibilityContainerType = container && (view.accessibilityLabel != nil || controlledVideo) ? .semanticGroup : .none
7172
+ // Keep the interaction trait native controls expose. Rebuilding this
7173
+ // set from the STX role alone otherwise turns a Slider, Picker,
7174
+ // DatePicker, or Switch into a static VoiceOver item whenever the
7175
+ // screen omits redundant accessibility metadata.
7146
7176
  var traits: UIAccessibilityTraits = []
7177
+ switch type {
7178
+ case "Slider", "Picker", "DatePicker": traits.insert(.adjustable)
7179
+ case "Switch": traits.insert(.button)
7180
+ default: break
7181
+ }
7147
7182
  switch role ?? type.lowercased() {
7148
7183
  case "tablist": traits.insert(.tabBar)
7149
7184
  case "button", "checkbox": traits.insert(.button)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.207",
3
+ "version": "0.0.210",
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>",