craft-native 0.0.125 → 0.0.127

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/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.125";
3306
+ var version = "0.0.127";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.125";
313
+ var version = "0.0.127";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.125";
61
+ var version = "0.0.127";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -402,12 +402,17 @@ final class CraftHybrid: NSObject {
402
402
  navigate = function () { return own.apply(router, arguments); };
403
403
  // A forward navigation to a native path is the shell's to show; the
404
404
  // page draws nothing. Back (pushState false), tab switches and the
405
- // shell's own navigations go through untouched.
405
+ // shell's own navigations go through untouched. A redirect (replace),
406
+ // as after signing in, is the page leaving where it was: the shell
407
+ // shows the native screen and the page follows it there behind it,
408
+ // or it stayed on the sign-in page and the next screen pushed from it
409
+ // came up blank.
406
410
  router.navigate = router.navigateTo = function (url, pushState, force) {
407
411
  var path = local(url);
408
412
  if (path !== null && pushState !== false && pushState !== 'tab' && hostNav === null && match(path)) {
409
413
  var replace = pushState === 'replace' || !!(pushState && typeof pushState === 'object' && pushState.replace);
410
414
  post({ type: 'navigateNative', path: path, replace: replace });
415
+ if (replace) return own.apply(router, arguments);
411
416
  return Promise.resolve(false);
412
417
  }
413
418
  return own.apply(router, arguments);
@@ -441,6 +446,16 @@ final class CraftHybrid: NSObject {
441
446
  if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
442
447
  var link = event.target && event.target.closest ? event.target.closest('a[href]') : null;
443
448
  if (!link) return;
449
+ // The bar's Back (data-native-back). Its href is only where to go
450
+ // without history, often a native path like the calendar; at the
451
+ // entry a native screen opened, Back is that screen.
452
+ if (link.hasAttribute('data-native-back')) {
453
+ if (!atBase()) return;
454
+ event.preventDefault();
455
+ event.stopPropagation();
456
+ post({ type: 'back' });
457
+ return;
458
+ }
444
459
  if ((link.target && link.target !== '_self') || link.hasAttribute('download') || link.hasAttribute('data-native-tab')
445
460
  || link.getAttribute('data-stx-nav') === 'tab' || link.hasAttribute('data-craft-web')) return;
446
461
  var path = local(link.href);
@@ -1077,8 +1092,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1077
1092
 
1078
1093
  func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
1079
1094
  // The page draws its own header, and a tab's root screen its own
1080
- // title; a pushed native screen gets the bar and its Back.
1081
- let bare = viewController is CraftHybridWebSlot || navigationController.viewControllers.first === viewController
1095
+ // title unless it asked for the bar; a pushed native screen gets the
1096
+ // bar and its Back.
1097
+ // A root screen that asked for its own bar (setOptions) keeps it.
1098
+ let asksForBar = (viewController as? CraftNativeScreenController)?.wantsNavigationBar ?? false
1099
+ let bare = viewController is CraftHybridWebSlot || (navigationController.viewControllers.first === viewController && !asksForBar)
1082
1100
  navigationController.setNavigationBarHidden(bare, animated: animated)
1083
1101
  }
1084
1102
 
@@ -213,6 +213,14 @@ enum CraftNativeFlexLayout {
213
213
  }
214
214
  }
215
215
 
216
+ /// A size rounded up to whole pixels, never below `atLeast`.
217
+ static func ceiled(_ size: CGSize, scale: CGFloat, atLeast floor: CGSize) -> CGSize {
218
+ let scale = max(1, scale)
219
+ // Less a hair, so a size already on the pixel grid stays put.
220
+ func up(_ value: CGFloat) -> CGFloat { (value * scale - 0.001).rounded(.up) / scale }
221
+ return CGSize(width: max(floor.width, up(size.width)), height: max(floor.height, up(size.height)))
222
+ }
223
+
216
224
  /// A frame snapped to the screen's pixels edge by edge, so neighbours
217
225
  /// neither overlap nor leave a hairline between them.
218
226
  static func snapped(_ rect: CGRect, scale: CGFloat) -> CGRect {
@@ -358,8 +366,23 @@ private final class CraftNativeFlowView: UIStackView {
358
366
  let scale = window?.screen.scale ?? traitCollection.displayScale
359
367
  let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
360
368
  for (child, frame) in result.frames {
361
- let snapped = CraftNativeFlexLayout.snapped(frame, scale: scale)
362
- if child.frame != snapped { child.frame = snapped }
369
+ var snapped = CraftNativeFlexLayout.snapped(frame, scale: scale)
370
+ // Snapping each edge can take a pixel off a label (15.83…33.83
371
+ // becomes 16…33.67), and a label a pixel narrower than its text
372
+ // truncates it ("13" drew as "…"). Text keeps its whole size.
373
+ if child is UILabel {
374
+ snapped.size = CraftNativeFlexLayout.ceiled(frame.size, scale: scale, atLeast: snapped.size)
375
+ }
376
+ if child.transform.isIdentity {
377
+ if child.frame != snapped { child.frame = snapped }
378
+ } else {
379
+ // A transformed child (a sticky header, a scaled spinner) is
380
+ // placed by bounds and center: its frame is undefined then.
381
+ let size = CGRect(origin: child.bounds.origin, size: snapped.size)
382
+ let center = CGPoint(x: snapped.midX, y: snapped.midY)
383
+ if child.bounds != size { child.bounds = size }
384
+ if child.center != center { child.center = center }
385
+ }
363
386
  child.craftApplyCornerRadius()
364
387
  }
365
388
  }
@@ -832,18 +855,144 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
832
855
  override func layoutSubviews() {
833
856
  super.layoutSubviews()
834
857
  craftApplyCornerRadius()
835
- guard contentNeedsLayout || laidOutSize != bounds.size else { return }
836
- contentNeedsLayout = false
837
- laidOutSize = bounds.size
838
- var size = contentFitting(width: bounds.width, height: bounds.height)
858
+ if contentNeedsLayout || laidOutSize != bounds.size {
859
+ contentNeedsLayout = false
860
+ laidOutSize = bounds.size
861
+ var size = contentFitting(width: bounds.width, height: bounds.height)
862
+ if isVertical {
863
+ size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
864
+ } else {
865
+ size.height = max(0, bounds.height - adjustedContentInset.top - adjustedContentInset.bottom)
866
+ }
867
+ let frame = CGRect(origin: .zero, size: size)
868
+ if contentStack.frame != frame { contentStack.frame = frame }
869
+ if contentSize != size { contentSize = size }
870
+ }
871
+ // A scroll view lays out on every frame of a scroll: the sticky
872
+ // headers follow it here, over content placed by this same pass.
873
+ if !stickyIndices.isEmpty || pendingScrollTarget != nil { contentStack.layoutIfNeeded() }
874
+ updateStickyHeaders()
875
+ scrollToPendingTarget()
876
+ }
877
+
878
+ // MARK: Sticky headers
879
+
880
+ private var stickyIndices: [Int] = []
881
+ private var stickyViews: [UIView] = []
882
+
883
+ /// `stickyHeaderIndices`, as in React Native: the content's children at
884
+ /// these indices stay at the top of the visible area once scrolled to,
885
+ /// each pushed up by the next. Vertical scroll views only.
886
+ func setStickyHeaderIndices(_ value: Any?) {
887
+ let next = Array(Set(((value as? [Any]) ?? []).compactMap { ($0 as? NSNumber)?.intValue }.filter { $0 >= 0 })).sorted()
888
+ guard next != stickyIndices else { return }
889
+ stickyIndices = next
890
+ setNeedsLayout()
891
+ }
892
+
893
+ /// Where a header sits in the content when it is not stuck.
894
+ private func naturalTop(_ view: UIView) -> CGFloat {
895
+ view.center.y - view.bounds.height / 2
896
+ }
897
+
898
+ private func updateStickyHeaders() {
899
+ let children = contentStack.arrangedSubviews
900
+ let headers = isVertical ? stickyIndices.compactMap { $0 < children.count ? children[$0] : nil }.filter { !$0.isHidden } : []
901
+ for view in stickyViews where !headers.contains(where: { $0 === view }) {
902
+ view.transform = .identity
903
+ view.layer.zPosition = 0
904
+ }
905
+ stickyViews = headers
906
+ guard !headers.isEmpty else { return }
907
+ let visibleTop = contentOffset.y + adjustedContentInset.top
908
+ for (index, header) in headers.enumerated() {
909
+ let natural = naturalTop(header)
910
+ var top = max(natural, visibleTop)
911
+ if index + 1 < headers.count {
912
+ top = max(natural, min(top, naturalTop(headers[index + 1]) - header.bounds.height))
913
+ }
914
+ let shift = CGAffineTransform(translationX: 0, y: top - natural)
915
+ if header.transform != shift { header.transform = shift }
916
+ // Above the content it covers, for drawing and for touches.
917
+ header.layer.zPosition = 1
918
+ if contentStack.subviews.last !== header { contentStack.bringSubviewToFront(header) }
919
+ }
920
+ }
921
+
922
+ /// The height a stuck header covers above a point of the content.
923
+ private func stickyHeight(above y: CGFloat) -> CGFloat {
924
+ stickyViews.last(where: { naturalTop($0) <= y })?.bounds.height ?? 0
925
+ }
926
+
927
+ // MARK: Scroll targets
928
+
929
+ private var scrollTargetKey: String?
930
+ private var pendingScrollTarget: (id: String, animated: Bool, attempts: Int)?
931
+
932
+ /// `scrollTarget`: `"<testID>"` or `{ id, animated?, key? }`. When it
933
+ /// changes, the content scrolls so the view with that `testID` sits at
934
+ /// the top of the visible area, below any sticky header. A new `key`
935
+ /// scrolls to the same view again; `null` forgets the last target.
936
+ func setScrollTarget(_ value: Any?) {
937
+ var id: String?
938
+ var animated = true
939
+ if let text = value as? String {
940
+ id = text
941
+ } else if let target = value as? [String: Any] {
942
+ id = target["id"] as? String
943
+ animated = target["animated"] as? Bool ?? true
944
+ }
945
+ guard let id, !id.isEmpty else {
946
+ scrollTargetKey = nil
947
+ pendingScrollTarget = nil
948
+ return
949
+ }
950
+ let key: String
951
+ if let object = value as? [String: Any],
952
+ let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) {
953
+ key = String(decoding: data, as: UTF8.self)
954
+ } else {
955
+ key = id
956
+ }
957
+ guard key != scrollTargetKey else { return }
958
+ scrollTargetKey = key
959
+ pendingScrollTarget = (id, animated, 0)
960
+ setNeedsLayout()
961
+ }
962
+
963
+ private func descendant(withIdentifier id: String, in view: UIView) -> UIView? {
964
+ for child in view.subviews {
965
+ if child.accessibilityIdentifier == id { return child }
966
+ if let match = descendant(withIdentifier: id, in: child) { return match }
967
+ }
968
+ return nil
969
+ }
970
+
971
+ private func scrollToPendingTarget() {
972
+ guard let target = pendingScrollTarget, bounds.height > 0 else { return }
973
+ pendingScrollTarget = nil
974
+ let view = descendant(withIdentifier: target.id, in: contentStack)
975
+ // A target the same render created may not be in place yet (this pass
976
+ // can run while the render is applied): try again on the next passes.
977
+ guard let view, view.bounds.size != .zero else {
978
+ if target.attempts < 3 {
979
+ pendingScrollTarget = (target.id, target.animated, target.attempts + 1)
980
+ DispatchQueue.main.async { [weak self] in self?.setNeedsLayout() }
981
+ }
982
+ return
983
+ }
984
+ let rect = view.convert(view.bounds, to: contentStack)
839
985
  if isVertical {
840
- size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
986
+ let lowest = -adjustedContentInset.top
987
+ let highest = max(lowest, contentSize.height + adjustedContentInset.bottom - bounds.height)
988
+ let y = min(highest, max(lowest, rect.minY - adjustedContentInset.top - stickyHeight(above: rect.minY)))
989
+ setContentOffset(CGPoint(x: contentOffset.x, y: y), animated: target.animated)
841
990
  } else {
842
- size.height = max(0, bounds.height - adjustedContentInset.top - adjustedContentInset.bottom)
991
+ let lowest = -adjustedContentInset.left
992
+ let highest = max(lowest, contentSize.width + adjustedContentInset.right - bounds.width)
993
+ let x = min(highest, max(lowest, rect.minX - adjustedContentInset.left))
994
+ setContentOffset(CGPoint(x: x, y: contentOffset.y), animated: target.animated)
843
995
  }
844
- let frame = CGRect(origin: .zero, size: size)
845
- if contentStack.frame != frame { contentStack.frame = frame }
846
- if contentSize != size { contentSize = size }
847
996
  }
848
997
 
849
998
  override func adjustedContentInsetDidChange() {
@@ -1140,7 +1289,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1140
1289
  if let navigationBarHidden, navigationController?.isNavigationBarHidden != navigationBarHidden {
1141
1290
  navigationController?.setNavigationBarHidden(navigationBarHidden, animated: animated)
1142
1291
  } else if navigationBarHidden == nil, navigationController?.isNavigationBarHidden == true,
1143
- navigationController?.viewControllers.first !== self {
1292
+ navigationController?.viewControllers.first !== self || wantsNavigationBar {
1144
1293
  // A screen that did not ask for a hidden bar shows one, even after
1145
1294
  // a screen that hid its own.
1146
1295
  navigationController?.setNavigationBarHidden(false, animated: animated)
@@ -1245,6 +1394,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1245
1394
  };
1246
1395
  })();
1247
1396
  }
1397
+ // Repeating timers, on the one-shot ones: each tick schedules the
1398
+ // next before running, so a callback that throws or clears itself
1399
+ // still behaves, and a screen that closes cancels them with the rest.
1400
+ if (typeof globalThis.setInterval !== 'function') {
1401
+ (function() {
1402
+ var intervals = new Map();
1403
+ var nextInterval = 0;
1404
+ globalThis.setInterval = function(callback, delay) {
1405
+ var args = Array.prototype.slice.call(arguments, 2);
1406
+ var id = ++nextInterval;
1407
+ var ms = Math.max(Number(delay) || 0, 1);
1408
+ var tick = function() {
1409
+ if (!intervals.has(id)) return;
1410
+ intervals.set(id, globalThis.setTimeout(tick, ms));
1411
+ if (typeof callback === 'function') callback.apply(undefined, args);
1412
+ };
1413
+ intervals.set(id, globalThis.setTimeout(tick, ms));
1414
+ return id;
1415
+ };
1416
+ globalThis.clearInterval = function(id) {
1417
+ var timer = intervals.get(id);
1418
+ if (timer === undefined) return;
1419
+ intervals.delete(id);
1420
+ globalThis.clearTimeout(timer);
1421
+ };
1422
+ })();
1423
+ }
1248
1424
  """)
1249
1425
  }
1250
1426
 
@@ -1421,6 +1597,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1421
1597
  private var rootTopToView: NSLayoutConstraint?
1422
1598
  private var navigationBarHidden: Bool?
1423
1599
  private var prefersLargeTitle: Bool?
1600
+
1601
+ /// Whether this screen asked for a bar of its own through `setOptions`: a
1602
+ /// title, a large title or buttons, and not `hidden`. A tab's root screen
1603
+ /// in a hybrid app is shown bare unless it did.
1604
+ var wantsNavigationBar: Bool {
1605
+ if let navigationBarHidden { return !navigationBarHidden }
1606
+ return titleFromOptions || prefersLargeTitle == true || !(navigationItem.rightBarButtonItems ?? []).isEmpty
1607
+ }
1424
1608
  private weak var trackedScrollView: UIScrollView?
1425
1609
 
1426
1610
  /// `craft.navigation.setOptions`: this screen's bar. Every key is
@@ -1449,6 +1633,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1449
1633
  let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
1450
1634
  navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
1451
1635
  }
1636
+ // Asked for after the bar was hidden for a bare root screen.
1637
+ if navigationBarHidden == nil, wantsNavigationBar, navigationController?.topViewController === self,
1638
+ navigationController?.isNavigationBarHidden == true {
1639
+ navigationController?.setNavigationBarHidden(false, animated: view.window != nil)
1640
+ }
1452
1641
  }
1453
1642
 
1454
1643
  /// A bar button from `{ id, symbol | title }`. Its tap is the event
@@ -2036,6 +2225,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2036
2225
  scroll.showsHorizontalScrollIndicator = props["showsHorizontalScrollIndicator"] as? Bool != false
2037
2226
  scroll.alwaysBounceVertical = props["alwaysBounceVertical"] as? Bool ?? (direction == .vertical)
2038
2227
  scroll.alwaysBounceHorizontal = props["alwaysBounceHorizontal"] as? Bool ?? (direction == .horizontal)
2228
+ scroll.setStickyHeaderIndices(props["stickyHeaderIndices"])
2229
+ scroll.setScrollTarget(props["scrollTarget"])
2039
2230
  updateAuxiliaryHandler(events["onScroll"], in: &scrollHandlers, for: scroll)
2040
2231
  updateAuxiliaryHandler(events["onScrollBeginDrag"], in: &scrollBeginHandlers, for: scroll)
2041
2232
  updateAuxiliaryHandler(events["onScrollEndDrag"], in: &scrollEndHandlers, for: scroll)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.125",
3
+ "version": "0.0.127",
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>",