craft-native 0.0.486 → 0.0.487

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.
@@ -150,8 +150,8 @@ private fun craftNativeBoolean(value: Any?, fallback: Boolean = false): Boolean
150
150
  private fun craftNativeInt(value: Any?, fallback: Int): Int = (value as? Number)?.toInt() ?: fallback
151
151
 
152
152
  private fun craftNativePickerValue(value: Any?): String? = when (value) {
153
- is String -> value.takeIf { it.isNotEmpty() }
154
- is Number -> value.toDouble().takeIf { it.isFinite() }?.toString()
153
+ is String -> value
154
+ is Number -> value.toDouble().takeIf { it.isFinite() }?.let { value.toString() }
155
155
  else -> null
156
156
  }
157
157
 
package/dist/cli.js CHANGED
@@ -3243,7 +3243,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3243
3243
  `);
3244
3244
  }
3245
3245
  // package.json
3246
- var version = "0.0.486";
3246
+ var version = "0.0.487";
3247
3247
 
3248
3248
  // bin/cli.ts
3249
3249
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -376,7 +376,7 @@ function craftBinaryNotFoundMessage(triedPath) {
376
376
  `);
377
377
  }
378
378
  // package.json
379
- var version = "0.0.486";
379
+ var version = "0.0.487";
380
380
 
381
381
  // src/sdk-version.ts
382
382
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.486";
120
+ var version = "0.0.487";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -168,8 +168,8 @@ private func craftNativeFontWeight(_ value: Any?) -> String? {
168
168
  }
169
169
 
170
170
  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) }
171
+ if let value = value as? String { return value }
172
+ if let value = craftNativeNumber(value), value.doubleValue.isFinite { return value.stringValue }
173
173
  return nil
174
174
  }
175
175
 
@@ -6595,11 +6595,108 @@ enum CraftNativeBundle {
6595
6595
  }
6596
6596
  }
6597
6597
 
6598
- private final class CraftNativePickerView: UIPickerView {
6598
+ /// One shared single-choice control, compact like a form field. UIKit owns
6599
+ /// presentation, dismissal, checkmarks, keyboard navigation and VoiceOver.
6600
+ final class CraftNativePickerView: UIButton {
6601
+ struct Option: Equatable {
6602
+ let value: String
6603
+ let label: String
6604
+ let disabled: Bool
6605
+ }
6606
+ private var options: [Option] = []
6607
+ private var selectedValue: String?
6608
+ private var handlerIdentity: String?
6609
+ private var dispatch: ((String, Int) -> Void)?
6610
+ private var revision = 0
6611
+ private(set) var selectedLabel = ""
6612
+
6613
+ override init(frame: CGRect) {
6614
+ super.init(frame: frame)
6615
+ showsMenuAsPrimaryAction = true
6616
+ contentHorizontalAlignment = .fill
6617
+ titleLabel?.adjustsFontForContentSizeCategory = true
6618
+ layer.cornerRadius = 10
6619
+ }
6620
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
6621
+
6622
+ func update(options: [Option], selected: String?, enabled: Bool, handler: String?,
6623
+ font: UIFont, foreground: UIColor, dispatch: @escaping (String, Int) -> Void) {
6624
+ let candidate = selected ?? self.selectedValue
6625
+ let resolved = options.contains(where: { $0.value == candidate }) ? candidate : options.first(where: { !$0.disabled })?.value
6626
+ if self.options != options || self.selectedValue != resolved || self.isEnabled != enabled || self.handlerIdentity != handler {
6627
+ revision += 1
6628
+ interactions.compactMap { $0 as? UIContextMenuInteraction }.forEach { $0.dismissMenu() }
6629
+ }
6630
+ self.options = options
6631
+ self.selectedValue = resolved
6632
+ self.handlerIdentity = handler
6633
+ self.dispatch = dispatch
6634
+ isEnabled = enabled && options.contains(where: { !$0.disabled })
6635
+ selectedLabel = options.first(where: { $0.value == resolved })?.label ?? ""
6636
+ var configuration = UIButton.Configuration.plain()
6637
+ configuration.title = selectedLabel
6638
+ configuration.image = UIImage(systemName: "chevron.down")
6639
+ configuration.imagePlacement = .trailing
6640
+ configuration.imagePadding = 12
6641
+ configuration.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)
6642
+ configuration.titleAlignment = .leading
6643
+ configuration.baseForegroundColor = foreground
6644
+ configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
6645
+ var result = attributes
6646
+ result.font = font
6647
+ return result
6648
+ }
6649
+ configuration.imageColorTransformer = UIConfigurationColorTransformer { _ in .secondaryLabel }
6650
+ self.configuration = configuration
6651
+ refreshMenu()
6652
+ invalidateIntrinsicContentSize()
6653
+ }
6654
+
6655
+ private func refreshMenu() {
6656
+ menu = UIMenu(children: options.enumerated().map { index, option in
6657
+ let choose = selection(index)
6658
+ return UIAction(title: option.label, attributes: option.disabled ? [.disabled] : [],
6659
+ state: option.value == selectedValue ? .on : .off) { _ in choose() }
6660
+ })
6661
+ }
6662
+
6663
+ /// An open menu must not apply an old option to a rebound or disabled field.
6664
+ func selection(_ index: Int) -> () -> Void {
6665
+ let expected = revision
6666
+ return { [weak self] in
6667
+ guard let self, self.revision == expected, self.isEnabled,
6668
+ self.options.indices.contains(index), !self.options[index].disabled else { return }
6669
+ let value = self.options[index].value
6670
+ guard value != self.selectedValue else { return }
6671
+ self.selectedValue = value
6672
+ self.selectedLabel = self.options[index].label
6673
+ self.configuration?.title = self.selectedLabel
6674
+ self.accessibilityValue = self.selectedLabel
6675
+ self.revision += 1
6676
+ self.refreshMenu()
6677
+ self.invalidateIntrinsicContentSize()
6678
+ self.dispatch?(value, index)
6679
+ }
6680
+ }
6681
+
6682
+ func detach() {
6683
+ revision += 1
6684
+ dispatch = nil
6685
+ interactions.compactMap { $0 as? UIContextMenuInteraction }.forEach { $0.dismissMenu() }
6686
+ menu = nil
6687
+ }
6688
+
6689
+ override var intrinsicContentSize: CGSize {
6690
+ let size = super.intrinsicContentSize
6691
+ return CGSize(width: max(44, size.width), height: max(44, size.height))
6692
+ }
6693
+ override func sizeThatFits(_ size: CGSize) -> CGSize {
6694
+ let fitted = super.sizeThatFits(size)
6695
+ return CGSize(width: fitted.width, height: max(44, fitted.height))
6696
+ }
6599
6697
  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
6600
6698
  craftPointerHitTest(point, with: event) { super.hitTest(point, with: event) }
6601
6699
  }
6602
-
6603
6700
  }
6604
6701
 
6605
6702
  private final class CraftNativeDatePickerView: UIDatePicker {
@@ -6650,7 +6747,7 @@ private final class CraftNativeScreenRootView: UIView {
6650
6747
  }
6651
6748
  }
6652
6749
 
6653
- final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
6750
+ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate {
6654
6751
  private final class RenderedNode {
6655
6752
  /// A recycled list row's root takes the identity of the row it shows next.
6656
6753
  var identity: String
@@ -6714,7 +6811,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6714
6811
  /// the user's tap. Remember that native value briefly so the echo does not
6715
6812
  /// interrupt the animation and leave the thumb painted at its old edge.
6716
6813
  private var switchUserValues: [ObjectIdentifier: Bool] = [:]
6717
- private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
6718
6814
  private var modalVisibility: [ObjectIdentifier: Bool] = [:]
6719
6815
  private var modalFocusRestore: [ObjectIdentifier: UIView] = [:]
6720
6816
  private var layoutHandlers: [ObjectIdentifier: String] = [:]
@@ -9594,31 +9690,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9594
9690
  control.configureAccessibility(items: items)
9595
9691
  updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: control)
9596
9692
  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)
9693
+ let picker = result as! CraftNativePickerView
9694
+ let rawItems = props["items"] as? [[String: Any]]
9695
+ let options: [CraftNativePickerView.Option] = rawItems.map { items in
9696
+ items.compactMap { item in
9697
+ guard let label = item["label"] as? String, !label.isEmpty else { return nil }
9698
+ return CraftNativePickerView.Option(value: craftNativePickerValue(item["value"]) ?? label,
9699
+ label: label, disabled: item["disabled"] as? Bool == true)
9609
9700
  }
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)
9701
+ } ?? children.compactMap { child in
9702
+ guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
9703
+ let optionProps = node["props"] as? [String: Any] ?? [:]
9704
+ let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
9705
+ guard !label.isEmpty else { return nil }
9706
+ return CraftNativePickerView.Option(value: craftNativePickerValue(optionProps["value"]) ?? label,
9707
+ label: label, disabled: optionProps["disabled"] as? Bool == true)
9708
+ }
9709
+ let handler = nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"])
9710
+ updateHandler(handler, for: picker)
9711
+ picker.update(options: options,
9712
+ selected: craftNativePickerValue(props["selectedValue"]) ?? craftNativePickerValue(props["value"]),
9713
+ enabled: props["disabled"] as? Bool != true,
9714
+ handler: handler, font: textFont(style, default: .preferredFont(forTextStyle: .body)),
9715
+ foreground: color(style["color"]) ?? .label) { [weak self, weak picker] value, index in
9716
+ guard let self, let picker, let handler = self.handlers[ObjectIdentifier(picker)] else { return }
9717
+ self.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": value, "index": index]])
9718
+ }
9719
+ if style["backgroundColor"] == nil { picker.backgroundColor = .tertiarySystemFill }
9622
9720
  case "DatePicker":
9623
9721
  let picker = result as! UIDatePicker
9624
9722
  let mode = Self.datePickerMode(props["mode"])
@@ -9994,6 +10092,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9994
10092
  result.craftPointerEvents = CraftNativePointerEvents.resolve(props: props, style: style)
9995
10093
  applyAccessibility(props, type: type, to: result)
9996
10094
  (result as? CraftNativeSvgView)?.inspection.configureAccessibility()
10095
+ if let picker = result as? CraftNativePickerView, props["accessibilityValue"] == nil {
10096
+ picker.accessibilityValue = picker.selectedLabel
10097
+ }
9997
10098
  // After applyAccessibility: it settles whether the view is an element
9998
10099
  // at all, and an action on a non-element is unreachable.
9999
10100
  updateAccessibilityActions(props["accessibilityActions"], handler: menuDisabled ? nil : events["onAccessibilityAction"], for: result)
@@ -10053,10 +10154,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10053
10154
  control.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
10054
10155
  return control
10055
10156
  case "Picker":
10056
- let picker = CraftNativePickerView()
10057
- picker.dataSource = self
10058
- picker.delegate = self
10059
- return picker
10157
+ return CraftNativePickerView(frame: .zero)
10060
10158
  case "DatePicker":
10061
10159
  let picker = CraftNativeDatePickerView()
10062
10160
  picker.preferredDatePickerStyle = .compact
@@ -10444,10 +10542,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10444
10542
  // its subtree is gone.
10445
10543
  if !preservingInputDrafts {
10446
10544
  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
- }
10545
+ (node.view as? CraftNativePickerView)?.detach()
10451
10546
  if let scroll = node.view as? UIScrollView { scroll.delegate = nil }
10452
10547
  (node.view as? UIControl)?.removeTarget(self, action: nil, for: .allEvents)
10453
10548
  }
@@ -10502,7 +10597,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10502
10597
  scrollMomentumEndHandlers.removeValue(forKey: id)
10503
10598
  sliderCompleteHandlers.removeValue(forKey: id)
10504
10599
  sliderSteps.removeValue(forKey: id)
10505
- pickerOptions.removeValue(forKey: id)
10506
10600
  modalVisibility.removeValue(forKey: id)
10507
10601
  if let recognizer = tapRecognizers.removeValue(forKey: id) {
10508
10602
  node.view.removeGestureRecognizer(recognizer)
@@ -10636,12 +10730,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10636
10730
  /// Views that take their own touches whatever handlers the screen gives
10637
10731
  /// them: native controls, a scroll view, a modal, and a video's controls (with
10638
10732
  /// 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.
10733
+ /// Maps too: a map without a long-press handler must still pan, zoom,
10734
+ /// and take taps on its pins.
10642
10735
  private func ownsTouches(_ view: UIView) -> Bool {
10643
10736
  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)
10737
+ || view is MKMapView || ((view as? CraftNativeSvgView)?.inspection.readings.isEmpty == false)
10645
10738
  }
10646
10739
 
10647
10740
  private func updatePressHandler(_ handler: String?, for view: UIView) {
@@ -11630,9 +11723,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11630
11723
  // A video with its system controls is a group too: VoiceOver reaches
11631
11724
  // play, the scrubber, skipping and full screen inside it.
11632
11725
  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)
11726
+ // Compact single-choice pickers are accessible menu buttons. Controls
11727
+ // containing several independent elements remain semantic groups.
11728
+ let container = ["ScrollView", "FlatList", "SectionList", "SegmentedControl", "Modal", "Drawer"].contains(type)
11636
11729
  || ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
11637
11730
  || controlledVideo
11638
11731
  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 +11737,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11644
11737
  var traits: UIAccessibilityTraits = []
11645
11738
  switch type {
11646
11739
  case "Slider", "DatePicker": traits.insert(.adjustable)
11740
+ case "Picker": traits.insert(.button)
11647
11741
  default: break
11648
11742
  }
11649
11743
  switch role ?? type.lowercased() {
@@ -12056,27 +12150,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
12056
12150
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender), step: sliderSteps[ObjectIdentifier(sender)])]])
12057
12151
  }
12058
12152
 
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
12153
  @objc private func textFocused(_ sender: UITextField) {
12081
12154
  lastFocusedInput = sender
12082
12155
  // Moving between fields does not move the keyboard, so nothing else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.486",
3
+ "version": "0.0.487",
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>",