craft-native 0.0.293 → 0.0.299
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.
- package/dist/android/src/index.d.ts +24 -0
- package/dist/android/src/index.js +31 -0
- package/dist/android/templates/CraftNativeShell.kt.template +32 -1
- package/dist/android/templates/MainActivityNative.kt.template +153 -12
- package/dist/api/app.d.ts +2 -2
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +21 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +24 -0
- package/dist/ios/src/index.js +31 -0
- package/dist/ios/templates/CraftApp.swift +153 -3
- package/dist/ios/templates/CraftHybrid.swift +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +1 -1
- package/dist/ios/templates/CraftNativeScreen.swift +294 -23
- package/dist/ios/templates/CraftWatchApp.swift.template +4 -0
- package/dist/types.d.ts +15 -0
- package/package.json +1 -1
|
@@ -2578,6 +2578,7 @@ extension UIView {
|
|
|
2578
2578
|
var responder: UIResponder? = next
|
|
2579
2579
|
while let current = responder {
|
|
2580
2580
|
if let screen = current as? CraftNativeScreenController { return screen }
|
|
2581
|
+
if let sheet = current as? CraftNativeSheetController { return sheet.screen }
|
|
2581
2582
|
responder = current.next
|
|
2582
2583
|
}
|
|
2583
2584
|
return nil
|
|
@@ -2983,7 +2984,10 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
2983
2984
|
// The screen reports onLayout from its own layout pass, which a
|
|
2984
2985
|
// change deep in a scroll view's content never runs: a view that
|
|
2985
2986
|
// went from 240 to 180 wide kept saying 240.
|
|
2986
|
-
if moved {
|
|
2987
|
+
if moved {
|
|
2988
|
+
craftScreenController?.scheduleLayoutEvents()
|
|
2989
|
+
craftScreenController?.scheduleKeyboardInsets()
|
|
2990
|
+
}
|
|
2987
2991
|
}
|
|
2988
2992
|
for (child, frame) in result.frames {
|
|
2989
2993
|
var viewportFrame = frame
|
|
@@ -3973,13 +3977,141 @@ private final class CraftNativeImageBackgroundView: UIImageView {
|
|
|
3973
3977
|
}
|
|
3974
3978
|
}
|
|
3975
3979
|
|
|
3976
|
-
private final class
|
|
3980
|
+
private final class CraftNativeSegmentElement: UIAccessibilityElement {
|
|
3981
|
+
weak var control: CraftNativeSegmentedControl?
|
|
3982
|
+
let index: Int
|
|
3983
|
+
init(control: CraftNativeSegmentedControl, index: Int) {
|
|
3984
|
+
self.control = control
|
|
3985
|
+
self.index = index
|
|
3986
|
+
super.init(accessibilityContainer: control)
|
|
3987
|
+
}
|
|
3988
|
+
override func accessibilityActivate() -> Bool {
|
|
3989
|
+
guard let control, control.isEnabled, control.values.indices.contains(index), control.isEnabledForSegment(at: index) else { return false }
|
|
3990
|
+
if control.selectedSegmentIndex != index {
|
|
3991
|
+
control.selectedSegmentIndex = index
|
|
3992
|
+
control.sendActions(for: .valueChanged)
|
|
3993
|
+
}
|
|
3994
|
+
return true
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
|
|
3998
|
+
private final class CraftNativeSegmentedControl: UISegmentedControl {
|
|
3999
|
+
var values: [String] = []
|
|
4000
|
+
private var segments: [CraftNativeSegmentElement] = []
|
|
4001
|
+
override var accessibilityElements: [Any]? {
|
|
4002
|
+
get { segments }
|
|
4003
|
+
set { /* The native segments, not the labelled group, own activation. */ }
|
|
4004
|
+
}
|
|
4005
|
+
func configureAccessibility(items: [[String: Any]]) {
|
|
4006
|
+
if segments.count != items.count { segments = items.indices.map { CraftNativeSegmentElement(control: self, index: $0) } }
|
|
4007
|
+
for (index, item) in items.enumerated() {
|
|
4008
|
+
segments[index].accessibilityLabel = item["spoken"] as? String ?? item["label"] as? String ?? values[index]
|
|
4009
|
+
}
|
|
4010
|
+
refreshAccessibilityState()
|
|
4011
|
+
setNeedsLayout()
|
|
4012
|
+
}
|
|
4013
|
+
func refreshAccessibilityState() {
|
|
4014
|
+
for (index, segment) in segments.enumerated() {
|
|
4015
|
+
segment.accessibilityTraits = [.button]
|
|
4016
|
+
if selectedSegmentIndex == index { segment.accessibilityTraits.insert(.selected) }
|
|
4017
|
+
if !isEnabled || !isEnabledForSegment(at: index) { segment.accessibilityTraits.insert(.notEnabled) }
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
override func layoutSubviews() {
|
|
4021
|
+
super.layoutSubviews()
|
|
4022
|
+
guard !segments.isEmpty else { return }
|
|
4023
|
+
let width = bounds.width / CGFloat(segments.count)
|
|
4024
|
+
for (index, segment) in segments.enumerated() {
|
|
4025
|
+
let visualIndex = effectiveUserInterfaceLayoutDirection == .rightToLeft ? segments.count - index - 1 : index
|
|
4026
|
+
segment.accessibilityFrameInContainerSpace = CGRect(x: CGFloat(visualIndex) * width, y: 0, width: width, height: bounds.height)
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
|
|
4031
|
+
private final class CraftNativeSheetController: UIViewController {
|
|
4032
|
+
weak var screen: CraftNativeScreenController?
|
|
4033
|
+
weak var content: CraftNativeFlowView?
|
|
4034
|
+
override func viewDidLayoutSubviews() {
|
|
4035
|
+
super.viewDidLayoutSubviews()
|
|
4036
|
+
content?.frame = view.bounds.inset(by: view.safeAreaInsets)
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate, UISheetPresentationControllerDelegate {
|
|
3977
4041
|
let contentStack = CraftNativeFlowView()
|
|
3978
4042
|
private let blocker = UIView()
|
|
3979
4043
|
private let backdropTap = UITapGestureRecognizer()
|
|
3980
4044
|
private var renderedVisible = false
|
|
3981
4045
|
private var animationGeneration = 0
|
|
3982
4046
|
var onRequestClose: ((String) -> Void)?
|
|
4047
|
+
weak var sheetPresenter: CraftNativeScreenController?
|
|
4048
|
+
var presentationStyle = "overFullScreen"
|
|
4049
|
+
var sheetDetents: [String] = ["medium", "large"]
|
|
4050
|
+
var selectedDetent: String?
|
|
4051
|
+
var grabberVisible = true
|
|
4052
|
+
private var sheetController: CraftNativeSheetController?
|
|
4053
|
+
var isPresentedAsSheet: Bool { sheetController != nil }
|
|
4054
|
+
|
|
4055
|
+
private func configureSheet(_ controller: CraftNativeSheetController) {
|
|
4056
|
+
controller.isModalInPresentation = onRequestClose == nil
|
|
4057
|
+
guard let sheet = controller.sheetPresentationController else { return }
|
|
4058
|
+
let choices = sheetDetents.filter { $0 == "medium" || $0 == "large" }
|
|
4059
|
+
let values = choices.isEmpty ? ["large"] : Array(Set(choices)).sorted { $0 == "medium" && $1 == "large" }
|
|
4060
|
+
sheet.detents = values.map { $0 == "medium" ? .medium() : .large() }
|
|
4061
|
+
sheet.prefersGrabberVisible = grabberVisible
|
|
4062
|
+
if let selectedDetent, values.contains(selectedDetent) {
|
|
4063
|
+
sheet.selectedDetentIdentifier = selectedDetent == "medium" ? .medium : .large
|
|
4064
|
+
}
|
|
4065
|
+
sheet.delegate = self
|
|
4066
|
+
}
|
|
4067
|
+
|
|
4068
|
+
private func restoreSheetContent(_ controller: CraftNativeSheetController) {
|
|
4069
|
+
guard sheetController === controller else { return }
|
|
4070
|
+
contentStack.removeFromSuperview()
|
|
4071
|
+
addSubview(contentStack)
|
|
4072
|
+
sheetController = nil
|
|
4073
|
+
setNeedsLayout()
|
|
4074
|
+
}
|
|
4075
|
+
|
|
4076
|
+
private func setSheetVisible(_ visible: Bool, animated: Bool) {
|
|
4077
|
+
renderedVisible = visible
|
|
4078
|
+
isHidden = true // UIKit owns the scrim, safe area, grabber and pan.
|
|
4079
|
+
guard visible else {
|
|
4080
|
+
guard let controller = sheetController else { return }
|
|
4081
|
+
controller.view.endEditing(true)
|
|
4082
|
+
controller.dismiss(animated: animated) { [weak self, weak controller] in
|
|
4083
|
+
guard let self, let controller else { return }
|
|
4084
|
+
self.restoreSheetContent(controller)
|
|
4085
|
+
if self.renderedVisible { self.setSheetVisible(true, animated: animated) }
|
|
4086
|
+
}
|
|
4087
|
+
return
|
|
4088
|
+
}
|
|
4089
|
+
if let controller = sheetController { configureSheet(controller); return }
|
|
4090
|
+
// A newly rendered modal is attached to its screen after this pass.
|
|
4091
|
+
DispatchQueue.main.async { [weak self] in
|
|
4092
|
+
guard let self, self.renderedVisible, self.sheetController == nil,
|
|
4093
|
+
let presenter = self.sheetPresenter, presenter.view.window != nil else { return }
|
|
4094
|
+
let controller = CraftNativeSheetController()
|
|
4095
|
+
controller.screen = presenter
|
|
4096
|
+
controller.modalPresentationStyle = self.presentationStyle == "formSheet" ? .formSheet : .pageSheet
|
|
4097
|
+
controller.view.backgroundColor = .systemBackground
|
|
4098
|
+
self.contentStack.removeFromSuperview()
|
|
4099
|
+
controller.view.addSubview(self.contentStack)
|
|
4100
|
+
controller.content = self.contentStack
|
|
4101
|
+
self.sheetController = controller
|
|
4102
|
+
self.configureSheet(controller)
|
|
4103
|
+
var top: UIViewController = presenter
|
|
4104
|
+
while let presented = top.presentedViewController { top = presented }
|
|
4105
|
+
top.present(controller, animated: animated)
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
|
4110
|
+
guard let controller = presentationController.presentedViewController as? CraftNativeSheetController else { return }
|
|
4111
|
+
restoreSheetContent(controller)
|
|
4112
|
+
renderedVisible = false
|
|
4113
|
+
onRequestClose?("swipe")
|
|
4114
|
+
}
|
|
3983
4115
|
|
|
3984
4116
|
var transparent = false {
|
|
3985
4117
|
didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
|
|
@@ -4017,6 +4149,10 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
4017
4149
|
/// press bookkeeping once turned that off for a view with no handler of
|
|
4018
4150
|
/// its own, and a shown modal let every tap through to the screen behind.
|
|
4019
4151
|
func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
|
|
4152
|
+
if blocksBackground && (presentationStyle == "pageSheet" || presentationStyle == "formSheet") {
|
|
4153
|
+
setSheetVisible(visible, animated: animationType != "none")
|
|
4154
|
+
return
|
|
4155
|
+
}
|
|
4020
4156
|
if visible { isUserInteractionEnabled = true }
|
|
4021
4157
|
if renderedVisible == visible && isHidden == !visible {
|
|
4022
4158
|
if visible, alpha != opacity { alpha = opacity }
|
|
@@ -4069,6 +4205,12 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
4069
4205
|
}
|
|
4070
4206
|
|
|
4071
4207
|
func resetForTeardown() {
|
|
4208
|
+
renderedVisible = false
|
|
4209
|
+
sheetPresenter = nil
|
|
4210
|
+
if let controller = sheetController {
|
|
4211
|
+
controller.dismiss(animated: false)
|
|
4212
|
+
restoreSheetContent(controller)
|
|
4213
|
+
}
|
|
4072
4214
|
craftScreenController?.forgetFocusedInput(in: self)
|
|
4073
4215
|
endEditing(true)
|
|
4074
4216
|
animationGeneration += 1
|
|
@@ -4130,6 +4272,7 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
4130
4272
|
override func layoutSubviews() {
|
|
4131
4273
|
super.layoutSubviews()
|
|
4132
4274
|
blocker.frame = bounds
|
|
4275
|
+
guard sheetController == nil else { return }
|
|
4133
4276
|
// The scrim covers the whole screen; what the modal holds stays
|
|
4134
4277
|
// clear of the bars and the home indicator it is drawn over.
|
|
4135
4278
|
contentStack.frame = bounds.inset(by: UIEdgeInsets(top: contentInsets.top, left: 0, bottom: contentInsets.bottom, right: 0))
|
|
@@ -5112,6 +5255,11 @@ private final class CraftNativeTextView: UITextView {
|
|
|
5112
5255
|
isScrollEnabled = overflows
|
|
5113
5256
|
if overflows, isFirstResponder { scrollRangeToVisible(selectedRange) }
|
|
5114
5257
|
}
|
|
5258
|
+
// Showing all its text, it has nothing to scroll. An offset left from
|
|
5259
|
+
// UIKit keeping the caret in view while the view was a layout short
|
|
5260
|
+
// of its text moved every line down inside it, and the caret of a
|
|
5261
|
+
// growing comment sat below the part of the screen it was kept in.
|
|
5262
|
+
if !isScrollEnabled, contentOffset != .zero { contentOffset = .zero }
|
|
5115
5263
|
let inset = textContainerInset
|
|
5116
5264
|
let padding = textContainer.lineFragmentPadding
|
|
5117
5265
|
placeholderLabel.frame = CGRect(
|
|
@@ -5492,6 +5640,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5492
5640
|
private var keyboardSafeAreaInset: CGFloat = 0
|
|
5493
5641
|
/// Where the keyboard is on the screen while it is up, in screen coordinates.
|
|
5494
5642
|
private var keyboardScreenFrame: CGRect?
|
|
5643
|
+
private var keyboardInsetsScheduled = false
|
|
5495
5644
|
/// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
|
|
5496
5645
|
/// app's native screens: told of the first frame, of a first frame that
|
|
5497
5646
|
/// could not be drawn, and of `craft.navigation.open(path)`.
|
|
@@ -5536,6 +5685,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5536
5685
|
|
|
5537
5686
|
override func viewDidLoad() {
|
|
5538
5687
|
super.viewDidLoad()
|
|
5688
|
+
CraftInterfaceStyleController.apply(to: self, config: config)
|
|
5539
5689
|
navigationItem.title = routeName ?? config.appName
|
|
5540
5690
|
view.backgroundColor = config.resolvedBackgroundColor
|
|
5541
5691
|
rootStack.axis = .vertical
|
|
@@ -5802,6 +5952,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5802
5952
|
positionModals()
|
|
5803
5953
|
emitLayoutEvents()
|
|
5804
5954
|
sendViewportIfChanged()
|
|
5955
|
+
scheduleKeyboardInsets()
|
|
5805
5956
|
}
|
|
5806
5957
|
|
|
5807
5958
|
/// The width the bundle last heard, so a layout pass at the same size says nothing.
|
|
@@ -5884,7 +6035,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5884
6035
|
@discardableResult
|
|
5885
6036
|
func handleBackForModal() -> Bool {
|
|
5886
6037
|
let modal = view.subviews.reversed().compactMap { $0 as? CraftNativeModalView }.first {
|
|
5887
|
-
!$0.isHidden && $0.alpha > 0.01 && ($0.blocksBackground || $0.dismissOnOutsideTap)
|
|
6038
|
+
($0.isPresentedAsSheet || (!$0.isHidden && $0.alpha > 0.01)) && ($0.blocksBackground || $0.dismissOnOutsideTap)
|
|
5888
6039
|
}
|
|
5889
6040
|
guard let modal else { return false }
|
|
5890
6041
|
let hasHandler = modal.onRequestClose != nil
|
|
@@ -6240,6 +6391,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6240
6391
|
let secureGet: @convention(block) (String) -> String? = { key in
|
|
6241
6392
|
config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
|
|
6242
6393
|
}
|
|
6394
|
+
let setAppearance: @convention(block) (String) -> Bool = { value in
|
|
6395
|
+
if Thread.isMainThread { return CraftInterfaceStyleController.set(value) }
|
|
6396
|
+
var accepted = false
|
|
6397
|
+
DispatchQueue.main.sync { accepted = CraftInterfaceStyleController.set(value) }
|
|
6398
|
+
return accepted
|
|
6399
|
+
}
|
|
6400
|
+
let getAppearance: @convention(block) () -> String = {
|
|
6401
|
+
if Thread.isMainThread { return CraftInterfaceStyleController.currentName() }
|
|
6402
|
+
var value = "light"
|
|
6403
|
+
DispatchQueue.main.sync { value = CraftInterfaceStyleController.currentName() }
|
|
6404
|
+
return value
|
|
6405
|
+
}
|
|
6243
6406
|
let setOptions: @convention(block) (String) -> Void = { [weak self] json in
|
|
6244
6407
|
guard let data = json.data(using: .utf8),
|
|
6245
6408
|
let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
@@ -6252,6 +6415,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6252
6415
|
jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
|
|
6253
6416
|
jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString)
|
|
6254
6417
|
jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
|
|
6418
|
+
jsContext.setObject(setAppearance, forKeyedSubscript: "craftNativeSetAppearance" as NSString)
|
|
6419
|
+
jsContext.setObject(getAppearance, forKeyedSubscript: "craftNativeGetAppearance" as NSString)
|
|
6255
6420
|
jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
|
|
6256
6421
|
// Older native bundles call the page-shaped `craft.dialog` API
|
|
6257
6422
|
// directly. Keep that API backed by the same capability protocol as
|
|
@@ -6494,6 +6659,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6494
6659
|
return value === undefined ? null : value;
|
|
6495
6660
|
}
|
|
6496
6661
|
});
|
|
6662
|
+
keep('app', {
|
|
6663
|
+
setAppearance: function(value) {
|
|
6664
|
+
value = String(value);
|
|
6665
|
+
if (value !== 'light' && value !== 'dark' && value !== 'system') {
|
|
6666
|
+
return Promise.reject(new TypeError('craft.app.setAppearance needs light, dark or system'));
|
|
6667
|
+
}
|
|
6668
|
+
if (!craftNativeSetAppearance(value)) {
|
|
6669
|
+
return Promise.reject(new Error('craft.app.setAppearance was rejected'));
|
|
6670
|
+
}
|
|
6671
|
+
return Promise.resolve();
|
|
6672
|
+
},
|
|
6673
|
+
getAppearance: function() { return Promise.resolve(craftNativeGetAppearance()); }
|
|
6674
|
+
});
|
|
6497
6675
|
// The signed-in account (CraftSession): the token under
|
|
6498
6676
|
// `sessionTokenKey`, and each change of it, which the host
|
|
6499
6677
|
// reports through `__craftSessionChanged` after the Keychain
|
|
@@ -7148,6 +7326,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7148
7326
|
}
|
|
7149
7327
|
}
|
|
7150
7328
|
|
|
7329
|
+
/// A layout moved a view while the keyboard is up: once that pass is
|
|
7330
|
+
/// over, each scroll view's keyboard inset is measured again where the
|
|
7331
|
+
/// scroll view now ends. Measured only when the keyboard moved, a form
|
|
7332
|
+
/// that grew down under the keyboard (a footer below it shrank, a
|
|
7333
|
+
/// banner above it went away) kept the inset of where it used to end,
|
|
7334
|
+
/// and its last lines stayed under the keyboard.
|
|
7335
|
+
fileprivate func scheduleKeyboardInsets() {
|
|
7336
|
+
guard keyboardScreenFrame != nil, !keyboardInsetsScheduled else { return }
|
|
7337
|
+
keyboardInsetsScheduled = true
|
|
7338
|
+
DispatchQueue.main.async { [weak self] in
|
|
7339
|
+
guard let self else { return }
|
|
7340
|
+
self.keyboardInsetsScheduled = false
|
|
7341
|
+
if self.keyboardScreenFrame != nil { self.insetScrollViewsForKeyboard() }
|
|
7342
|
+
}
|
|
7343
|
+
}
|
|
7344
|
+
|
|
7151
7345
|
/// The focused field, scrolled into the part of its scroll view the
|
|
7152
7346
|
/// keyboard leaves, with a little room around it.
|
|
7153
7347
|
func scrollFocusedInputIntoView(animated: Bool) {
|
|
@@ -7159,6 +7353,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7159
7353
|
/// Internal for XCTest.
|
|
7160
7354
|
func scrollIntoView(_ input: UIView, animated: Bool) {
|
|
7161
7355
|
guard let scroll = enclosingVerticalScroll(of: input) else { return }
|
|
7356
|
+
// Measured against the keyboard where the scroll view is now.
|
|
7357
|
+
if keyboardScreenFrame != nil { insetScrollViewsForKeyboard() }
|
|
7162
7358
|
var rect = input.convert(input.bounds, to: scroll).insetBy(dx: 0, dy: -16)
|
|
7163
7359
|
// A field taller than the part the keyboard leaves (a long comment
|
|
7164
7360
|
// with no max height) shows its caret rather than its top: scrolled
|
|
@@ -7901,6 +8097,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7901
8097
|
textMaxLengths.removeValue(forKey: fieldId)
|
|
7902
8098
|
}
|
|
7903
8099
|
field.delegate = self
|
|
8100
|
+
switch props["clearButtonMode"] as? String {
|
|
8101
|
+
case "while-editing": field.clearButtonMode = .whileEditing
|
|
8102
|
+
case "unless-editing": field.clearButtonMode = .unlessEditing
|
|
8103
|
+
case "always": field.clearButtonMode = .always
|
|
8104
|
+
default: field.clearButtonMode = .never
|
|
8105
|
+
}
|
|
7904
8106
|
(field as? CraftNativeTextField)?.padding = paddingInsets(style)
|
|
7905
8107
|
field.textColor = color(style["color"]) ?? .label
|
|
7906
8108
|
field.font = textFont(style, default: .systemFont(ofSize: 16))
|
|
@@ -7921,7 +8123,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7921
8123
|
if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
|
|
7922
8124
|
applyAutofill(to: field, props: props)
|
|
7923
8125
|
if let value = props["value"] as? String {
|
|
7924
|
-
updateField(field, value: value)
|
|
8126
|
+
updateField(field, value: value, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
|
|
7925
8127
|
} else if previous?.view !== field {
|
|
7926
8128
|
updateField(field, value: inputDrafts[current.identity] ?? props["defaultValue"] as? String)
|
|
7927
8129
|
}
|
|
@@ -7933,6 +8135,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7933
8135
|
if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
|
|
7934
8136
|
DispatchQueue.main.async { _ = field.becomeFirstResponder() }
|
|
7935
8137
|
}
|
|
8138
|
+
case "SegmentedControl":
|
|
8139
|
+
let control = result as! CraftNativeSegmentedControl
|
|
8140
|
+
let items = props["items"] as? [[String: Any]] ?? []
|
|
8141
|
+
let labels = items.map { $0["label"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
|
|
8142
|
+
// UIKit owns the segments; only rebuild when the choices change.
|
|
8143
|
+
// Setting a controlled value never dispatches valueChanged.
|
|
8144
|
+
if control.numberOfSegments != labels.count || labels.enumerated().contains(where: { control.titleForSegment(at: $0.offset) != $0.element }) {
|
|
8145
|
+
control.removeAllSegments()
|
|
8146
|
+
for (index, label) in labels.enumerated() { control.insertSegment(withTitle: label, at: index, animated: false) }
|
|
8147
|
+
}
|
|
8148
|
+
control.values = items.map { $0["value"] as? String ?? craftNativePickerValue($0["value"]) ?? "" }
|
|
8149
|
+
for (index, item) in items.enumerated() { control.setEnabled(item["disabled"] as? Bool != true, forSegmentAt: index) }
|
|
8150
|
+
let selected = props["value"] as? String ?? craftNativePickerValue(props["value"]) ?? props["selectedValue"] as? String ?? craftNativePickerValue(props["selectedValue"])
|
|
8151
|
+
control.selectedSegmentIndex = control.values.firstIndex(where: { $0 == selected }) ?? UISegmentedControl.noSegment
|
|
8152
|
+
control.isEnabled = props["disabled"] as? Bool != true
|
|
8153
|
+
control.selectedSegmentTintColor = color(props["tintColor"])
|
|
8154
|
+
let titleAttributes: [NSAttributedString.Key: Any]? = color(style["color"]).map { [.foregroundColor: $0] }
|
|
8155
|
+
control.setTitleTextAttributes(titleAttributes, for: .normal)
|
|
8156
|
+
control.setTitleTextAttributes(titleAttributes, for: .selected)
|
|
8157
|
+
control.configureAccessibility(items: items)
|
|
8158
|
+
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: control)
|
|
7936
8159
|
case "Picker":
|
|
7937
8160
|
let picker = result as! UIPickerView
|
|
7938
8161
|
let itemOptions = (props["items"] as? [[String: Any]])?.compactMap { item -> (value: String, label: String)? in
|
|
@@ -7995,6 +8218,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7995
8218
|
case "Modal":
|
|
7996
8219
|
let modal = result as! CraftNativeModalView
|
|
7997
8220
|
let focusBeforeContent = firstResponder(in: rootStack) ?? lastFocusedInput
|
|
8221
|
+
modal.sheetPresenter = self
|
|
8222
|
+
modal.presentationStyle = props["presentationStyle"] as? String ?? "overFullScreen"
|
|
8223
|
+
modal.sheetDetents = props["sheetDetents"] as? [String] ?? ["medium", "large"]
|
|
8224
|
+
modal.selectedDetent = props["selectedDetent"] as? String
|
|
8225
|
+
modal.grabberVisible = props["grabberVisible"] as? Bool != false
|
|
8226
|
+
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
8227
|
+
{ [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
|
|
8228
|
+
}
|
|
7998
8229
|
modal.transparent = props["transparent"] as? Bool == true
|
|
7999
8230
|
modal.blocksBackground = props["nonModal"] as? Bool != true
|
|
8000
8231
|
modal.dismissOnOutsideTap = props["dismissOnOutsideTap"] as? Bool == true
|
|
@@ -8068,7 +8299,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8068
8299
|
toggle.onTintColor = color(track["true"])
|
|
8069
8300
|
toggle.tintColor = color(track["false"])
|
|
8070
8301
|
} else {
|
|
8071
|
-
toggle.onTintColor =
|
|
8302
|
+
toggle.onTintColor = color(style["color"])
|
|
8072
8303
|
toggle.tintColor = nil
|
|
8073
8304
|
}
|
|
8074
8305
|
toggle.thumbTintColor = color(props["thumbColor"])
|
|
@@ -8106,7 +8337,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8106
8337
|
}
|
|
8107
8338
|
indicator.indicatorScale = scale
|
|
8108
8339
|
applyTransform(style["transform"], to: indicator)
|
|
8109
|
-
indicator.color = color(props["color"]) ?? .tintColor
|
|
8340
|
+
indicator.color = color(props["color"]) ?? color(style["color"]) ?? .tintColor
|
|
8110
8341
|
indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
|
|
8111
8342
|
if props["animating"] as? Bool == false {
|
|
8112
8343
|
indicator.stopAnimating()
|
|
@@ -8279,7 +8510,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8279
8510
|
reconcileChildren(children, in: stack, parent: current, path: path, style: style)
|
|
8280
8511
|
}
|
|
8281
8512
|
updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
|
|
8282
|
-
if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
|
|
8513
|
+
if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "SegmentedControl" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
|
|
8283
8514
|
let disabled = props["disabled"] as? Bool == true
|
|
8284
8515
|
updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
|
|
8285
8516
|
updatePressFeedback(
|
|
@@ -8335,6 +8566,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8335
8566
|
field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
|
|
8336
8567
|
field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
|
|
8337
8568
|
return field
|
|
8569
|
+
case "SegmentedControl":
|
|
8570
|
+
let control = CraftNativeSegmentedControl()
|
|
8571
|
+
control.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
|
|
8572
|
+
return control
|
|
8338
8573
|
case "Picker":
|
|
8339
8574
|
let picker = UIPickerView()
|
|
8340
8575
|
picker.dataSource = self
|
|
@@ -8401,6 +8636,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8401
8636
|
case "link": return "Link"
|
|
8402
8637
|
case "checkbox": return "Checkbox"
|
|
8403
8638
|
case "textinput", "input": return "TextInput"
|
|
8639
|
+
case "segmentedcontrol", "segmented-control": return "SegmentedControl"
|
|
8404
8640
|
case "picker", "select": return "Picker"
|
|
8405
8641
|
case "datepicker", "date-picker", "date": return "DatePicker"
|
|
8406
8642
|
case "modal": return "Modal"
|
|
@@ -8750,6 +8986,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8750
8986
|
submitHandlers.removeValue(forKey: id)
|
|
8751
8987
|
inputIdentities.removeValue(forKey: id)
|
|
8752
8988
|
unechoedText.removeValue(forKey: id)
|
|
8989
|
+
inputEventCounts.removeValue(forKey: id)
|
|
8753
8990
|
if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
|
|
8754
8991
|
longPressHandlers.removeValue(forKey: id)
|
|
8755
8992
|
textMaxLengths.removeValue(forKey: id)
|
|
@@ -8967,19 +9204,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8967
9204
|
}
|
|
8968
9205
|
}
|
|
8969
9206
|
|
|
8970
|
-
///
|
|
8971
|
-
///
|
|
8972
|
-
/// one is the script catching up, not a new value, and applying it would
|
|
8973
|
-
/// undo what was typed since.
|
|
9207
|
+
/// Legacy runtimes echo values without a revision. Keep their bounded
|
|
9208
|
+
/// history on either script thread; bridge delivery may still be delayed.
|
|
8974
9209
|
private var unechoedText: [ObjectIdentifier: [String]] = [:]
|
|
9210
|
+
private var inputEventCounts: [ObjectIdentifier: Int] = [:]
|
|
8975
9211
|
|
|
8976
|
-
|
|
8977
|
-
|
|
9212
|
+
@discardableResult
|
|
9213
|
+
private func noteSentText(_ text: String, for view: UIView) -> Int {
|
|
8978
9214
|
let id = ObjectIdentifier(view)
|
|
9215
|
+
let count = (inputEventCounts[id] ?? 0) + 1
|
|
9216
|
+
inputEventCounts[id] = count
|
|
8979
9217
|
var sent = unechoedText[id] ?? []
|
|
8980
9218
|
sent.append(text)
|
|
8981
9219
|
if sent.count > 32 { sent.removeFirst(sent.count - 32) }
|
|
8982
9220
|
unechoedText[id] = sent
|
|
9221
|
+
return count
|
|
8983
9222
|
}
|
|
8984
9223
|
|
|
8985
9224
|
/// Whether `value` is a stale echo of text the view has moved past: it
|
|
@@ -8997,9 +9236,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8997
9236
|
return later > 0
|
|
8998
9237
|
}
|
|
8999
9238
|
|
|
9000
|
-
private func
|
|
9239
|
+
private func acceptsText(_ value: String, for view: UIView, acknowledged: Int?) -> Bool {
|
|
9240
|
+
if let acknowledged, acknowledged >= 0 {
|
|
9241
|
+
guard acknowledged >= (inputEventCounts[ObjectIdentifier(view)] ?? 0) else { return false }
|
|
9242
|
+
unechoedText.removeValue(forKey: ObjectIdentifier(view))
|
|
9243
|
+
return true // A current explicit reset may equal an older edit.
|
|
9244
|
+
}
|
|
9245
|
+
return !isStaleEcho(value, for: view)
|
|
9246
|
+
}
|
|
9247
|
+
|
|
9248
|
+
private func updateField(_ field: UITextField, value: String?, acknowledged: Int? = nil) {
|
|
9001
9249
|
guard let value = value, field.markedTextRange == nil else { return }
|
|
9002
|
-
if
|
|
9250
|
+
if !acceptsText(value, for: field, acknowledged: acknowledged) || field.text == value { return }
|
|
9003
9251
|
let selection = field.selectedTextRange
|
|
9004
9252
|
let start = selection.map { field.offset(from: field.beginningOfDocument, to: $0.start) }
|
|
9005
9253
|
let end = selection.map { field.offset(from: field.beginningOfDocument, to: $0.end) }
|
|
@@ -9011,9 +9259,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9011
9259
|
}
|
|
9012
9260
|
}
|
|
9013
9261
|
|
|
9014
|
-
private func updateTextView(_ textView: UITextView, value: String?) {
|
|
9262
|
+
private func updateTextView(_ textView: UITextView, value: String?, acknowledged: Int? = nil) {
|
|
9015
9263
|
guard let value = value, textView.markedTextRange == nil else { return }
|
|
9016
|
-
if
|
|
9264
|
+
if !acceptsText(value, for: textView, acknowledged: acknowledged) || textView.text == value { return }
|
|
9017
9265
|
let selection = textView.selectedTextRange
|
|
9018
9266
|
let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
|
|
9019
9267
|
let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
|
|
@@ -9076,7 +9324,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9076
9324
|
craftTextView.placeholderColor = color(props["placeholderTextColor"])
|
|
9077
9325
|
craftTextView.placeholderFont = textView.font
|
|
9078
9326
|
}
|
|
9079
|
-
updateTextView(textView, value: (props["value"] as? String) ?? defaultValue)
|
|
9327
|
+
updateTextView(textView, value: (props["value"] as? String) ?? defaultValue, acknowledged: (props["mostRecentEventCount"] as? NSNumber)?.intValue)
|
|
9080
9328
|
if textView.markedTextRange == nil {
|
|
9081
9329
|
let attributes = inputTextAttributes(style, font: textView.font ?? .systemFont(ofSize: 16), alignment: textView.textAlignment)
|
|
9082
9330
|
let styled = NSAttributedString(string: textView.text ?? "", attributes: attributes)
|
|
@@ -9780,7 +10028,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9780
10028
|
let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
|
|
9781
10029
|
// UIPickerView exposes adjustable component wheels. Making the
|
|
9782
10030
|
// picker itself a leaf hides those wheels and their selected values.
|
|
9783
|
-
let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type)
|
|
10031
|
+
let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl"].contains(type)
|
|
9784
10032
|
|| ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
|
|
9785
10033
|
|| controlledVideo
|
|
9786
10034
|
view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
|
|
@@ -9883,6 +10131,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9883
10131
|
/// Return on a `returnKeyType="next"` field the screen does not handle
|
|
9884
10132
|
/// itself (no `@submit`) goes to the next field down the screen, keeping
|
|
9885
10133
|
/// the keyboard up. Every other Return ends editing and fires `@submit`.
|
|
10134
|
+
func textFieldShouldClear(_ textField: UITextField) -> Bool {
|
|
10135
|
+
guard textField.isEnabled else { return false }
|
|
10136
|
+
textField.text = ""
|
|
10137
|
+
let id = ObjectIdentifier(textField)
|
|
10138
|
+
inputIdentities[id].map { inputDrafts[$0] = "" }
|
|
10139
|
+
let eventCount = noteSentText("", for: textField)
|
|
10140
|
+
if let handler = handlers[id] {
|
|
10141
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": "", "clear": true, "eventCount": eventCount]])
|
|
10142
|
+
}
|
|
10143
|
+
return false // The model receives exactly one edit, including lazy models.
|
|
10144
|
+
}
|
|
10145
|
+
|
|
9886
10146
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
9887
10147
|
guard textField.returnKeyType == .next, submitHandlers[ObjectIdentifier(textField)] == nil,
|
|
9888
10148
|
let next = inputAfter(textField) else { return true }
|
|
@@ -9929,8 +10189,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9929
10189
|
(textView as? CraftNativeTextView)?.refreshPlaceholder()
|
|
9930
10190
|
keepCaretVisible(in: textView)
|
|
9931
10191
|
guard let handler = handlers[id] else { return }
|
|
9932
|
-
noteSentText(textView.text ?? "", for: textView)
|
|
9933
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
|
|
10192
|
+
let eventCount = noteSentText(textView.text ?? "", for: textView)
|
|
10193
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? "", "eventCount": eventCount]])
|
|
9934
10194
|
}
|
|
9935
10195
|
|
|
9936
10196
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
@@ -9951,6 +10211,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9951
10211
|
textView.invalidateIntrinsicContentSize()
|
|
9952
10212
|
CraftNativeFlexLayout.invalidateAncestors(of: textView)
|
|
9953
10213
|
self.view.layoutIfNeeded()
|
|
10214
|
+
// That layout can move the scroll view: its keyboard inset is
|
|
10215
|
+
// measured where it now ends before the caret is.
|
|
10216
|
+
if self.keyboardScreenFrame != nil { self.insetScrollViewsForKeyboard() }
|
|
9954
10217
|
guard let scroll = self.enclosingVerticalScroll(of: textView),
|
|
9955
10218
|
let rect = self.caretRect(of: textView, in: scroll) else { return }
|
|
9956
10219
|
let visible = scroll.bounds.inset(by: scroll.adjustedContentInset)
|
|
@@ -9983,14 +10246,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9983
10246
|
inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
|
|
9984
10247
|
let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
|
|
9985
10248
|
guard let handler = handlers[id] else { return }
|
|
9986
|
-
noteSentText(sender.text ?? "", for: sender)
|
|
9987
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
|
|
10249
|
+
let eventCount = noteSentText(sender.text ?? "", for: sender)
|
|
10250
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? "", "eventCount": eventCount]])
|
|
9988
10251
|
restoreFocus(focused)
|
|
9989
10252
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
|
|
9990
10253
|
self?.restoreFocus(focused)
|
|
9991
10254
|
}
|
|
9992
10255
|
}
|
|
9993
10256
|
|
|
10257
|
+
@objc private func segmentChanged(_ sender: CraftNativeSegmentedControl) {
|
|
10258
|
+
let index = sender.selectedSegmentIndex
|
|
10259
|
+
guard sender.isEnabled, sender.values.indices.contains(index), sender.isEnabledForSegment(at: index),
|
|
10260
|
+
let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
10261
|
+
sender.refreshAccessibilityState()
|
|
10262
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.values[index], "index": index]])
|
|
10263
|
+
}
|
|
10264
|
+
|
|
9994
10265
|
@objc private func switchChanged(_ sender: UISwitch) {
|
|
9995
10266
|
let id = ObjectIdentifier(sender)
|
|
9996
10267
|
switchUserValues[id] = sender.isOn
|
|
@@ -117,6 +117,10 @@ final class CraftWatchSession: NSObject, ObservableObject {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
private func apply(_ payload: [String: Any]) {
|
|
120
|
+
if let incomingVersion = payload["stateVersion"] as? Int {
|
|
121
|
+
guard incomingVersion >= stateVersion else { return }
|
|
122
|
+
stateVersion = incomingVersion
|
|
123
|
+
}
|
|
120
124
|
if let plan = payload["plan"] as? [String: Any], payload["type"] as? String == "planned-session" {
|
|
121
125
|
plannedSession = plan
|
|
122
126
|
status = "Plan ready"
|
package/dist/types.d.ts
CHANGED
|
@@ -569,6 +569,17 @@ export interface AppConfig {
|
|
|
569
569
|
* Craft System Tray API (available as window.craft.tray in WebView)
|
|
570
570
|
*/
|
|
571
571
|
export interface CraftTrayAPI {
|
|
572
|
+
/** Read the rendered macOS status item, including its decoded image. */
|
|
573
|
+
getState(): Promise<{
|
|
574
|
+
title: string;
|
|
575
|
+
tooltip: string;
|
|
576
|
+
visible: boolean;
|
|
577
|
+
hasIcon: boolean;
|
|
578
|
+
template: boolean;
|
|
579
|
+
imageWidth: number;
|
|
580
|
+
imageHeight: number;
|
|
581
|
+
imagePosition: number;
|
|
582
|
+
}>;
|
|
572
583
|
/** SF Symbol name, image path, or base64 image data URL. */
|
|
573
584
|
setIcon(icon: string): Promise<void>;
|
|
574
585
|
/**
|
|
@@ -649,6 +660,10 @@ export interface CraftAppAPI {
|
|
|
649
660
|
* Get application information
|
|
650
661
|
*/
|
|
651
662
|
getInfo(): Promise<AppInfo>;
|
|
663
|
+
/** Pin every native surface to Light, Dark, or the system setting. */
|
|
664
|
+
setAppearance(appearance: 'light' | 'dark' | 'system'): Promise<void>;
|
|
665
|
+
/** Read the app's effective Light/Dark appearance. */
|
|
666
|
+
getAppearance(): Promise<'light' | 'dark'>;
|
|
652
667
|
}
|
|
653
668
|
/**
|
|
654
669
|
* Tray click event details
|