craft-native 0.0.486 → 0.0.490

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.
@@ -12,6 +12,7 @@ import OSLog
12
12
  import Security
13
13
  import SwiftUI
14
14
  import UIKit
15
+ import UIKit.UIGestureRecognizerSubclass
15
16
 
16
17
  /// A menu belongs to its native view, not to coordinates in a web page.
17
18
  final class CraftNativeContextMenu: NSObject, UIContextMenuInteractionDelegate {
@@ -168,8 +169,8 @@ private func craftNativeFontWeight(_ value: Any?) -> String? {
168
169
  }
169
170
 
170
171
  private func craftNativePickerValue(_ value: Any?) -> String? {
171
- if let value = value as? String, !value.isEmpty { return value }
172
- if let value = craftNativeNumber(value), value.doubleValue.isFinite { return String(value.doubleValue) }
172
+ if let value = value as? String { return value }
173
+ if let value = craftNativeNumber(value), value.doubleValue.isFinite { return value.stringValue }
173
174
  return nil
174
175
  }
175
176
 
@@ -5195,6 +5196,23 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
5195
5196
  /// Native inspection of an SVG plot. Coordinates are fractions of the plot's
5196
5197
  /// bounds; labels and values are already localized by the application. No JS
5197
5198
  /// callback or bridge round trip is needed while a finger moves.
5199
+ /// Reports where each touch starts and then fails, so it observes a window
5200
+ /// without ever taking a touch from the view underneath.
5201
+ final class CraftTouchBeganRecognizer: UIGestureRecognizer {
5202
+ private let began: (CGPoint) -> Void
5203
+ init(began: @escaping (CGPoint) -> Void) {
5204
+ self.began = began
5205
+ super.init(target: nil, action: nil)
5206
+ cancelsTouchesInView = false
5207
+ delaysTouchesBegan = false
5208
+ delaysTouchesEnded = false
5209
+ }
5210
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
5211
+ state = .failed
5212
+ if let touch = touches.first { began(touch.location(in: view)) }
5213
+ }
5214
+ }
5215
+
5198
5216
  final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
5199
5217
  struct Reading {
5200
5218
  let key: String
@@ -5214,6 +5232,7 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
5214
5232
  private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
5215
5233
  private lazy var pan = UIPanGestureRecognizer(target: self, action: #selector(panned(_:)))
5216
5234
  private lazy var hold = UILongPressGestureRecognizer(target: self, action: #selector(held(_:)))
5235
+ private var outside: CraftTouchBeganRecognizer?
5217
5236
 
5218
5237
  init(view: UIView) {
5219
5238
  self.view = view
@@ -5241,6 +5260,7 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
5241
5260
  color: (point["color"] as? String).flatMap { UIColor(css: $0) } ?? .systemBlue, label: label, rows: rows)
5242
5261
  }
5243
5262
  selectedIndex = -1
5263
+ unwatchOutside()
5244
5264
  view?.isUserInteractionEnabled = !readings.isEmpty
5245
5265
  if readings.isEmpty { view?.accessibilityTraits.remove(.adjustable); view?.accessibilityValue = nil }
5246
5266
  [tap, pan, hold].forEach { $0.isEnabled = !readings.isEmpty }
@@ -5270,6 +5290,7 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
5270
5290
  }
5271
5291
 
5272
5292
  @discardableResult func dismiss() -> Bool {
5293
+ unwatchOutside()
5273
5294
  guard selectedIndex >= 0 else { return false }
5274
5295
  selectedIndex = -1
5275
5296
  configureAccessibility()
@@ -5277,20 +5298,47 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
5277
5298
  return true
5278
5299
  }
5279
5300
 
5280
- private func select(at point: CGPoint) {
5281
- guard let view, view.bounds.width > 0 else { return }
5301
+ private func nearest(at point: CGPoint) -> Int? {
5302
+ guard let view, view.bounds.width > 0 else { return nil }
5282
5303
  let fraction = max(0, min(1, point.x / view.bounds.width))
5283
- if let nearest = readings.indices.min(by: { abs(readings[$0].x - fraction) < abs(readings[$1].x - fraction) }) { select(nearest) }
5304
+ return readings.indices.min(by: { abs(readings[$0].x - fraction) < abs(readings[$1].x - fraction) })
5305
+ }
5306
+ private func select(at point: CGPoint) {
5307
+ if let index = nearest(at: point) { select(index); watchOutside() }
5308
+ }
5309
+ /// A tap pins the reading under it. Tapping the pinned reading again puts
5310
+ /// it away, so a reading can always be closed where it was opened.
5311
+ func tap(at point: CGPoint) {
5312
+ guard let index = nearest(at: point) else { return }
5313
+ if index == selectedIndex { dismiss() } else { select(at: point) }
5314
+ }
5315
+ /// A drag or a hold reads the chart only while the finger is down.
5316
+ func scrub(_ state: UIGestureRecognizer.State, at point: CGPoint) {
5317
+ switch state {
5318
+ case .began, .changed: select(at: point)
5319
+ case .ended, .cancelled, .failed: dismiss()
5320
+ default: break
5321
+ }
5322
+ }
5323
+ /// A touch that starts anywhere off the chart closes a pinned reading. The
5324
+ /// watcher never recognises, so the touch still reaches what it landed on.
5325
+ func touchBegan(inWindow point: CGPoint) {
5326
+ guard let view, let window = view.window else { return }
5327
+ if !view.bounds.contains(view.convert(point, from: window)) { dismiss() }
5284
5328
  }
5285
- @objc private func tapped(_ gesture: UITapGestureRecognizer) { select(at: gesture.location(in: view)) }
5286
- @objc private func panned(_ gesture: UIPanGestureRecognizer) {
5287
- if gesture.state == .began || gesture.state == .changed { select(at: gesture.location(in: view)) }
5288
- if gesture.state == .cancelled { dismiss() }
5329
+ private func watchOutside() {
5330
+ guard outside == nil, let window = view?.window else { return }
5331
+ let watcher = CraftTouchBeganRecognizer { [weak self] point in self?.touchBegan(inWindow: point) }
5332
+ window.addGestureRecognizer(watcher)
5333
+ outside = watcher
5289
5334
  }
5290
- @objc private func held(_ gesture: UILongPressGestureRecognizer) {
5291
- if gesture.state == .began || gesture.state == .changed { select(at: gesture.location(in: view)) }
5292
- if gesture.state == .cancelled { dismiss() }
5335
+ private func unwatchOutside() {
5336
+ if let outside { outside.view?.removeGestureRecognizer(outside) }
5337
+ outside = nil
5293
5338
  }
5339
+ @objc private func tapped(_ gesture: UITapGestureRecognizer) { tap(at: gesture.location(in: view)) }
5340
+ @objc private func panned(_ gesture: UIPanGestureRecognizer) { scrub(gesture.state, at: gesture.location(in: view)) }
5341
+ @objc private func held(_ gesture: UILongPressGestureRecognizer) { scrub(gesture.state, at: gesture.location(in: view)) }
5294
5342
  func gestureRecognizerShouldBegin(_ gesture: UIGestureRecognizer) -> Bool {
5295
5343
  guard gesture === pan else { return true }
5296
5344
  let velocity = pan.velocity(in: view)
@@ -6002,9 +6050,23 @@ final class CraftNativeSolidSwitch: UIControl {
6002
6050
  return CGRect(x: min(max(x, track.minX), track.maxX - rect.width - stretch),
6003
6051
  y: rect.minY, width: rect.width + stretch, height: rect.height)
6004
6052
  }
6053
+ /// How much wider than tall the thumb is drawn.
6054
+ ///
6055
+ /// iOS 26 draws a wide pill where every system before it drew a circle,
6056
+ /// so a square thumb reads as a control from an older OS sitting among
6057
+ /// native ones. Measured on iOS 26.5 (iPhone 17 Pro simulator) by walking
6058
+ /// a real `UISwitch`: the control is 63x28 and its thumb 37x24, inset 2 on
6059
+ /// every side, travelling from x=2 to x=24.
6060
+ ///
6061
+ /// Expressed against the thumb's own height rather than as a fixed size,
6062
+ /// so it keeps following whatever the real `UISwitch` reports — which is
6063
+ /// where `naturalSize` already comes from. At the system's own 24-point
6064
+ /// thumb height this is exactly 37.
6065
+ private static let thumbAspect: CGFloat = 37.0 / 24.0
6005
6066
  private func thumbRect(at position: CGFloat) -> CGRect {
6006
6067
  let track = trackRect.insetBy(dx: 2, dy: 2)
6007
- return CGRect(x: track.minX + (track.width - track.height) * position, y: track.minY, width: track.height, height: track.height)
6068
+ let width = min(track.width, track.height * Self.thumbAspect)
6069
+ return CGRect(x: track.minX + (track.width - width) * position, y: track.minY, width: width, height: track.height)
6008
6070
  }
6009
6071
  override func draw(_ rect: CGRect) {
6010
6072
  let track = trackRect
@@ -6341,12 +6403,37 @@ private final class CraftNativeTextView: UITextView {
6341
6403
  if !isScrollEnabled, contentOffset != .zero { contentOffset = .zero }
6342
6404
  let inset = textContainerInset
6343
6405
  let padding = textContainer.lineFragmentPadding
6344
- placeholderLabel.frame = CGRect(
6345
- x: inset.left + padding,
6346
- y: inset.top,
6347
- width: max(0, bounds.width - inset.left - inset.right - padding * 2),
6348
- height: max(0, bounds.height - inset.top - inset.bottom)
6349
- )
6406
+ let measured = placeholderLabel
6407
+ .sizeThatFits(CGSize(width: Self.placeholderWidth(bounds: bounds, inset: inset, padding: padding),
6408
+ height: .greatestFiniteMagnitude))
6409
+ placeholderLabel.frame = Self.placeholderFrame(bounds: bounds, inset: inset, padding: padding,
6410
+ fittedHeight: measured.height)
6411
+ }
6412
+
6413
+ static func placeholderWidth(bounds: CGRect, inset: UIEdgeInsets, padding: CGFloat) -> CGFloat {
6414
+ max(0, bounds.width - inset.left - inset.right - padding * 2)
6415
+ }
6416
+
6417
+ /// Where the placeholder sits inside the box.
6418
+ ///
6419
+ /// Anchored at the top, and no taller than the text it holds. `UILabel`
6420
+ /// centres its text block vertically within its frame, while
6421
+ /// `UITextView` lays its glyphs out from the top, so a label handed the
6422
+ /// box's whole remaining height drew the placeholder of a four-row notes
6423
+ /// field halfway down it — nowhere near the caret that replaced it the
6424
+ /// moment the field was tapped (#501). The two rules only agreed when
6425
+ /// the box was exactly one line tall, which is why single-line fields
6426
+ /// looked right and every `textarea` did not. Given only the height its
6427
+ /// text needs, the label has no spare height to centre within.
6428
+ ///
6429
+ /// Clamped to what is left of the box so that a placeholder longer than
6430
+ /// the field stays inside it rather than drawing past the bottom.
6431
+ static func placeholderFrame(bounds: CGRect, inset: UIEdgeInsets, padding: CGFloat, fittedHeight: CGFloat) -> CGRect {
6432
+ let available = max(0, bounds.height - inset.top - inset.bottom)
6433
+ return CGRect(x: inset.left + padding,
6434
+ y: inset.top,
6435
+ width: placeholderWidth(bounds: bounds, inset: inset, padding: padding),
6436
+ height: min(max(0, fittedHeight), available))
6350
6437
  }
6351
6438
 
6352
6439
  func refreshPlaceholder() {
@@ -6595,11 +6682,108 @@ enum CraftNativeBundle {
6595
6682
  }
6596
6683
  }
6597
6684
 
6598
- private final class CraftNativePickerView: UIPickerView {
6685
+ /// One shared single-choice control, compact like a form field. UIKit owns
6686
+ /// presentation, dismissal, checkmarks, keyboard navigation and VoiceOver.
6687
+ final class CraftNativePickerView: UIButton {
6688
+ struct Option: Equatable {
6689
+ let value: String
6690
+ let label: String
6691
+ let disabled: Bool
6692
+ }
6693
+ private var options: [Option] = []
6694
+ private var selectedValue: String?
6695
+ private var handlerIdentity: String?
6696
+ private var dispatch: ((String, Int) -> Void)?
6697
+ private var revision = 0
6698
+ private(set) var selectedLabel = ""
6699
+
6700
+ override init(frame: CGRect) {
6701
+ super.init(frame: frame)
6702
+ showsMenuAsPrimaryAction = true
6703
+ contentHorizontalAlignment = .fill
6704
+ titleLabel?.adjustsFontForContentSizeCategory = true
6705
+ layer.cornerRadius = 10
6706
+ }
6707
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
6708
+
6709
+ func update(options: [Option], selected: String?, enabled: Bool, handler: String?,
6710
+ font: UIFont, foreground: UIColor, dispatch: @escaping (String, Int) -> Void) {
6711
+ let candidate = selected ?? self.selectedValue
6712
+ let resolved = options.contains(where: { $0.value == candidate }) ? candidate : options.first(where: { !$0.disabled })?.value
6713
+ if self.options != options || self.selectedValue != resolved || self.isEnabled != enabled || self.handlerIdentity != handler {
6714
+ revision += 1
6715
+ interactions.compactMap { $0 as? UIContextMenuInteraction }.forEach { $0.dismissMenu() }
6716
+ }
6717
+ self.options = options
6718
+ self.selectedValue = resolved
6719
+ self.handlerIdentity = handler
6720
+ self.dispatch = dispatch
6721
+ isEnabled = enabled && options.contains(where: { !$0.disabled })
6722
+ selectedLabel = options.first(where: { $0.value == resolved })?.label ?? ""
6723
+ var configuration = UIButton.Configuration.plain()
6724
+ configuration.title = selectedLabel
6725
+ configuration.image = UIImage(systemName: "chevron.down")
6726
+ configuration.imagePlacement = .trailing
6727
+ configuration.imagePadding = 12
6728
+ configuration.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)
6729
+ configuration.titleAlignment = .leading
6730
+ configuration.baseForegroundColor = foreground
6731
+ configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
6732
+ var result = attributes
6733
+ result.font = font
6734
+ return result
6735
+ }
6736
+ configuration.imageColorTransformer = UIConfigurationColorTransformer { _ in .secondaryLabel }
6737
+ self.configuration = configuration
6738
+ refreshMenu()
6739
+ invalidateIntrinsicContentSize()
6740
+ }
6741
+
6742
+ private func refreshMenu() {
6743
+ menu = UIMenu(children: options.enumerated().map { index, option in
6744
+ let choose = selection(index)
6745
+ return UIAction(title: option.label, attributes: option.disabled ? [.disabled] : [],
6746
+ state: option.value == selectedValue ? .on : .off) { _ in choose() }
6747
+ })
6748
+ }
6749
+
6750
+ /// An open menu must not apply an old option to a rebound or disabled field.
6751
+ func selection(_ index: Int) -> () -> Void {
6752
+ let expected = revision
6753
+ return { [weak self] in
6754
+ guard let self, self.revision == expected, self.isEnabled,
6755
+ self.options.indices.contains(index), !self.options[index].disabled else { return }
6756
+ let value = self.options[index].value
6757
+ guard value != self.selectedValue else { return }
6758
+ self.selectedValue = value
6759
+ self.selectedLabel = self.options[index].label
6760
+ self.configuration?.title = self.selectedLabel
6761
+ self.accessibilityValue = self.selectedLabel
6762
+ self.revision += 1
6763
+ self.refreshMenu()
6764
+ self.invalidateIntrinsicContentSize()
6765
+ self.dispatch?(value, index)
6766
+ }
6767
+ }
6768
+
6769
+ func detach() {
6770
+ revision += 1
6771
+ dispatch = nil
6772
+ interactions.compactMap { $0 as? UIContextMenuInteraction }.forEach { $0.dismissMenu() }
6773
+ menu = nil
6774
+ }
6775
+
6776
+ override var intrinsicContentSize: CGSize {
6777
+ let size = super.intrinsicContentSize
6778
+ return CGSize(width: max(44, size.width), height: max(44, size.height))
6779
+ }
6780
+ override func sizeThatFits(_ size: CGSize) -> CGSize {
6781
+ let fitted = super.sizeThatFits(size)
6782
+ return CGSize(width: fitted.width, height: max(44, fitted.height))
6783
+ }
6599
6784
  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
6600
6785
  craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
6601
6786
  }
6602
-
6603
6787
  }
6604
6788
 
6605
6789
  private final class CraftNativeDatePickerView: UIDatePicker {
@@ -6650,7 +6834,7 @@ private final class CraftNativeScreenRootView: UIView {
6650
6834
  }
6651
6835
  }
6652
6836
 
6653
- final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
6837
+ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate {
6654
6838
  private final class RenderedNode {
6655
6839
  /// A recycled list row's root takes the identity of the row it shows next.
6656
6840
  var identity: String
@@ -6714,7 +6898,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6714
6898
  /// the user's tap. Remember that native value briefly so the echo does not
6715
6899
  /// interrupt the animation and leave the thumb painted at its old edge.
6716
6900
  private var switchUserValues: [ObjectIdentifier: Bool] = [:]
6717
- private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
6718
6901
  private var modalVisibility: [ObjectIdentifier: Bool] = [:]
6719
6902
  private var modalFocusRestore: [ObjectIdentifier: UIView] = [:]
6720
6903
  private var layoutHandlers: [ObjectIdentifier: String] = [:]
@@ -6941,6 +7124,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6941
7124
 
6942
7125
  override func viewWillDisappear(_ animated: Bool) {
6943
7126
  super.viewWillDisappear(animated)
7127
+ // A screen does not take the keyboard with it. Its field is still
7128
+ // first responder at this point, and once the view leaves the window
7129
+ // the next screen's `window?.endEditing` no longer reaches it — the
7130
+ // keyboard then stays up with nothing able to dismiss it until the
7131
+ // app is backgrounded (#497). This is the one moment both facts hold:
7132
+ // the screen is going, and its view still owns the responder.
7133
+ //
7134
+ // `lastFocusedInput` is deliberately left alone. A screen merely
7135
+ // covered by a push comes back, and restoreFocus putting the person
7136
+ // back where they were is the behaviour that was already there.
7137
+ view.endEditing(true)
6944
7138
  screenVisibilityChanged(false)
6945
7139
  stopCelebrations(in: view)
6946
7140
  // Release only this screen's accessory. A retained or prepared screen
@@ -6949,7 +7143,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6949
7143
  CraftChrome.shared.releaseTabAccessory(ownedBy: self)
6950
7144
  // Popped (or its stack dismissed) while the bar was away: it comes
6951
7145
  // back for whatever shows next, a web page included.
6952
- if tabBarHidden, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
7146
+ if tabBarHiddenApplied, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
6953
7147
  CraftChrome.shared.setHiddenByScreen(false)
6954
7148
  }
6955
7149
  }
@@ -6991,7 +7185,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6991
7185
 
6992
7186
  private func restoreShellChrome() {
6993
7187
  guard !isAppScript else { return }
6994
- CraftChrome.shared.setHiddenByScreen(tabBarHidden)
7188
+ applyTabBarHidden()
6995
7189
  // A screen not yet asked about the accessory (one shown for the first
6996
7190
  // time, before its script has run) leaves the bar as it is; one that
6997
7191
  // has drawn without asking for it takes off what nobody claims.
@@ -7087,6 +7281,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7087
7281
  private func disposeRuntime() {
7088
7282
  guard !runtimeDisposed else { return }
7089
7283
  runtimeDisposed = true
7284
+ // Again here, and forgetting the input this time: a screen can be torn
7285
+ // down without ever appearing (one prepared and then replaced), so
7286
+ // viewWillDisappear is not guaranteed to have run. Nothing comes back
7287
+ // from here, so there is no focus worth restoring.
7288
+ //
7289
+ // `viewIfLoaded`, never `view`: this also runs from `deinit`, where
7290
+ // `view` is a `UIView!` that UIKit has already cleared — reading it
7291
+ // traps on the implicit unwrap, and a screen that never appeared
7292
+ // would instead have `loadView` resurrect `self` while it is being
7293
+ // deallocated. A screen with no loaded view has nothing focused.
7294
+ forgetFocusedInput()
7295
+ viewIfLoaded?.endEditing(true)
7090
7296
  frameDelivery.close()
7091
7297
  storageLifetime.close()
7092
7298
  pendingCapabilityDeadlines.values.forEach { $0.cancel() }
@@ -7562,7 +7768,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7562
7768
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
7563
7769
  /// `craft.secureStorage.set` wrote.
7564
7770
  /// - `craft.navigation.setOptions({ title, titleColor, largeTitle, hidden,
7565
- /// backTitle, tabBarHidden, tabItem, swipeBack, contentSwipeBack,
7771
+ /// backTitle, tabBarHidden, tabBarHiddenWhenPushed, tabItem, swipeBack,
7772
+ /// contentSwipeBack,
7566
7773
  /// tabSwipeEnabled, backgroundColor, navigationBarSolid, leftButtons,
7567
7774
  /// rightButtons, tabAccessory })`; a button's tap is the event
7568
7775
  /// `navButton`. `craft.d.ts` types no navigation surface, so this list
@@ -8029,6 +8236,32 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8029
8236
  private var statusBarHiddenOverride: Bool?
8030
8237
  /// The app's tab bar away while this screen shows (`tabBarHidden`).
8031
8238
  private var tabBarHidden = false
8239
+ /// The bar away only once this screen is pushed onto something
8240
+ /// (`tabBarHiddenWhenPushed`): UIKit's `hidesBottomBarWhenPushed` idiom,
8241
+ /// for a screen that keeps the bar as its stack's root and drops it when
8242
+ /// it is a detail. Craft's tab bar is drawn by CraftChrome rather than a
8243
+ /// UITabBarController, so the depth is read here and the bar is asked
8244
+ /// directly; the Android shell has honoured the same key all along.
8245
+ private var tabBarHiddenWhenPushed = false
8246
+ /// What this screen last told the chrome. Teardown has to put back a bar
8247
+ /// this screen took away, and recomputing the answer there is no good:
8248
+ /// UIKit has already taken a popped screen out of `viewControllers` by
8249
+ /// `viewWillDisappear`, so a bar hidden for depth would read as never
8250
+ /// hidden at all and stay off for whatever shows next.
8251
+ private var tabBarHiddenApplied = false
8252
+
8253
+ /// Whether the bar should be away for this screen as it stands now.
8254
+ private var wantsTabBarHidden: Bool {
8255
+ if tabBarHidden { return true }
8256
+ guard tabBarHiddenWhenPushed, let stack = navigationController?.viewControllers else { return false }
8257
+ guard let depth = stack.firstIndex(where: { $0 === self }) else { return false }
8258
+ return depth > 0
8259
+ }
8260
+
8261
+ private func applyTabBarHidden() {
8262
+ tabBarHiddenApplied = wantsTabBarHidden
8263
+ CraftChrome.shared.setHiddenByScreen(tabBarHiddenApplied)
8264
+ }
8032
8265
  private var tabAccessory: [String: Any]?
8033
8266
  /// Whether the screen has said anything about the accessory yet
8034
8267
  /// (`tabAccessory`, null included): until it has, the bar keeps showing
@@ -8114,8 +8347,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8114
8347
  }
8115
8348
  if let hidden = options["tabBarHidden"] as? Bool {
8116
8349
  tabBarHidden = hidden
8350
+ }
8351
+ if let hiddenWhenPushed = options["tabBarHiddenWhenPushed"] as? Bool {
8352
+ tabBarHiddenWhenPushed = hiddenWhenPushed
8353
+ }
8354
+ if options.keys.contains("tabBarHidden") || options.keys.contains("tabBarHiddenWhenPushed") {
8355
+ // Prepared tabs and screens under a push keep the answer and apply
8356
+ // it when they are shown (restoreShellChrome); only the screen on
8357
+ // display may move the shared bar.
8117
8358
  if navigationController?.topViewController === self, isShownOnScreen {
8118
- CraftChrome.shared.setHiddenByScreen(hidden)
8359
+ applyTabBarHidden()
8119
8360
  }
8120
8361
  }
8121
8362
  if let swipeBack = options["swipeBack"] as? Bool {
@@ -8228,6 +8469,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8228
8469
 
8229
8470
  /// A bar button from `{ id, symbol | title }`. Its tap is the event
8230
8471
  /// `navButton` with `{ id }`.
8472
+ /// A bar button's choices, or nil when it has none worth a menu.
8473
+ static func navigationMenu(_ raw: Any?, chosen: @escaping (String) -> Void) -> UIMenu? {
8474
+ let actions: [UIAction] = ((raw as? [[String: Any]]) ?? []).prefix(12).compactMap { choice in
8475
+ guard let id = choice["id"] as? String, !id.isEmpty,
8476
+ let title = choice["title"] as? String, !title.isEmpty else { return nil }
8477
+ var attributes: UIMenuElement.Attributes = []
8478
+ if choice["disabled"] as? Bool == true { attributes.insert(.disabled) }
8479
+ if choice["destructive"] as? Bool == true { attributes.insert(.destructive) }
8480
+ let action = UIAction(title: title, image: (choice["symbol"] as? String).flatMap { UIImage(systemName: $0) },
8481
+ attributes: attributes) { _ in chosen(id) }
8482
+ action.accessibilityIdentifier = "nav-menu-\(id)"
8483
+ return action
8484
+ }
8485
+ return actions.isEmpty ? nil : UIMenu(children: actions)
8486
+ }
8487
+
8231
8488
  private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
8232
8489
  guard let id = spec["id"] as? String, !id.isEmpty else { return nil }
8233
8490
  let action = UIAction { [weak self] _ in
@@ -8236,11 +8493,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8236
8493
  if spec["variant"] as? String == "accent" || spec["variant"] as? String == "account" {
8237
8494
  return accountNavigationButton(spec, action: action)
8238
8495
  }
8496
+ // `menu`: the button opens a native menu instead of sending its own
8497
+ // id, and each choice is reported as a press of that choice's id.
8498
+ let menu = Self.navigationMenu(spec["menu"]) { [weak self] choice in
8499
+ self?.send(type: "EVENT", payload: ["handlerName": "navButton", "nativeEvent": ["id": choice]])
8500
+ }
8239
8501
  let item: UIBarButtonItem
8240
8502
  if let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
8241
- item = UIBarButtonItem(image: image, primaryAction: action)
8503
+ item = menu.map { UIBarButtonItem(image: image, menu: $0) } ?? UIBarButtonItem(image: image, primaryAction: action)
8242
8504
  } else if let title = spec["title"] as? String {
8243
- item = UIBarButtonItem(title: title, primaryAction: action)
8505
+ item = menu.map { UIBarButtonItem(title: title, menu: $0) } ?? UIBarButtonItem(title: title, primaryAction: action)
8244
8506
  } else {
8245
8507
  return nil
8246
8508
  }
@@ -8831,14 +9093,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8831
9093
  ])
8832
9094
  }
8833
9095
  case "NAVIGATE", "NAVIGATE_REPLACE":
8834
- // A screen's own stack; the app script has none (it opens paths).
8835
- guard !isAppScript, navigationController?.topViewController === self,
8836
- let screen = payload["screen"] as? String, !screen.isEmpty,
8837
- let navigation = navigationController else { return }
9096
+ guard let screen = payload["screen"] as? String, !screen.isEmpty else { return }
9097
+ // Replacing a path needs no stack of its own: it asks the shell to
9098
+ // show that path, which is why it delegates rather than pushing.
9099
+ // The app script has no stack, so this was refused to it along
9100
+ // with everything else, and its only way to redirect was to push
9101
+ // sign-in on top of the screen it meant to prevent (#499). The
9102
+ // gate in CraftHybrid.configure is the fix for launch; this is
9103
+ // what lets a session that ends mid-run replace rather than stack.
8838
9104
  if type == "NAVIGATE_REPLACE", screen.hasPrefix("/") {
9105
+ guard isAppScript || navigationController?.topViewController === self else { return }
8839
9106
  hybridEvents?.nativeScreen(self, replace: screen)
8840
9107
  return
8841
9108
  }
9109
+ // A screen's own stack; the app script has none (it opens paths).
9110
+ guard !isAppScript, navigationController?.topViewController === self,
9111
+ let navigation = navigationController else { return }
8842
9112
  let params = payload["params"] as? [String: Any] ?? [:]
8843
9113
  let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
8844
9114
  next.hybridEvents = hybridEvents
@@ -9280,7 +9550,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9280
9550
  previousNodesByProtocolID.removeAll(keepingCapacity: true)
9281
9551
  adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
9282
9552
  indexPreviousNodes(previous)
9283
- let next = reconcile(document, identity: "root", path: "root", previous: previous)
9553
+ // The app's configured body face seeds the inheritance chain, so a
9554
+ // screen's text is branded for the same reason its chrome is. Without
9555
+ // this the chain starts empty and `fontFamily` reached the navigation
9556
+ // bar, the tab labels and the bar buttons — all UIAppearance proxies —
9557
+ // while every label, button and field inside a screen stayed in the
9558
+ // system face (#500).
9559
+ //
9560
+ // A node that sets its own family still wins: the fill below only
9561
+ // applies where a node set none or `inherit`. Nil unless the app
9562
+ // configured one, which the same guard makes free.
9563
+ let next = reconcile(document, identity: "root", path: "root", previous: previous,
9564
+ inheritedFontFamily: CraftChromeFonts.body)
9284
9565
  previousNodesByProtocolID.removeAll(keepingCapacity: true)
9285
9566
  adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
9286
9567
  if previous?.view !== next.view {
@@ -9594,31 +9875,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9594
9875
  control.configureAccessibility(items: items)
9595
9876
  updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: control)
9596
9877
  case "Picker":
9597
- let picker = result as! UIPickerView
9598
- let itemOptions = (props["items"] as? [[String: Any]])?.compactMap { item -> (value: String, label: String)? in
9599
- guard let label = item["label"] as? String, !label.isEmpty else { return nil }
9600
- return (value: craftNativePickerValue(item["value"]) ?? label, label: label)
9601
- } ?? []
9602
- let options = itemOptions.isEmpty
9603
- ? children.compactMap { child -> (value: String, label: String)? in
9604
- guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
9605
- let optionProps = node["props"] as? [String: Any] ?? [:]
9606
- let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
9607
- guard !label.isEmpty else { return nil }
9608
- return (value: craftNativePickerValue(optionProps["value"]) ?? label, label: label)
9878
+ let picker = result as! CraftNativePickerView
9879
+ let rawItems = props["items"] as? [[String: Any]]
9880
+ let options: [CraftNativePickerView.Option] = rawItems.map { items in
9881
+ items.compactMap { item in
9882
+ guard let label = item["label"] as? String, !label.isEmpty else { return nil }
9883
+ return CraftNativePickerView.Option(value: craftNativePickerValue(item["value"]) ?? label,
9884
+ label: label, disabled: item["disabled"] as? Bool == true)
9609
9885
  }
9610
- : itemOptions
9611
- let pickerID = ObjectIdentifier(picker)
9612
- pickerOptions[pickerID] = options
9613
- picker.reloadAllComponents()
9614
- if let selected = craftNativePickerValue(props["selectedValue"]) ?? craftNativePickerValue(props["value"]),
9615
- let row = options.firstIndex(where: { $0.value == selected }) {
9616
- picker.selectRow(row, inComponent: 0, animated: false)
9617
- } else if !options.isEmpty {
9618
- picker.selectRow(0, inComponent: 0, animated: false)
9619
- }
9620
- picker.isUserInteractionEnabled = props["disabled"] as? Bool != true
9621
- updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
9886
+ } ?? children.compactMap { child in
9887
+ guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
9888
+ let optionProps = node["props"] as? [String: Any] ?? [:]
9889
+ let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
9890
+ guard !label.isEmpty else { return nil }
9891
+ return CraftNativePickerView.Option(value: craftNativePickerValue(optionProps["value"]) ?? label,
9892
+ label: label, disabled: optionProps["disabled"] as? Bool == true)
9893
+ }
9894
+ let handler = nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"])
9895
+ updateHandler(handler, for: picker)
9896
+ picker.update(options: options,
9897
+ selected: craftNativePickerValue(props["selectedValue"]) ?? craftNativePickerValue(props["value"]),
9898
+ enabled: props["disabled"] as? Bool != true,
9899
+ handler: handler, font: textFont(style, default: .preferredFont(forTextStyle: .body)),
9900
+ foreground: color(style["color"]) ?? .label) { [weak self, weak picker] value, index in
9901
+ guard let self, let picker, let handler = self.handlers[ObjectIdentifier(picker)] else { return }
9902
+ self.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": value, "index": index]])
9903
+ }
9904
+ if style["backgroundColor"] == nil { picker.backgroundColor = .tertiarySystemFill }
9622
9905
  case "DatePicker":
9623
9906
  let picker = result as! UIDatePicker
9624
9907
  let mode = Self.datePickerMode(props["mode"])
@@ -9994,6 +10277,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9994
10277
  result.craftPointerEvents = CraftNativePointerEvents.resolve(props: props, style: style)
9995
10278
  applyAccessibility(props, type: type, to: result)
9996
10279
  (result as? CraftNativeSvgView)?.inspection.configureAccessibility()
10280
+ if let picker = result as? CraftNativePickerView, props["accessibilityValue"] == nil {
10281
+ picker.accessibilityValue = picker.selectedLabel
10282
+ }
9997
10283
  // After applyAccessibility: it settles whether the view is an element
9998
10284
  // at all, and an action on a non-element is unreachable.
9999
10285
  updateAccessibilityActions(props["accessibilityActions"], handler: menuDisabled ? nil : events["onAccessibilityAction"], for: result)
@@ -10053,10 +10339,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10053
10339
  control.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
10054
10340
  return control
10055
10341
  case "Picker":
10056
- let picker = CraftNativePickerView()
10057
- picker.dataSource = self
10058
- picker.delegate = self
10059
- return picker
10342
+ return CraftNativePickerView(frame: .zero)
10060
10343
  case "DatePicker":
10061
10344
  let picker = CraftNativeDatePickerView()
10062
10345
  picker.preferredDatePickerStyle = .compact
@@ -10444,10 +10727,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10444
10727
  // its subtree is gone.
10445
10728
  if !preservingInputDrafts {
10446
10729
  if let textView = node.view as? UITextView { textView.delegate = nil }
10447
- if let picker = node.view as? UIPickerView {
10448
- picker.delegate = nil
10449
- picker.dataSource = nil
10450
- }
10730
+ (node.view as? CraftNativePickerView)?.detach()
10451
10731
  if let scroll = node.view as? UIScrollView { scroll.delegate = nil }
10452
10732
  (node.view as? UIControl)?.removeTarget(self, action: nil, for: .allEvents)
10453
10733
  }
@@ -10502,7 +10782,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10502
10782
  scrollMomentumEndHandlers.removeValue(forKey: id)
10503
10783
  sliderCompleteHandlers.removeValue(forKey: id)
10504
10784
  sliderSteps.removeValue(forKey: id)
10505
- pickerOptions.removeValue(forKey: id)
10506
10785
  modalVisibility.removeValue(forKey: id)
10507
10786
  if let recognizer = tapRecognizers.removeValue(forKey: id) {
10508
10787
  node.view.removeGestureRecognizer(recognizer)
@@ -10636,12 +10915,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10636
10915
  /// Views that take their own touches whatever handlers the screen gives
10637
10916
  /// them: native controls, a scroll view, a modal, and a video's controls (with
10638
10917
  /// interaction off, its play, scrubber and full screen never answered).
10639
- /// A map and a wheel picker too: neither is a UIControl, so a map with no
10640
- /// long-press handler - every map - was made non-interactive, and could
10641
- /// not be panned, zoomed, or have a pin tapped; a picker could not be spun.
10918
+ /// Maps too: a map without a long-press handler must still pan, zoom,
10919
+ /// and take taps on its pins.
10642
10920
  private func ownsTouches(_ view: UIView) -> Bool {
10643
10921
  view is UIControl || view is UIScrollView || view is CraftNativeModalView || view is CraftNativeVideoView
10644
- || view is MKMapView || view is UIPickerView || ((view as? CraftNativeSvgView)?.inspection.readings.isEmpty == false)
10922
+ || view is MKMapView || ((view as? CraftNativeSvgView)?.inspection.readings.isEmpty == false)
10645
10923
  }
10646
10924
 
10647
10925
  private func updatePressHandler(_ handler: String?, for view: UIView) {
@@ -11630,9 +11908,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11630
11908
  // A video with its system controls is a group too: VoiceOver reaches
11631
11909
  // play, the scrubber, skipping and full screen inside it.
11632
11910
  let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
11633
- // UIPickerView exposes adjustable component wheels. Making the
11634
- // picker itself a leaf hides those wheels and their selected values.
11635
- let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl", "Modal", "Drawer"].contains(type)
11911
+ // Compact single-choice pickers are accessible menu buttons. Controls
11912
+ // containing several independent elements remain semantic groups.
11913
+ let container = ["ScrollView", "FlatList", "SectionList", "SegmentedControl", "Modal", "Drawer"].contains(type)
11636
11914
  || ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
11637
11915
  || controlledVideo
11638
11916
  view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
@@ -11644,6 +11922,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11644
11922
  var traits: UIAccessibilityTraits = []
11645
11923
  switch type {
11646
11924
  case "Slider", "DatePicker": traits.insert(.adjustable)
11925
+ case "Picker": traits.insert(.button)
11647
11926
  default: break
11648
11927
  }
11649
11928
  switch role ?? type.lowercased() {
@@ -12056,27 +12335,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
12056
12335
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender), step: sliderSteps[ObjectIdentifier(sender)])]])
12057
12336
  }
12058
12337
 
12059
- func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
12060
-
12061
- func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
12062
- pickerOptions[ObjectIdentifier(pickerView)]?.count ?? 0
12063
- }
12064
-
12065
- func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
12066
- guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row) else { return nil }
12067
- return options[row].label
12068
- }
12069
-
12070
- func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
12071
- guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row),
12072
- let handler = handlers[ObjectIdentifier(pickerView)] else { return }
12073
- let option = options[row]
12074
- send(type: "EVENT", payload: [
12075
- "handlerName": handler,
12076
- "nativeEvent": ["value": option.value, "index": row],
12077
- ])
12078
- }
12079
-
12080
12338
  @objc private func textFocused(_ sender: UITextField) {
12081
12339
  lastFocusedInput = sender
12082
12340
  // Moving between fields does not move the keyboard, so nothing else