craft-native 0.0.202 → 0.0.204

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.
@@ -1,11 +1,40 @@
1
1
  import UIKit
2
2
 
3
3
  private final class CraftNativeFlatListLayout: UICollectionViewFlowLayout {
4
+ private var preparedExtent: CGFloat?
5
+
6
+ override func prepare() {
7
+ // Content-size queries can prepare layout before layoutSubviews runs.
8
+ (collectionView as? CraftNativeFlatList)?.updateItemEstimate()
9
+ super.prepare()
10
+ preparedExtent = collectionView.map { extent(of: $0.bounds) }
11
+ }
12
+
13
+ private func extent(of bounds: CGRect) -> CGFloat {
14
+ scrollDirection == .vertical ? bounds.width : bounds.height
15
+ }
16
+
4
17
  override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
5
- guard let current = collectionView?.bounds else { return true }
6
- return scrollDirection == .vertical
7
- ? abs(current.width - newBounds.width) > .ulpOfOne
8
- : abs(current.height - newBounds.height) > .ulpOfOne
18
+ guard let preparedExtent else { return true }
19
+ return abs(preparedExtent - extent(of: newBounds)) > .ulpOfOne
20
+ }
21
+
22
+ override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
23
+ let context = super.invalidationContext(forBoundsChange: newBounds)
24
+ if shouldInvalidateLayout(forBoundsChange: newBounds), let flow = context as? UICollectionViewFlowLayoutInvalidationContext {
25
+ flow.invalidateFlowLayoutDelegateMetrics = true
26
+ flow.invalidateFlowLayoutAttributes = true
27
+ // Self-sized item dimensions are cached separately from flow
28
+ // metrics. Clear them before a narrower viewport validates old
29
+ // widths, while keeping the cells and their rendered state.
30
+ if let collectionView {
31
+ let paths = (0..<collectionView.numberOfSections).flatMap { section in
32
+ (0..<collectionView.numberOfItems(inSection: section)).map { IndexPath(item: $0, section: section) }
33
+ }
34
+ flow.invalidateItems(at: paths)
35
+ }
36
+ }
37
+ return context
9
38
  }
10
39
  }
11
40
 
@@ -190,9 +219,30 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
190
219
  /// room again as they are.
191
220
  func fittingHeight(width: CGFloat) -> CGFloat {
192
221
  guard !isScrollEnabled else { return 0 }
193
- if abs(bounds.width - width) > 0.5 { frame.size.width = width }
194
- layoutIfNeeded()
195
- let content = flowLayout.collectionViewContentSize.height
222
+ // Flex measurement proposes several widths before committing a frame.
223
+ // Mutating the collection here lets self-sizing cache a provisional
224
+ // width and can reenter parent measurement through contentSize.didSet.
225
+ // Read the committed measurement, or estimate until layout measures
226
+ // rows at their actual width and onFittingHeightChanged asks for room.
227
+ let content: CGFloat
228
+ if abs(bounds.width - width) <= 0.5, contentSize.height > 0 {
229
+ content = contentSize.height
230
+ } else if flowLayout.scrollDirection == .horizontal {
231
+ content = items.isEmpty ? 0 : 44
232
+ } else {
233
+ var rows = 0
234
+ var pending = 0
235
+ for item in items {
236
+ if item.isChrome {
237
+ rows += (pending + columns - 1) / columns + 1
238
+ pending = 0
239
+ } else {
240
+ pending += 1
241
+ }
242
+ }
243
+ rows += (pending + columns - 1) / columns
244
+ content = CGFloat(rows) * 44 + CGFloat(max(0, rows - 1)) * flowLayout.minimumLineSpacing
245
+ }
196
246
  return max(0, ceil(content + contentInset.top + contentInset.bottom))
197
247
  }
198
248
 
@@ -208,13 +258,17 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
208
258
  return max(1, (width - spacing) / CGFloat(count))
209
259
  }
210
260
 
211
- override func layoutSubviews() {
261
+ fileprivate func updateItemEstimate() {
212
262
  let estimate = flowLayout.scrollDirection == .horizontal
213
263
  ? CGSize(width: 44, height: itemExtent(isChrome: true))
214
264
  : CGSize(width: itemExtent(isChrome: false), height: 44)
215
265
  if flowLayout.estimatedItemSize != estimate, bounds.width > 0 {
216
266
  flowLayout.estimatedItemSize = estimate
217
267
  }
268
+ }
269
+
270
+ override func layoutSubviews() {
271
+ updateItemEstimate()
218
272
  super.layoutSubviews()
219
273
  craftApplyCornerRadius()
220
274
  }
@@ -4650,16 +4650,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4650
4650
  }
4651
4651
  /// The scroll view the screen starts with, when nothing sits above it:
4652
4652
  /// the root's first child, its first, and so on, none padded on top.
4653
- func leading(_ node: RenderedNode) -> UIScrollView? {
4654
- if let scroll = node.view as? CraftNativeScrollView { return scroll.isVertical && scroll.isScrollEnabled ? scroll : nil }
4653
+ func leading(_ node: RenderedNode) -> RenderedNode? {
4654
+ if let scroll = node.view as? CraftNativeScrollView { return scroll.isVertical && scroll.isScrollEnabled ? node : nil }
4655
4655
  if let list = node.view as? CraftNativeFlatList {
4656
- return list.isScrollEnabled && (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal ? list : nil
4656
+ return list.isScrollEnabled && (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal ? node : nil
4657
4657
  }
4658
4658
  guard paddingInsets(node.style).top == 0,
4659
4659
  let first = node.children.first(where: { $0.type != "Modal" && !$0.view.isHidden }) else { return nil }
4660
4660
  return leading(first)
4661
4661
  }
4662
4662
  let scroll = find(renderedRoot)
4663
+ let leadingScroll = leading(renderedRoot)
4664
+ // Only the screen's leading scroller draws beneath floating chrome.
4665
+ // A bounded list inside a form must clip its rows to its viewport.
4666
+ if let leadingScroll, leadingScroll.style["overflow"] as? String != "hidden" {
4667
+ leadingScroll.view.clipsToBounds = false
4668
+ }
4663
4669
  // Under a large title the screen runs edge to edge, as UIKit's own
4664
4670
  // screens do: the scroll view starts under the bar and insets its
4665
4671
  // content by it, so the title collapses and pull to refresh draws
@@ -4667,7 +4673,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4667
4673
  // view: its content scrolls up under the bar behind iOS's scroll edge
4668
4674
  // effect, rather than over the bar's back button. Other screens stay
4669
4675
  // inside the safe area.
4670
- let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leading(renderedRoot))
4676
+ let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leadingScroll?.view)
4671
4677
  let fullEdgeToEdge = rootIsSafeAreaView
4672
4678
  let topEdgeToEdge = fullEdgeToEdge || edgeToEdge
4673
4679
  rootTopToSafeArea?.isActive = !topEdgeToEdge
@@ -5801,6 +5807,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5801
5807
  case "sectionlist": return "SectionList"
5802
5808
  case "flatlist": return "FlatList"
5803
5809
  case "svg": return "Svg"
5810
+ case "g": return "G"
5811
+ case "svgtext", "svg-text": return "SvgText"
5804
5812
  case "touchableopacity": return "TouchableOpacity"
5805
5813
  case "touchablehighlight": return "TouchableHighlight"
5806
5814
  case "pressable": return "Pressable"
@@ -5848,7 +5856,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5848
5856
  stack.removeArrangedSubview(spacer)
5849
5857
  spacer.removeFromSuperview()
5850
5858
  }
5851
- let nodes = children.compactMap { $0 as? [String: Any] }
5859
+ // STX permits text directly inside containers (including HTML links).
5860
+ // Give it a native label, inheriting typography without duplicating
5861
+ // the parent's padding, dimensions or flex layout.
5862
+ let typography = style.filter { key, _ in
5863
+ key == "color" || key == "letterSpacing" || key == "lineHeight"
5864
+ || key.hasPrefix("font") || key.hasPrefix("text")
5865
+ }
5866
+ let nodes = children.compactMap { child -> [String: Any]? in
5867
+ if let node = child as? [String: Any] { return node }
5868
+ if let text = child as? String {
5869
+ return ["type": "Text", "style": typography, "children": [text]]
5870
+ }
5871
+ return nil
5872
+ }
5852
5873
  let keys = nodes.compactMap { explicitKey($0, props: $0["props"] as? [String: Any] ?? [:]) }
5853
5874
  let counts = Dictionary(keys.map { ($0, 1) }, uniquingKeysWith: +)
5854
5875
  let old = Dictionary(uniqueKeysWithValues: parent.children.map { ($0.identity, $0) })
@@ -6157,10 +6178,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6157
6178
  }
6158
6179
 
6159
6180
  /// Views that take their own touches whatever handlers the screen gives
6160
- /// them: a scroll view, a modal, and a video's system controls (with
6181
+ /// them: native controls, a scroll view, a modal, and a video's controls (with
6161
6182
  /// interaction off, its play, scrubber and full screen never answered).
6162
6183
  private func ownsTouches(_ view: UIView) -> Bool {
6163
- view is UIScrollView || view is CraftNativeModalView || view is CraftNativeVideoView
6184
+ view is UIControl || view is UIScrollView || view is CraftNativeModalView || view is CraftNativeVideoView
6164
6185
  }
6165
6186
 
6166
6187
  private func updatePressHandler(_ handler: String?, for view: UIView) {
@@ -6172,7 +6193,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6172
6193
  let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:)))
6173
6194
  tapRecognizers[id] = recognizer
6174
6195
  view.addGestureRecognizer(recognizer)
6175
- } else if handler == nil, let recognizer = tapRecognizers.removeValue(forKey: id) {
6196
+ } else if !actionable, let recognizer = tapRecognizers.removeValue(forKey: id) {
6176
6197
  view.removeGestureRecognizer(recognizer)
6177
6198
  }
6178
6199
  // A container keeps receiving touches without a handler of its own,
@@ -6420,11 +6441,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6420
6441
  )
6421
6442
  // A label paints its background inside its own bounds, so a rounded
6422
6443
  // chip of text clips to its corners.
6423
- // A scroll view does not clip either: its content runs on under the
6424
- // floating tab bar and the home indicator, as iOS 26's own screens'
6425
- // does. At the top, a screen that starts with one runs edge to edge
6426
- // (trackContentScrollView), so nothing scrolls over the bar.
6427
- view.clipsToBounds = style["overflow"] as? String == "hidden"
6444
+ // Native scrollers clip their viewport unless explicitly made
6445
+ // visible. trackContentScrollView opens only the leading scroller
6446
+ // beneath the screen's floating chrome.
6447
+ let overflow = style["overflow"] as? String
6448
+ view.clipsToBounds = overflow == "hidden"
6449
+ || (view is UIScrollView && overflow != "visible")
6428
6450
  || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
6429
6451
  view.layer.masksToBounds = view.clipsToBounds
6430
6452
  view.craftLayoutStyle = CraftNativeLayoutStyle(style)
@@ -6809,7 +6831,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6809
6831
  } else if type != "TextInput" {
6810
6832
  view.accessibilityValue = nil
6811
6833
  }
6812
- let role = props["accessibilityRole"] as? String
6834
+ let hasLinkTarget = ["href", "to"].contains { !(props[$0] as? String ?? "").isEmpty }
6835
+ let linkedControl = ["Link", "TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type) && hasLinkTarget
6836
+ let role = (props["accessibilityRole"] as? String) ?? (linkedControl ? "link" : nil)
6813
6837
  // A scroll view, a list or a group of controls holds what VoiceOver
6814
6838
  // reads: its label names the group, as a tab strip or a pager's
6815
6839
  // name, and never stands in for its content. As an element of its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.202",
3
+ "version": "0.0.204",
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>",