@rdlabo/capacitor-native-ui 0.0.0-stage → 1.2.1

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.
Files changed (255) hide show
  1. package/LICENSE +21 -0
  2. package/Package.swift +23 -0
  3. package/README.md +53 -2
  4. package/RdlaboCapacitorNativeUi.podspec +17 -0
  5. package/android/build.gradle +69 -0
  6. package/android/proguard-rules.pro +0 -0
  7. package/android/src/main/AndroidManifest.xml +1 -0
  8. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUI.kt +51 -0
  9. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUIPlugin.kt +192 -0
  10. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUISnapshot.kt +57 -0
  11. package/dist/_virtual/_rolldown/runtime.js +13 -0
  12. package/dist/core.d.ts +4 -0
  13. package/dist/core.js +3 -0
  14. package/dist/native/activation.d.ts +14 -0
  15. package/dist/native/activation.d.ts.map +1 -0
  16. package/dist/native/activation.js +305 -0
  17. package/dist/native/activation.js.map +1 -0
  18. package/dist/native/components/index.d.ts +19 -0
  19. package/dist/native/components/index.d.ts.map +1 -0
  20. package/dist/native/components/index.js +38 -0
  21. package/dist/native/components/index.js.map +1 -0
  22. package/dist/native/components/ion-back-button.d.ts +10 -0
  23. package/dist/native/components/ion-back-button.d.ts.map +1 -0
  24. package/dist/native/components/ion-back-button.js +21 -0
  25. package/dist/native/components/ion-back-button.js.map +1 -0
  26. package/dist/native/components/ion-button.d.ts +10 -0
  27. package/dist/native/components/ion-button.d.ts.map +1 -0
  28. package/dist/native/components/ion-button.js +25 -0
  29. package/dist/native/components/ion-button.js.map +1 -0
  30. package/dist/native/components/ion-buttons.d.ts +11 -0
  31. package/dist/native/components/ion-buttons.d.ts.map +1 -0
  32. package/dist/native/components/ion-buttons.js +30 -0
  33. package/dist/native/components/ion-buttons.js.map +1 -0
  34. package/dist/native/components/ion-fab.d.ts +10 -0
  35. package/dist/native/components/ion-fab.d.ts.map +1 -0
  36. package/dist/native/components/ion-fab.js +132 -0
  37. package/dist/native/components/ion-fab.js.map +1 -0
  38. package/dist/native/components/ion-menu-button.d.ts +13 -0
  39. package/dist/native/components/ion-menu-button.d.ts.map +1 -0
  40. package/dist/native/components/ion-menu-button.js +44 -0
  41. package/dist/native/components/ion-menu-button.js.map +1 -0
  42. package/dist/native/components/ion-segment.d.ts +11 -0
  43. package/dist/native/components/ion-segment.d.ts.map +1 -0
  44. package/dist/native/components/ion-segment.js +30 -0
  45. package/dist/native/components/ion-segment.js.map +1 -0
  46. package/dist/native/components/ion-tab-bar.d.ts +11 -0
  47. package/dist/native/components/ion-tab-bar.d.ts.map +1 -0
  48. package/dist/native/components/ion-tab-bar.js +50 -0
  49. package/dist/native/components/ion-tab-bar.js.map +1 -0
  50. package/dist/native/components/searchable-tabs.d.ts +20 -0
  51. package/dist/native/components/searchable-tabs.d.ts.map +1 -0
  52. package/dist/native/components/searchable-tabs.js +413 -0
  53. package/dist/native/components/searchable-tabs.js.map +1 -0
  54. package/dist/native/definitions.d.ts +255 -0
  55. package/dist/native/definitions.d.ts.map +1 -0
  56. package/dist/native/definitions.js +0 -0
  57. package/dist/native/extensions.d.ts +31 -0
  58. package/dist/native/extensions.d.ts.map +1 -0
  59. package/dist/native/extensions.js +122 -0
  60. package/dist/native/extensions.js.map +1 -0
  61. package/dist/native/foldable/fold-state.d.ts +5 -0
  62. package/dist/native/foldable/fold-state.d.ts.map +1 -0
  63. package/dist/native/foldable/fold-state.js +15 -0
  64. package/dist/native/foldable/fold-state.js.map +1 -0
  65. package/dist/native/foldable/types.d.ts +12 -0
  66. package/dist/native/foldable/types.d.ts.map +1 -0
  67. package/dist/native/foldable/types.js +0 -0
  68. package/dist/native/html.d.ts +9 -0
  69. package/dist/native/html.d.ts.map +1 -0
  70. package/dist/native/html.js +188 -0
  71. package/dist/native/html.js.map +1 -0
  72. package/dist/native/index.d.ts +10 -0
  73. package/dist/native/index.d.ts.map +1 -0
  74. package/dist/native/index.js +28 -0
  75. package/dist/native/index.js.map +1 -0
  76. package/dist/native/legacy-layout.d.ts +5 -0
  77. package/dist/native/legacy-layout.d.ts.map +1 -0
  78. package/dist/native/legacy-layout.js +27 -0
  79. package/dist/native/legacy-layout.js.map +1 -0
  80. package/dist/native/overlays/content.d.ts +4 -0
  81. package/dist/native/overlays/content.d.ts.map +1 -0
  82. package/dist/native/overlays/content.js +51 -0
  83. package/dist/native/overlays/content.js.map +1 -0
  84. package/dist/native/overlays/controller.d.ts +8 -0
  85. package/dist/native/overlays/controller.d.ts.map +1 -0
  86. package/dist/native/overlays/controller.js +285 -0
  87. package/dist/native/overlays/controller.js.map +1 -0
  88. package/dist/native/overlays/dialog-animation.d.ts +9 -0
  89. package/dist/native/overlays/dialog-animation.d.ts.map +1 -0
  90. package/dist/native/overlays/dialog-animation.js +53 -0
  91. package/dist/native/overlays/dialog-animation.js.map +1 -0
  92. package/dist/native/overlays/dismiss-animation.d.ts +4 -0
  93. package/dist/native/overlays/dismiss-animation.d.ts.map +1 -0
  94. package/dist/native/overlays/dismiss-animation.js +18 -0
  95. package/dist/native/overlays/dismiss-animation.js.map +1 -0
  96. package/dist/native/overlays/focus.d.ts +5 -0
  97. package/dist/native/overlays/focus.d.ts.map +1 -0
  98. package/dist/native/overlays/focus.js +59 -0
  99. package/dist/native/overlays/focus.js.map +1 -0
  100. package/dist/native/overlays/popover.d.ts +12 -0
  101. package/dist/native/overlays/popover.d.ts.map +1 -0
  102. package/dist/native/overlays/popover.js +110 -0
  103. package/dist/native/overlays/popover.js.map +1 -0
  104. package/dist/native/overlays/styles.d.ts +11 -0
  105. package/dist/native/overlays/styles.d.ts.map +1 -0
  106. package/dist/native/overlays/styles.js +167 -0
  107. package/dist/native/overlays/styles.js.map +1 -0
  108. package/dist/native/prehide.d.ts +8 -0
  109. package/dist/native/prehide.d.ts.map +1 -0
  110. package/dist/native/prehide.js +238 -0
  111. package/dist/native/prehide.js.map +1 -0
  112. package/dist/native/runtime.d.ts +7 -0
  113. package/dist/native/runtime.d.ts.map +1 -0
  114. package/dist/native/runtime.js +788 -0
  115. package/dist/native/runtime.js.map +1 -0
  116. package/dist/native/shared/bridge.d.ts +6 -0
  117. package/dist/native/shared/bridge.d.ts.map +1 -0
  118. package/dist/native/shared/bridge.js +18 -0
  119. package/dist/native/shared/bridge.js.map +1 -0
  120. package/dist/native/shared/candidate.d.ts +25 -0
  121. package/dist/native/shared/candidate.d.ts.map +1 -0
  122. package/dist/native/shared/candidate.js +106 -0
  123. package/dist/native/shared/candidate.js.map +1 -0
  124. package/dist/native/shared/clipping.d.ts +5 -0
  125. package/dist/native/shared/clipping.d.ts.map +1 -0
  126. package/dist/native/shared/clipping.js +26 -0
  127. package/dist/native/shared/clipping.js.map +1 -0
  128. package/dist/native/shared/crossfade.d.ts +12 -0
  129. package/dist/native/shared/crossfade.d.ts.map +1 -0
  130. package/dist/native/shared/crossfade.js +67 -0
  131. package/dist/native/shared/crossfade.js.map +1 -0
  132. package/dist/native/shared/diagnostics.d.ts +4 -0
  133. package/dist/native/shared/diagnostics.d.ts.map +1 -0
  134. package/dist/native/shared/diagnostics.js +18 -0
  135. package/dist/native/shared/diagnostics.js.map +1 -0
  136. package/dist/native/shared/dom.d.ts +46 -0
  137. package/dist/native/shared/dom.d.ts.map +1 -0
  138. package/dist/native/shared/dom.js +180 -0
  139. package/dist/native/shared/dom.js.map +1 -0
  140. package/dist/native/shared/events.d.ts +15 -0
  141. package/dist/native/shared/events.d.ts.map +1 -0
  142. package/dist/native/shared/events.js +17 -0
  143. package/dist/native/shared/events.js.map +1 -0
  144. package/dist/native/shared/icons.d.ts +8 -0
  145. package/dist/native/shared/icons.d.ts.map +1 -0
  146. package/dist/native/shared/icons.js +61 -0
  147. package/dist/native/shared/icons.js.map +1 -0
  148. package/dist/native/shared/modal.d.ts +10 -0
  149. package/dist/native/shared/modal.d.ts.map +1 -0
  150. package/dist/native/shared/modal.js +160 -0
  151. package/dist/native/shared/modal.js.map +1 -0
  152. package/dist/native/shared/projection-id.d.ts +4 -0
  153. package/dist/native/shared/projection-id.d.ts.map +1 -0
  154. package/dist/native/shared/projection-id.js +7 -0
  155. package/dist/native/shared/projection-id.js.map +1 -0
  156. package/dist/native/shared/toolbar-options.d.ts +9 -0
  157. package/dist/native/shared/toolbar-options.d.ts.map +1 -0
  158. package/dist/native/shared/toolbar-options.js +36 -0
  159. package/dist/native/shared/toolbar-options.js.map +1 -0
  160. package/dist/native/shared/vertical-bars-geometry.d.ts +10 -0
  161. package/dist/native/shared/vertical-bars-geometry.d.ts.map +1 -0
  162. package/dist/native/shared/vertical-bars-geometry.js +85 -0
  163. package/dist/native/shared/vertical-bars-geometry.js.map +1 -0
  164. package/dist/native/startup.d.ts +8 -0
  165. package/dist/native/startup.d.ts.map +1 -0
  166. package/dist/native/startup.js +86 -0
  167. package/dist/native/startup.js.map +1 -0
  168. package/dist/native/vertical-bars-web-search.d.ts +10 -0
  169. package/dist/native/vertical-bars-web-search.d.ts.map +1 -0
  170. package/dist/native/vertical-bars-web-search.js +90 -0
  171. package/dist/native/vertical-bars-web-search.js.map +1 -0
  172. package/dist/native/vertical-bars-web.d.ts +5 -0
  173. package/dist/native/vertical-bars-web.d.ts.map +1 -0
  174. package/dist/native/vertical-bars-web.js +421 -0
  175. package/dist/native/vertical-bars-web.js.map +1 -0
  176. package/dist/native-integration/index.d.ts +25 -0
  177. package/dist/native-integration/index.d.ts.map +1 -0
  178. package/dist/native-integration/index.js +33 -0
  179. package/dist/native-integration/index.js.map +1 -0
  180. package/dist/vertical-bars.d.ts +6 -0
  181. package/dist/vertical-bars.js +5 -0
  182. package/docs/configuration.md +187 -0
  183. package/docs/framework-independent.md +82 -0
  184. package/docs/migration.md +206 -0
  185. package/docs/native-mini-player.md +47 -0
  186. package/docs/native-ui-extensions.md +365 -0
  187. package/docs/native-ui.md +409 -0
  188. package/docs/setup.md +205 -0
  189. package/docs/vertical-bars.md +379 -0
  190. package/ios/Sources/NativeUIPlugin/Components/NativeUIButton.swift +71 -0
  191. package/ios/Sources/NativeUIPlugin/Components/NativeUIButtons.swift +24 -0
  192. package/ios/Sources/NativeUIPlugin/Components/NativeUIComponents.swift +19 -0
  193. package/ios/Sources/NativeUIPlugin/Components/NativeUIFab.swift +80 -0
  194. package/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift +439 -0
  195. package/ios/Sources/NativeUIPlugin/Components/NativeUISegment.swift +91 -0
  196. package/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift +218 -0
  197. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +590 -0
  198. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalSearch.swift +118 -0
  199. package/ios/Sources/NativeUIPlugin/Extensions/NativeUI.swift +178 -0
  200. package/ios/Sources/NativeUIPlugin/Extensions/NativeUIRendering.swift +38 -0
  201. package/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +615 -0
  202. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayController.swift +119 -0
  203. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayHost.swift +75 -0
  204. package/ios/Sources/NativeUIPlugin/Shared/NativeUIControlRendering.swift +21 -0
  205. package/ios/Sources/NativeUIPlugin/Shared/NativeUICrossfade.swift +35 -0
  206. package/ios/Sources/NativeUIPlugin/Shared/NativeUIHost.swift +8 -0
  207. package/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift +161 -0
  208. package/ios/Sources/NativeUIPlugin/Shared/NativeUIVerticalBarsGeometry.swift +52 -0
  209. package/ios/Tests/NativeUIPluginTests/NativeUIExtensionTests.swift +165 -0
  210. package/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +805 -0
  211. package/package.json +89 -4
  212. package/src/core.ts +15 -0
  213. package/src/native/activation.ts +394 -0
  214. package/src/native/components/index.ts +45 -0
  215. package/src/native/components/ion-back-button.ts +22 -0
  216. package/src/native/components/ion-button.ts +25 -0
  217. package/src/native/components/ion-buttons.ts +37 -0
  218. package/src/native/components/ion-fab.ts +151 -0
  219. package/src/native/components/ion-menu-button.ts +37 -0
  220. package/src/native/components/ion-segment.ts +27 -0
  221. package/src/native/components/ion-tab-bar.ts +50 -0
  222. package/src/native/components/searchable-tabs.ts +499 -0
  223. package/src/native/definitions.ts +324 -0
  224. package/src/native/extensions.ts +176 -0
  225. package/src/native/foldable/fold-state.ts +10 -0
  226. package/src/native/foldable/types.ts +4 -0
  227. package/src/native/html.ts +193 -0
  228. package/src/native/index.ts +41 -0
  229. package/src/native/legacy-layout.ts +21 -0
  230. package/src/native/overlays/content.ts +51 -0
  231. package/src/native/overlays/controller.ts +349 -0
  232. package/src/native/overlays/dialog-animation.ts +53 -0
  233. package/src/native/overlays/dismiss-animation.ts +15 -0
  234. package/src/native/overlays/focus.ts +64 -0
  235. package/src/native/overlays/popover.ts +120 -0
  236. package/src/native/overlays/styles.ts +181 -0
  237. package/src/native/prehide.ts +285 -0
  238. package/src/native/runtime.ts +988 -0
  239. package/src/native/shared/bridge.ts +18 -0
  240. package/src/native/shared/candidate.ts +162 -0
  241. package/src/native/shared/clipping.ts +24 -0
  242. package/src/native/shared/crossfade.ts +67 -0
  243. package/src/native/shared/diagnostics.ts +13 -0
  244. package/src/native/shared/dom.ts +304 -0
  245. package/src/native/shared/events.ts +14 -0
  246. package/src/native/shared/icons.ts +57 -0
  247. package/src/native/shared/modal.ts +157 -0
  248. package/src/native/shared/projection-id.ts +2 -0
  249. package/src/native/shared/toolbar-options.ts +53 -0
  250. package/src/native/shared/vertical-bars-geometry.ts +80 -0
  251. package/src/native/startup.ts +84 -0
  252. package/src/native/vertical-bars-web-search.ts +86 -0
  253. package/src/native/vertical-bars-web.ts +498 -0
  254. package/src/native-integration/index.ts +49 -0
  255. package/src/vertical-bars.ts +11 -0
@@ -0,0 +1,805 @@
1
+ import XCTest
2
+ import WebKit
3
+ import Capacitor
4
+ @testable import NativeUIPlugin
5
+
6
+ final class NativeUISnapshotTests: XCTestCase {
7
+ func testStandardRendererKindsDoNotRequireIonicNames() throws {
8
+ let pairs: [(String, String, NativeUIComponent)] = [
9
+ ("button", "ion-button", .button), ("button-group", "ion-buttons", .buttons),
10
+ ("back-button", "ion-back-button", .backButton), ("menu-button", "ion-menu-button", .menuButton),
11
+ ("tab-bar", "ion-tab-bar", .tabBar), ("segment", "ion-segment", .segment),
12
+ ("floating-action-button", "ion-fab", .fab)
13
+ ]
14
+ for (standard, legacy, expected) in pairs {
15
+ for name in [standard, legacy] {
16
+ let value = try JSONDecoder().decode(NativeUIComponent.self, from: JSONEncoder().encode(name))
17
+ XCTAssertEqual(value, expected)
18
+ XCTAssertEqual(value.rawValue, standard)
19
+ }
20
+ }
21
+ XCTAssertThrowsError(try JSONDecoder().decode(NativeUIComponent.self, from: JSONEncoder().encode("unknown")))
22
+ }
23
+
24
+ @MainActor func testVerticalBarGeometryIgnoresZeroSizeScaffoldsAndConvertsCoordinates() throws {
25
+ let owner = UIViewController()
26
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 466, height: 678))
27
+ window.rootViewController = owner
28
+ window.makeKeyAndVisible()
29
+ defer { window.isHidden = true }
30
+ let web = UIView(frame: CGRect(x: 20, y: 30, width: 446, height: 648))
31
+ owner.view.addSubview(web)
32
+ let root = UIView(frame: owner.view.bounds)
33
+ owner.view.addSubview(root)
34
+ let scaffold = UIView(frame: .zero)
35
+ root.addSubview(scaffold)
36
+ let surface = UIView(frame: CGRect(x: 100, y: 170, width: 48, height: 48))
37
+ scaffold.addSubview(surface)
38
+ let label = UIView(frame: CGRect(x: 5, y: 6, width: 38, height: 36))
39
+ label.accessibilityIdentifier = "BackButton"
40
+ surface.addSubview(label)
41
+ let frames = NativeUIVerticalBarsGeometry.measure(in: root, relativeTo: web, ids: ["BackButton"])
42
+ XCTAssertEqual(frames["BackButton"], CGRect(x: 80, y: 140, width: 48, height: 48))
43
+ XCTAssertTrue(NativeUIVerticalBarsGeometry.measure(in: root, relativeTo: web, ids: ["missing"]).isEmpty)
44
+ }
45
+
46
+ @MainActor func testVerticalPopoverCanCancelBeforeSwiftUIAppearsAndReopen() throws {
47
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native Vertical Bars") }
48
+ let owner = UIViewController()
49
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 700, height: 900))
50
+ window.rootViewController = owner
51
+ window.makeKeyAndVisible()
52
+ defer { window.isHidden = true }
53
+ let rail = NativeUIVerticalBarsController(activate: { _ in }, changed: { _, _, _, _, _ in 0 })
54
+ rail.attach(to: owner, in: owner.view)
55
+ defer { rail.detach() }
56
+ rail.apply(try decode([control(["placement": "vertical-bars"])]).controls,
57
+ rendering: NativeUIControlRendering(), edge: "right")
58
+ for _ in 0..<3 {
59
+ let host = NativeUIOverlayHost(configuration: WKWebViewConfiguration(), options: nil, event: { _ in })
60
+ var results: [Bool] = []
61
+ XCTAssertTrue(rail.presentPopover(anchor: "action", host: host, completion: { results.append($0) }))
62
+ // Cancel in the same runloop turn, before SwiftUI can call onAppear.
63
+ XCTAssertFalse(host.anchoredVisible)
64
+ host.dismissAnchored?()
65
+ XCTAssertEqual(results, [false])
66
+ XCTAssertFalse(host.anchoredVisible)
67
+ host.dismissAnchored?()
68
+ XCTAssertEqual(results, [false], "Cancellation must settle presentation only once")
69
+ }
70
+ }
71
+
72
+ @MainActor func testVisiblePopoverDismissalNotifiesAllOverlappingCallersOnce() {
73
+ let host = NativeUIOverlayHost(configuration: WKWebViewConfiguration(), options: nil, event: { _ in })
74
+ host.anchoredVisible = true
75
+ var dismissals = 0
76
+ var completed: [Int] = []
77
+ host.dismissAnchored = { dismissals += 1 }
78
+ for caller in 0..<3 {
79
+ host.dismissPopover(animated: false) { completed.append(caller) }
80
+ }
81
+ XCTAssertEqual(dismissals, 1)
82
+ XCTAssertTrue(completed.isEmpty)
83
+ // SwiftUI delivers this callback only after the visible popover disappears.
84
+ host.anchoredVisible = false
85
+ host.completeDismissal()
86
+ XCTAssertEqual(completed, [0, 1, 2])
87
+ host.completeDismissal()
88
+ XCTAssertEqual(completed, [0, 1, 2])
89
+ host.dismissPopover(animated: false) { completed.append(3) }
90
+ XCTAssertEqual(completed, [0, 1, 2, 3])
91
+ }
92
+
93
+ @MainActor func testMissingPopoverDisappearanceCancelsPresentationAndSettlesAllWaiters() {
94
+ let host = NativeUIOverlayHost(configuration: WKWebViewConfiguration(), options: nil, event: { _ in })
95
+ host.anchoredVisible = true
96
+ host.dismissAnchored = {} // No onDisappear callback arrives.
97
+ var cancelled = false
98
+ host.cancelAnchored = { cancelled = true }
99
+ let closed = expectation(description: "Both stop requests finish after cancellation")
100
+ closed.expectedFulfillmentCount = 2
101
+ for _ in 0..<2 {
102
+ host.dismissPopover(animated: false) {
103
+ XCTAssertTrue(cancelled)
104
+ closed.fulfill()
105
+ }
106
+ }
107
+ waitForExpectations(timeout: NativeUIOverlayHost.completionTimeout + 2)
108
+ XCTAssertFalse(host.anchoredVisible)
109
+ }
110
+
111
+ @MainActor func testDetachingRailSettlesPopoverPresentationAndDismissal() throws {
112
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native Vertical Bars") }
113
+ let owner = UIViewController()
114
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 700, height: 900))
115
+ window.rootViewController = owner
116
+ window.makeKeyAndVisible()
117
+ defer { window.isHidden = true }
118
+ let rail = NativeUIVerticalBarsController(activate: { _ in }, changed: { _, _, _, _, _ in 0 })
119
+ rail.attach(to: owner, in: owner.view)
120
+ rail.apply(try decode([control(["placement": "vertical-bars"])]).controls,
121
+ rendering: NativeUIControlRendering(), edge: "right")
122
+ let host = NativeUIOverlayHost(configuration: WKWebViewConfiguration(), options: nil, event: { _ in })
123
+ var presentations: [Bool] = []
124
+ XCTAssertTrue(rail.presentPopover(anchor: "action", host: host) { presentations.append($0) })
125
+ host.anchoredVisible = true
126
+ var dismissals = 0
127
+ host.dismissPopover(animated: false) { dismissals += 1 }
128
+ rail.detach()
129
+ XCTAssertEqual(presentations, [false])
130
+ XCTAssertEqual(dismissals, 1)
131
+ XCTAssertFalse(host.anchoredVisible)
132
+ rail.detach()
133
+ XCTAssertEqual(dismissals, 1)
134
+ }
135
+
136
+ @MainActor func testStoppingPreparedPopoverRestoresWebViewAndAllowsNewSession() {
137
+ let source = WKWebView(frame: .zero)
138
+ let owner = UIViewController()
139
+ final class Delegate: NSObject, WKUIDelegate {}
140
+ let original = Delegate()
141
+ source.uiDelegate = original
142
+ for initiallyAllowed in [false, true] {
143
+ source.configuration.preferences.javaScriptCanOpenWindowsAutomatically = initiallyAllowed
144
+ let previous = NativeUIOverlayController(source: source, owner: owner, event: { _, _ in })
145
+ XCTAssertTrue(previous.prepare("old-context"))
146
+ XCTAssertTrue(source.configuration.preferences.javaScriptCanOpenWindowsAutomatically)
147
+ var stopped = false
148
+ previous.stop { stopped = true }
149
+ XCTAssertTrue(stopped)
150
+ XCTAssertTrue(source.uiDelegate === original)
151
+ XCTAssertEqual(source.configuration.preferences.javaScriptCanOpenWindowsAutomatically, initiallyAllowed)
152
+
153
+ let replacement = NativeUIOverlayController(source: source, owner: owner, event: { _, _ in })
154
+ XCTAssertTrue(replacement.prepare("new-context"))
155
+ replacement.stop {}
156
+ XCTAssertTrue(source.uiDelegate === original)
157
+ XCTAssertEqual(source.configuration.preferences.javaScriptCanOpenWindowsAutomatically, initiallyAllowed)
158
+ }
159
+ }
160
+
161
+ private func item(_ changes: JSObject = [:]) -> JSObject {
162
+ var value: JSObject = ["id": "action", "x": 4.0, "y": 8.0, "width": 44.0, "height": 44.0,
163
+ "label": "Send", "accessibilityLabel": "Send message", "disabled": false, "selected": false,
164
+ "fontSize": 17.0, "fontWeight": 400.0, "color": "rgb(0, 0, 0)"]
165
+ value.merge(changes) { _, new in new }
166
+ return value
167
+ }
168
+
169
+ private func control(_ changes: JSObject = [:]) -> JSObject {
170
+ var value: JSObject = ["id": "control", "kind": "ion-button", "x": 16.0, "y": 60.0,
171
+ "width": 44.0, "height": 44.0, "items": [item()], "dark": false, "rtl": false]
172
+ value.merge(changes) { _, new in new }
173
+ return value
174
+ }
175
+
176
+ private func decode(_ controls: [JSObject], width: Double = 390) throws -> NativeUISnapshot {
177
+ try JSValueDecoder().decode(NativeUISnapshot.self, from: ["revision": 1, "viewportWidth": width, "controls": controls])
178
+ }
179
+
180
+ func testModalRailFrameIsOptionalAndRequiresPositiveBounds() throws {
181
+ var payload: JSObject = ["revision": 1, "viewportWidth": 466.0, "controls": [control()]]
182
+ let page = try JSValueDecoder().decode(NativeUISnapshot.self, from: payload)
183
+ XCTAssertNil(page.verticalBarFrame)
184
+ for height in [339.0, 0.0, -1.0] {
185
+ payload["verticalBarFrame"] = ["x": 0.0, "y": 339.0, "width": 466.0, "height": height]
186
+ let modal = try JSValueDecoder().decode(NativeUISnapshot.self, from: payload)
187
+ XCTAssertEqual(modal.isValid, height > 0)
188
+ XCTAssertEqual(modal.verticalBarFrame?.y, 339)
189
+ }
190
+ }
191
+
192
+ @MainActor func testTabTypographyPreservesCSSWeightsAndSize() throws {
193
+ let weights: [UIFont.Weight] = [.ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .black]
194
+ let tab = UITabBarItem()
195
+ for (index, weight) in weights.enumerated() {
196
+ let cssWeight = Double((index + 1) * 100)
197
+ let content = try decode([control(["items": [item(["fontSize": 19.0, "fontWeight": cssWeight])]])]).controls[0].items[0].content
198
+ NativeUITabBar.applyTypography(content, to: tab)
199
+ for state in [UIControl.State.normal, .selected] {
200
+ let font = try XCTUnwrap(tab.titleTextAttributes(for: state)?[.font] as? UIFont)
201
+ XCTAssertEqual(font.pointSize, 19)
202
+ XCTAssertEqual(font, UIFont.systemFont(ofSize: 19, weight: weight), "CSS weight \(cssWeight)")
203
+ }
204
+ }
205
+ }
206
+
207
+ @MainActor func testVerticalBarsPreserveButtonAppearance() throws {
208
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive controls") }
209
+ let controls = try decode([control(["kind": "ion-buttons", "items": [
210
+ item(["backgroundColor": "rgb(0, 122, 255)"]),
211
+ item(["id": "clear", "buttonFill": "clear"]),
212
+ item(["id": "outline", "borderColor": "rgb(255, 0, 0)", "borderWidth": 2.0])
213
+ ]])]).controls
214
+ XCTAssertEqual(controls.first?.items.first?.content.backgroundColor, "rgb(0, 122, 255)")
215
+ let model = NativeUIVerticalBarsModel()
216
+ model.apply(controls, rendering: NativeUIControlRendering())
217
+ XCTAssertNotNil(model.groups.first?.items.first?.background)
218
+ XCTAssertEqual(model.groups.first?.items.map(\.clear), [false, true, false])
219
+ XCTAssertNotNil(model.groups.first?.items.last?.borderColor)
220
+ XCTAssertEqual(model.groups.first?.items.last?.borderWidth, 2)
221
+ model.apply(try decode([control(["kind": "ion-button"])]).controls, rendering: NativeUIControlRendering())
222
+ XCTAssertNil(model.groups.first?.items.first?.background)
223
+ XCTAssertEqual(model.groups.first?.items.first?.clear, false)
224
+ XCTAssertNil(model.groups.first?.items.first?.borderColor)
225
+ XCTAssertEqual(model.groups.first?.items.first?.borderWidth, 0)
226
+ }
227
+
228
+ @MainActor func testVerticalBarsTabOptimismWaitsForWebAndRollsBackWhenStale() throws {
229
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive tabs") }
230
+ func tabs(_ selected: String, includeRight: Bool = true) throws -> [NativeUIControl] {
231
+ var items = [item(["id": "left", "selected": selected == "left"])]
232
+ if includeRight {
233
+ items.append(item(["id": "right", "selected": selected == "right",
234
+ "badge": ["value": "3", "color": "rgb(255, 0, 0)", "textColor": "rgb(255, 255, 255)"]]))
235
+ }
236
+ return try decode([control(["kind": "ion-tab-bar", "items": items])]).controls
237
+ }
238
+ let model = NativeUIVerticalBarsModel()
239
+ let rendering = NativeUIControlRendering()
240
+ model.apply(try tabs("left"), rendering: rendering, now: 100)
241
+ XCTAssertEqual(model.tabs.last?.badge?.value, "3")
242
+ model.select("right", now: 100, ttl: 10)
243
+ model.apply(try tabs("left"), rendering: rendering, now: 101)
244
+ XCTAssertEqual(model.selection, "right", "a stale Web echo must not undo the optimistic selection")
245
+ model.apply(try tabs("right"), rendering: rendering, now: 102)
246
+ XCTAssertEqual(model.selection, "right", "the matching Web echo confirms the selection")
247
+
248
+ model.apply(try tabs("left"), rendering: rendering, now: 200)
249
+ model.select("right", now: 200, ttl: 10)
250
+ model.apply(try tabs("left"), rendering: rendering, now: 211)
251
+ XCTAssertEqual(model.selection, "left", "an expired selection rolls back to Web state")
252
+ model.select("right", now: 220, ttl: 10)
253
+ model.apply(try tabs("left", includeRight: false), rendering: rendering, now: 221)
254
+ XCTAssertEqual(model.selection, "left", "a removed target rolls back immediately")
255
+ }
256
+
257
+ @MainActor func testSegmentSelectionEchoPreservesNativeViewsAndActionsUseUpdatedItems() throws {
258
+ guard #available(iOS 26.0, *) else { return }
259
+ func node(_ items: [JSObject]) throws -> NativeUIControl {
260
+ try XCTUnwrap(decode([control(["kind": "ion-segment", "width": 160.0, "items": items])]).controls.first)
261
+ }
262
+ let first = try node([item(["id": "left", "selected": true]), item(["id": "right"])])
263
+ var activated: [String] = []
264
+ let segment = try XCTUnwrap(NativeUISegment.make(first, scale: 1, rendering: NativeUIControlRendering(),
265
+ activate: { activated.append($0) }) as? NativeUISegment)
266
+ segment.frame = CGRect(x: 0, y: 0, width: 160, height: 48)
267
+ segment.layoutIfNeeded()
268
+ XCTAssertEqual(segment.widthForSegment(at: 0) + segment.widthForSegment(at: 1), 160)
269
+ segment.selectedSegmentIndex = 1
270
+ segment.layoutIfNeeded()
271
+ let children = segment.subviews
272
+ let animation = CABasicAnimation(keyPath: "opacity")
273
+ animation.fromValue = 0.5
274
+ animation.toValue = 1
275
+ animation.duration = 1
276
+ segment.layer.add(animation, forKey: "selection-in-flight")
277
+ let echo = try node([item(["id": "left"]), item(["id": "right", "selected": true])])
278
+ segment.update(echo, scale: 1, rendering: NativeUIControlRendering())
279
+ segment.layoutIfNeeded()
280
+ XCTAssertEqual(segment.selectedSegmentIndex, 1)
281
+ XCTAssertEqual(segment.subviews, children)
282
+ XCTAssertNotNil(segment.layer.animation(forKey: "selection-in-flight"))
283
+ segment.sendActions(for: .valueChanged)
284
+ XCTAssertEqual(activated, ["right"])
285
+
286
+ // Web can reject a selection, clear it, or replace the available actions.
287
+ segment.update(first, scale: 1, rendering: NativeUIControlRendering())
288
+ XCTAssertEqual(segment.selectedSegmentIndex, 0)
289
+ let replaced = try node([item(["id": "replacement", "label": "Updated", "disabled": true])])
290
+ segment.update(replaced, scale: 2, rendering: NativeUIControlRendering())
291
+ XCTAssertEqual(segment.numberOfSegments, 1)
292
+ XCTAssertEqual(segment.selectedSegmentIndex, UISegmentedControl.noSegment)
293
+ XCTAssertEqual(segment.titleForSegment(at: 0), "Updated")
294
+ XCTAssertFalse(segment.isEnabledForSegment(at: 0))
295
+ XCTAssertEqual(segment.widthForSegment(at: 0), 320)
296
+ segment.selectedSegmentIndex = 0
297
+ segment.sendActions(for: .valueChanged)
298
+ XCTAssertEqual(activated, ["right", "replacement"])
299
+ }
300
+
301
+ func testFlatWireFormatAndOptionalFields() throws {
302
+ let snapshot = try decode([control()])
303
+ XCTAssertTrue(snapshot.isValid)
304
+ let node = try XCTUnwrap(snapshot.controls.first)
305
+ XCTAssertEqual(node.kind, .button)
306
+ XCTAssertEqual(node.frame.rect, CGRect(x: 16, y: 60, width: 44, height: 44))
307
+ let action = try XCTUnwrap(node.items.first)
308
+ XCTAssertEqual(action.content.accessibilityLabel, "Send message")
309
+ XCTAssertNil(action.content.icon)
310
+ XCTAssertNil(action.visible)
311
+ XCTAssertNil(node.search)
312
+ XCTAssertTrue(try decode([]).isValid)
313
+ }
314
+
315
+ func testRequiredFieldsAndEnumsRejectMalformedInput() throws {
316
+ var missing = item()
317
+ missing.removeValue(forKey: "label")
318
+ for malformed in [missing, item(["disabled": "false"]), item(["iconPosition": "bottom"])] {
319
+ XCTAssertThrowsError(try decode([control(["items": [malformed]])]))
320
+ }
321
+ XCTAssertThrowsError(try decode([control(["kind": "ion-input"])]))
322
+ }
323
+
324
+ func testGeometryRejectsNonFiniteAndNonPositiveSizes() throws {
325
+ for width in [0, -1, Double.infinity, Double.nan] {
326
+ XCTAssertFalse(try decode([control(["width": width])]).isValid)
327
+ XCTAssertFalse(try decode([control(["items": [item(["width": width])]])]).isValid)
328
+ XCTAssertFalse(try decode([], width: width).isValid)
329
+ }
330
+ XCTAssertFalse(try decode([control(["x": Double.infinity])]).isValid)
331
+ XCTAssertTrue(try decode([control(["x": -20.0, "y": -10.0])]).isValid)
332
+ XCTAssertFalse(try decode([control(["items": [item(["iconWidth": Double.nan])]])]).isValid)
333
+ }
334
+
335
+ func testBatchLimitsAndDuplicateIdentifiers() throws {
336
+ let controls = (0..<100).map { control(["id": "control-\($0)"]) }
337
+ XCTAssertTrue(try decode(controls).isValid)
338
+ XCTAssertFalse(try decode(controls + [control()]).isValid)
339
+ XCTAssertFalse(try decode([control(), control()]).isValid)
340
+ let items = (0..<30).map { item(["id": "item-\($0)"]) }
341
+ XCTAssertTrue(try decode([control(["kind": "ion-segment", "items": items])]).isValid)
342
+ XCTAssertFalse(try decode([control(["kind": "ion-segment", "items": items + [item()]])]).isValid)
343
+ XCTAssertFalse(try decode([control(["kind": "ion-segment", "items": [item(), item()]])]).isValid)
344
+ XCTAssertFalse(try decode([control(), control(["id": "invalid", "height": 0.0])]).isValid)
345
+ }
346
+
347
+ func testSearchUsesContentForFieldAndGeometryForTrigger() throws {
348
+ // The hidden Web search field may have no layout. Its content is still projected.
349
+ var search: JSObject = ["id": "search", "field": item(["width": 0.0, "height": 0.0]),
350
+ "trigger": item(), "closeId": "close", "active": false, "available": true,
351
+ "focused": false, "value": "あ", "placeholder": "Search", "disabled": false,
352
+ "editSequence": 2, "valueVersion": 1]
353
+ let snapshot = try decode([control(["kind": "ion-tab-bar", "search": search])])
354
+ XCTAssertTrue(snapshot.isValid)
355
+ XCTAssertEqual(snapshot.controls.first?.search?.value, "あ")
356
+ XCTAssertFalse(try decode([control(["search": search])]).isValid)
357
+ search["trigger"] = item(["width": 0.0])
358
+ XCTAssertFalse(try decode([control(["kind": "ion-tab-bar", "search": search])]).isValid)
359
+ }
360
+
361
+ func testTabAnchorsKeepOnlySupportedPlacements() throws {
362
+ for x in [0.0, 0.5, 1.0] {
363
+ for y in [0.0, 1.0] {
364
+ XCTAssertTrue(try decode([control(["kind": "ion-tab-bar", "tabBarAnchor": ["x": x, "y": y]])]).isValid)
365
+ }
366
+ }
367
+ XCTAssertFalse(try decode([control(["kind": "ion-tab-bar", "tabBarAnchor": ["x": 0.2, "y": 0.0]])]).isValid)
368
+ }
369
+
370
+ func testFabMovementAndVisibilityDoNotInvalidateArtwork() throws {
371
+ let original = try JSValueDecoder().decode(NativeUIItem.self, from: item(["visible": true]))
372
+ let moved = try JSValueDecoder().decode(NativeUIItem.self, from: item(["x": 20.0, "visible": false]))
373
+ XCTAssertNotEqual(original, moved)
374
+ XCTAssertEqual(original.content.fabArtwork, moved.content.fabArtwork)
375
+ let opened = try JSValueDecoder().decode(NativeUIItem.self, from: item([
376
+ "selected": true, "icon": "normal", "closeIcon": "close", "closeIconWidth": 20.0, "closeIconHeight": 20.0]))
377
+ XCTAssertEqual(opened.content.fabArtwork.icon, "close")
378
+ XCTAssertEqual(opened.content.fabArtwork.iconWidth, 20)
379
+ XCTAssertEqual(opened.content.fabArtwork.label, "")
380
+ XCTAssertNotEqual(original.content.fabArtwork, opened.content.fabArtwork)
381
+ }
382
+
383
+ @MainActor
384
+ func testTabVariantsAndBadgeUpdatesKeepItemIdentity() throws {
385
+ let rendering = NativeUIControlRendering()
386
+ let image = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { context in
387
+ UIColor.black.setFill()
388
+ context.fill(CGRect(x: 0, y: 0, width: 24, height: 24))
389
+ }.pngData()!.base64EncodedString()
390
+ let dot: JSObject = ["value": "", "color": "rgb(235, 68, 90)", "textColor": "rgb(255, 255, 255)"]
391
+ let number: JSObject = ["value": "47", "color": "rgb(0, 102, 255)", "textColor": "rgb(255, 255, 255)"]
392
+ func node(_ badges: Bool) throws -> NativeUIControl {
393
+ let items = [
394
+ item(["id": "icon", "label": "", "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
395
+ item(["id": "label", "label": "Music"]),
396
+ item(["id": "dot", "badge": badges ? dot : NSNull()]),
397
+ item(["id": "number", "badge": badges ? number : NSNull()])
398
+ ]
399
+ return try decode([control(["kind": "ion-tab-bar", "items": items])]).controls[0]
400
+ }
401
+ let bar = UITabBar()
402
+ NativeUITabBar.update(bar, node: try node(true), rendering: rendering)
403
+ let items = try XCTUnwrap(bar.items)
404
+ XCTAssertEqual(items[0].title, "")
405
+ XCTAssertNotNil(items[0].image)
406
+ XCTAssertEqual(items[1].title, "Music")
407
+ XCTAssertNil(items[1].image)
408
+ XCTAssertEqual(items[2].badgeValue, "")
409
+ XCTAssertEqual(items[3].badgeValue, "47")
410
+ XCTAssertEqual(items[2].badgeColor, rendering.color("rgb(235, 68, 90)"))
411
+ XCTAssertEqual(items[3].badgeColor, rendering.color("rgb(0, 102, 255)"))
412
+ NativeUITabBar.update(bar, node: try node(false), rendering: rendering)
413
+ for (before, after) in zip(items, bar.items!) {
414
+ XCTAssertTrue(before === after)
415
+ XCTAssertNil(after.badgeValue)
416
+ XCTAssertNil(after.badgeColor)
417
+ }
418
+ }
419
+
420
+
421
+ @MainActor
422
+ func testSearchTabsPreserveAccessibilityAndBadgeAppearance() throws {
423
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
424
+ let badge: JSObject = ["value": "47", "color": "rgb(235, 68, 90)", "textColor": "rgb(255, 255, 255)"]
425
+ let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
426
+ "closeId": "close", "active": false, "available": true, "focused": false,
427
+ "value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
428
+ let node = try decode([control(["kind": "ion-tab-bar", "width": 300.0, "search": search,
429
+ "items": [item(["id": "first", "label": "", "accessibilityLabel": "Favorites", "badge": badge]),
430
+ item(["id": "second", "label": "Music"]) ]])]).controls[0]
431
+ let controller = NativeUISearchController()
432
+ let rendering = NativeUIControlRendering()
433
+ _ = controller.apply(node, webFrame: CGRect(x: 0, y: 0, width: 390, height: 844),
434
+ barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
435
+ triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
436
+ let tab = try XCTUnwrap(controller.tabBar.items?.first)
437
+ XCTAssertEqual(tab.accessibilityLabel, "Favorites")
438
+ XCTAssertEqual(tab.badgeValue, "47")
439
+ XCTAssertEqual(tab.accessibilityValue, "47")
440
+ XCTAssertEqual(tab.badgeColor, rendering.color("rgb(235, 68, 90)"))
441
+ XCTAssertEqual(tab.badgeTextAttributes(for: .normal)?[.foregroundColor] as? UIColor, rendering.color("rgb(255, 255, 255)"))
442
+ }
443
+
444
+
445
+ @MainActor
446
+ func testReplacingSearchResetsEditingSequenceWithoutOverwritingCurrentInput() throws {
447
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
448
+ let controller = NativeUISearchController()
449
+ let rendering = NativeUIControlRendering()
450
+ func apply(_ id: String, value: String) throws {
451
+ let search: JSObject = ["id": id, "field": item(), "trigger": item(["id": "trigger"]),
452
+ "closeId": "close", "active": true, "available": true, "focused": false,
453
+ "value": value, "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
454
+ let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
455
+ "items": [item(["id": "first", "selected": true]), item(["id": "second"]) ]])]).controls.first)
456
+ XCTAssertTrue(controller.apply(node, webFrame: CGRect(x: 0, y: 0, width: 390, height: 844),
457
+ barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
458
+ triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
459
+ }
460
+ try apply("old-search", value: "initial")
461
+ let navigation = try XCTUnwrap(controller.tabs.last?.viewController as? UINavigationController)
462
+ let searchBar = try XCTUnwrap(navigation.topViewController?.navigationItem.searchController?.searchBar)
463
+ searchBar.text = "native edit"
464
+ controller.changed = { _, _, _, _, _ in 7 }
465
+ controller.searchBar(searchBar, textDidChange: "native edit")
466
+ try apply("old-search", value: "stale")
467
+ XCTAssertEqual(searchBar.text, "native edit")
468
+ try apply("new-search", value: "replacement initial")
469
+ XCTAssertEqual(searchBar.text, "replacement initial")
470
+ }
471
+
472
+ @MainActor
473
+ func testSingleButtonsRejectEmptyAndMultipleItems() throws {
474
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native glass buttons") }
475
+ for kind in ["ion-button", "ion-back-button", "ion-menu-button"] {
476
+ for items in [[JSObject](), [item(), item(["id": "second"])]] {
477
+ let snapshot = try decode([control(["kind": kind, "items": items])])
478
+ XCTAssertFalse(snapshot.isValid)
479
+ let node = try XCTUnwrap(snapshot.controls.first)
480
+ XCTAssertNil(NativeUIButton.make(node, rendering: NativeUIControlRendering(), activate: { _ in
481
+ XCTFail("Invalid input must not create an action")
482
+ }))
483
+ }
484
+ let snapshot = try decode([control(["kind": kind])])
485
+ XCTAssertTrue(snapshot.isValid)
486
+ let node = try XCTUnwrap(snapshot.controls.first)
487
+ var activated: String?
488
+ let button = try XCTUnwrap(NativeUIButton.make(node, rendering: NativeUIControlRendering(), activate: { activated = $0 }))
489
+ button.sendActions(for: .touchUpInside)
490
+ XCTAssertEqual(activated, "action")
491
+ }
492
+ let emptySegment = try decode([control(["kind": "ion-segment", "items": [JSObject]()])])
493
+ XCTAssertFalse(emptySegment.isValid)
494
+ XCTAssertNil(NativeUISegment.make(try XCTUnwrap(emptySegment.controls.first), scale: 1,
495
+ rendering: NativeUIControlRendering(), activate: { _ in XCTFail("Empty segment") }))
496
+ }
497
+
498
+
499
+ @MainActor
500
+ func testTabSelectionWithEmptySingleAndReorderedItems() throws {
501
+ let first = item(["id": "first"])
502
+ let selected = item(["id": "selected", "selected": true])
503
+ let bar = UITabBar()
504
+ for items in [[first, selected], [selected, first], [first], []] {
505
+ let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": items])]).controls.first)
506
+ NativeUITabBar.update(bar, node: node, rendering: NativeUIControlRendering())
507
+ XCTAssertEqual(bar.items?.count ?? 0, items.count)
508
+ XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, items.count == 2 ? "selected" : nil)
509
+ }
510
+ }
511
+
512
+ @MainActor
513
+ func testTabTitleLayoutWarmupRestoresSelection() throws {
514
+ let image = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { context in
515
+ UIColor.black.setFill()
516
+ context.fill(CGRect(x: 0, y: 0, width: 24, height: 24))
517
+ }.pngData()!.base64EncodedString()
518
+ let items = [
519
+ item(["id": "first", "label": "Index", "selected": true, "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
520
+ item(["id": "second", "label": "Docs", "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
521
+ item(["id": "third", "label": "Library", "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
522
+ ]
523
+ let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "width": 280.0, "height": 62.0, "items": items])]).controls.first)
524
+ let bar = UITabBar()
525
+ NativeUITabBar.update(bar, node: node, rendering: NativeUIControlRendering())
526
+ XCTAssertTrue(NativeUITabBar.fit(bar, node: node, bounds: CGRect(x: 20, y: 700, width: 280, height: 62)))
527
+ XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
528
+ // A second fit without content changes must not leave a pending warmup.
529
+ XCTAssertTrue(NativeUITabBar.fit(bar, node: node, bounds: CGRect(x: 20, y: 700, width: 280, height: 62)))
530
+ XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
531
+ }
532
+
533
+ @MainActor
534
+ func testTabPendingSelectionIgnoresStaleDomEcho() throws {
535
+ let first = item(["id": "first", "selected": true])
536
+ let second = item(["id": "second"])
537
+ let bar = UITabBar()
538
+ let initial = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [first, second]])]).controls.first)
539
+ NativeUITabBar.update(bar, node: initial, rendering: NativeUIControlRendering())
540
+ XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
541
+
542
+ var pending: NativeUITabBar.PendingSelection? = .start("second")
543
+ // Stale Web echo still reports the previous tab.
544
+ NativeUITabBar.update(bar, node: initial, rendering: NativeUIControlRendering(), pendingSelection: &pending)
545
+ XCTAssertEqual(pending?.id, "second")
546
+ XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "second")
547
+
548
+ let echoed = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [
549
+ item(["id": "first"]), item(["id": "second", "selected": true]),
550
+ ]])]).controls.first)
551
+ NativeUITabBar.update(bar, node: echoed, rendering: NativeUIControlRendering(), pendingSelection: &pending)
552
+ XCTAssertNil(pending)
553
+ XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "second")
554
+ }
555
+
556
+ @MainActor
557
+ func testTabPendingSelectionFallsBackWhenDomRejects() throws {
558
+ let first = item(["id": "first", "selected": true])
559
+ let second = item(["id": "second"])
560
+ let bar = UITabBar()
561
+ let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [first, second]])]).controls.first)
562
+ NativeUITabBar.update(bar, node: node, rendering: NativeUIControlRendering())
563
+ bar.selectedItem = bar.items?.first { $0.accessibilityIdentifier == "second" }
564
+
565
+ var pending: NativeUITabBar.PendingSelection? = NativeUITabBar.PendingSelection(id: "second", until: CFAbsoluteTimeGetCurrent() - 1)
566
+ NativeUITabBar.update(bar, node: node, rendering: NativeUIControlRendering(), pendingSelection: &pending)
567
+ XCTAssertNil(pending)
568
+ XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
569
+
570
+ pending = .start("second")
571
+ let disabled = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [
572
+ item(["id": "first", "selected": true]), item(["id": "second", "disabled": true]),
573
+ ]])]).controls.first)
574
+ bar.selectedItem = bar.items?.first { $0.accessibilityIdentifier == "second" }
575
+ NativeUITabBar.update(bar, node: disabled, rendering: NativeUIControlRendering(), pendingSelection: &pending)
576
+ XCTAssertNil(pending)
577
+ XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
578
+ }
579
+
580
+ @MainActor
581
+ func testSearchOptimisticTabSelectionIgnoresStaleDomEcho() throws {
582
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
583
+ let controller = NativeUISearchController()
584
+ let rendering = NativeUIControlRendering()
585
+ let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
586
+ let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
587
+ let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
588
+ func node(selected: String) throws -> NativeUIControl {
589
+ let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
590
+ "closeId": "close", "active": false, "available": true, "focused": false,
591
+ "value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
592
+ return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
593
+ "items": [item(["id": "first", "selected": selected == "first"]),
594
+ item(["id": "second", "selected": selected == "second"])]])]).controls.first)
595
+ }
596
+ _ = controller.apply(try node(selected: "first"), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
597
+ let second = try XCTUnwrap(controller.tabs.first { $0.identifier == "second" })
598
+ var activated: [String] = []
599
+ controller.activate = { activated.append($0) }
600
+ XCTAssertTrue(controller.tabBarController(controller, shouldSelectTab: second))
601
+ XCTAssertEqual(activated, ["second"])
602
+ XCTAssertEqual(controller.selectedTab?.identifier, "second")
603
+ // Stale Web echo still reports the previous tab.
604
+ _ = controller.apply(try node(selected: "first"), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
605
+ XCTAssertEqual(controller.selectedTab?.identifier, "second")
606
+ // Web catches up — clear pending without bouncing selection.
607
+ _ = controller.apply(try node(selected: "second"), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
608
+ XCTAssertEqual(controller.selectedTab?.identifier, "second")
609
+ }
610
+
611
+ @MainActor
612
+ func testSearchLocksProjectionWhileActiveAndKeepsApplicationValue() throws {
613
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
614
+ let controller = NativeUISearchController()
615
+ let rendering = NativeUIControlRendering()
616
+ let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
617
+ let shrunk = CGRect(x: 0, y: 0, width: 390, height: 500)
618
+ func node(active: Bool, focused: Bool, value: String = "", valueVersion: Int = 0) throws -> NativeUIControl {
619
+ let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
620
+ "closeId": "close", "active": active, "available": true, "focused": focused,
621
+ "value": value, "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": valueVersion]
622
+ return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
623
+ "items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
624
+ }
625
+ // Resting layout may reject without a full window hierarchy; surface is still assigned.
626
+ _ = controller.apply(try node(active: false, focused: false), webFrame: resting,
627
+ barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
628
+ triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
629
+ controller.surface.frame = resting
630
+ // Active (even before focus) freezes Web layout for the whole search session.
631
+ XCTAssertTrue(controller.apply(try node(active: true, focused: false), webFrame: resting,
632
+ barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
633
+ triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
634
+ XCTAssertEqual(controller.surface.frame, resting)
635
+ XCTAssertTrue(controller.apply(try node(active: true, focused: false), webFrame: shrunk,
636
+ barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
637
+ triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
638
+ XCTAssertEqual(controller.surface.frame, resting)
639
+ // Application realtime value updates still cross the lock.
640
+ XCTAssertTrue(controller.apply(try node(active: true, focused: true, value: "external", valueVersion: 2), webFrame: shrunk,
641
+ barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
642
+ triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
643
+ XCTAssertEqual(controller.surface.frame, resting)
644
+ let navigation = try XCTUnwrap(controller.tabs.last?.viewController as? UINavigationController)
645
+ let searchBar = try XCTUnwrap(navigation.topViewController?.navigationItem.searchController?.searchBar)
646
+ XCTAssertEqual(searchBar.text, "external")
647
+ // Blur request returns to presented (active, not focused) without unlocking projection.
648
+ XCTAssertTrue(controller.apply(try node(active: true, focused: false, value: "external", valueVersion: 2), webFrame: shrunk,
649
+ barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
650
+ triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
651
+ XCTAssertEqual(controller.surface.frame, resting)
652
+ XCTAssertFalse(searchBar.searchTextField.isFirstResponder)
653
+ let tabsBeforeLeave = controller.tabs.map(\.identifier)
654
+ // Leave re-fits; without a window hierarchy fit may reject while still restoring selection.
655
+ _ = controller.apply(try node(active: false, focused: false, value: "external", valueVersion: 2), webFrame: resting,
656
+ barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
657
+ triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
658
+ XCTAssertEqual(controller.surface.frame, resting)
659
+ // Leave must reassign tabs even when identifiers match (UISearchTab morph cleanup).
660
+ XCTAssertEqual(controller.tabs.map(\.identifier), tabsBeforeLeave)
661
+ XCTAssertEqual(controller.tabs.count, 3) // first, second, search
662
+ XCTAssertEqual(controller.selectedTab?.identifier, "first")
663
+ // Switching ordinary tabs after leave must keep resting chrome (no deferred morph expand).
664
+ func nodeSelecting(_ id: String) throws -> NativeUIControl {
665
+ let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
666
+ "closeId": "close", "active": false, "available": true, "focused": false,
667
+ "value": "external", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 2]
668
+ return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
669
+ "items": [item(["id": "first", "selected": id == "first"]),
670
+ item(["id": "second", "selected": id == "second"])]])]).controls.first)
671
+ }
672
+ let tabsAfterLeave = controller.tabs.map(\.identifier)
673
+ _ = controller.apply(try nodeSelecting("second"), webFrame: resting,
674
+ barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
675
+ triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
676
+ XCTAssertEqual(controller.selectedTab?.identifier, "second")
677
+ XCTAssertEqual(controller.tabs.map(\.identifier), tabsAfterLeave)
678
+ _ = controller.apply(try nodeSelecting("first"), webFrame: resting,
679
+ barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
680
+ triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
681
+ XCTAssertEqual(controller.selectedTab?.identifier, "first")
682
+ XCTAssertEqual(controller.tabs.map(\.identifier), tabsAfterLeave)
683
+ }
684
+
685
+ @MainActor
686
+ func testSearchRelocksProjectionWidthAfterRotation() throws {
687
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
688
+ let controller = NativeUISearchController()
689
+ let rendering = NativeUIControlRendering()
690
+ let portrait = CGRect(x: 0, y: 0, width: 390, height: 844)
691
+ let landscape = CGRect(x: 0, y: 0, width: 844, height: 390)
692
+ let keyboardShrunk = CGRect(x: 0, y: 0, width: 844, height: 200)
693
+ func node(active: Bool) throws -> NativeUIControl {
694
+ let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
695
+ "closeId": "close", "active": active, "available": true, "focused": false,
696
+ "value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
697
+ return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
698
+ "items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
699
+ }
700
+ let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
701
+ let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
702
+ _ = controller.apply(try node(active: false), webFrame: portrait, barFrame: bar, triggerFrame: trigger, rendering: rendering)
703
+ controller.surface.frame = portrait
704
+ XCTAssertTrue(controller.apply(try node(active: true), webFrame: portrait, barFrame: bar, triggerFrame: trigger, rendering: rendering))
705
+ XCTAssertEqual(controller.surface.frame, portrait)
706
+ // Rotation must adopt the new width instead of keeping the portrait lock.
707
+ XCTAssertTrue(controller.apply(try node(active: true), webFrame: landscape, barFrame: bar, triggerFrame: trigger, rendering: rendering))
708
+ XCTAssertEqual(controller.surface.frame, landscape)
709
+ // Keyboard shrink at the same width must still be ignored.
710
+ XCTAssertTrue(controller.apply(try node(active: true), webFrame: keyboardShrunk, barFrame: bar, triggerFrame: trigger, rendering: rendering))
711
+ XCTAssertEqual(controller.surface.frame, landscape)
712
+ }
713
+
714
+ @MainActor
715
+ func testSearchPendingSelectionExpiresWithoutLaterApply() throws {
716
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
717
+ let controller = NativeUISearchController()
718
+ let rendering = NativeUIControlRendering()
719
+ let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
720
+ let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
721
+ let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
722
+ let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
723
+ "closeId": "close", "active": false, "available": true, "focused": false,
724
+ "value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
725
+ let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
726
+ "items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
727
+ _ = controller.apply(node, webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
728
+ let second = try XCTUnwrap(controller.tabs.first { $0.identifier == "second" })
729
+ XCTAssertTrue(controller.tabBarController(controller, shouldSelectTab: second))
730
+ XCTAssertEqual(controller.selectedTab?.identifier, "second")
731
+ let expectation = expectation(description: "pending selection expires back to DOM")
732
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.15) {
733
+ XCTAssertEqual(controller.selectedTab?.identifier, "first")
734
+ expectation.fulfill()
735
+ }
736
+ wait(for: [expectation], timeout: 2)
737
+ }
738
+
739
+ @MainActor
740
+ func testSearchEmitsInputAfterRestingToActive() throws {
741
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
742
+ let controller = NativeUISearchController()
743
+ let rendering = NativeUIControlRendering()
744
+ let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
745
+ let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
746
+ let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
747
+ func node(active: Bool) throws -> NativeUIControl {
748
+ let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
749
+ "closeId": "close", "active": active, "available": true, "focused": active,
750
+ "value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
751
+ return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
752
+ "items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
753
+ }
754
+ // Resting must not permanently close the editing bridge.
755
+ _ = controller.apply(try node(active: false), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
756
+ var phases: [String] = []
757
+ controller.changed = { _, phase, _, _, _ in
758
+ phases.append(phase.rawValue)
759
+ return phases.count
760
+ }
761
+ XCTAssertTrue(controller.apply(try node(active: true), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering))
762
+ let navigation = try XCTUnwrap(controller.tabs.last?.viewController as? UINavigationController)
763
+ let searchBar = try XCTUnwrap(navigation.topViewController?.navigationItem.searchController?.searchBar)
764
+ controller.searchBar(searchBar, textDidChange: "query")
765
+ XCTAssertEqual(phases, ["input"])
766
+ }
767
+
768
+ func testVerticalSearchBridgesInputFocusAndDismissalWithoutStaleWebEchoes() throws {
769
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native glass search") }
770
+ func node(active: Bool, value: String = "", version: Int = 0) throws -> NativeUIControl {
771
+ let search: JSObject = ["id": "search", "field": item(),
772
+ "trigger": item(["id": "trigger"]), "closeId": "close", "active": active,
773
+ "available": true, "focused": false, "value": value, "placeholder": "Search",
774
+ "disabled": false, "editSequence": 0, "valueVersion": version]
775
+ return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "placement": "vertical-bars",
776
+ "search": search, "items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
777
+ }
778
+ var actions: [String] = []
779
+ var values: [String] = []
780
+ let rail = UIView()
781
+ let model = NativeUIVerticalSearchModel(try XCTUnwrap(node(active: false).search), in: rail,
782
+ activate: { actions.append($0) },
783
+ changed: { _, _, value, _, _ in values.append(value); return values.count })
784
+ model.present(true)
785
+ XCTAssertEqual(actions, ["trigger"])
786
+ model.apply(try XCTUnwrap(node(active: true).search))
787
+ XCTAssertTrue(model.presented)
788
+ model.input("native query")
789
+ XCTAssertEqual(values, ["native query"])
790
+ model.apply(try XCTUnwrap(node(active: true).search))
791
+ XCTAssertEqual(model.text, "native query")
792
+ // The first presentation acknowledgement must not undo native focus.
793
+ model.focus(true)
794
+ model.apply(try XCTUnwrap(node(active: true).search))
795
+ XCTAssertTrue(model.focused)
796
+ model.apply(try XCTUnwrap(node(active: true, value: "application", version: 1).search))
797
+ XCTAssertEqual(model.text, "application")
798
+ model.present(false)
799
+ XCTAssertEqual(actions, ["trigger", "close"])
800
+ model.apply(try XCTUnwrap(node(active: false).search))
801
+ XCTAssertFalse(model.presented)
802
+ XCTAssertFalse(model.focused)
803
+ }
804
+
805
+ }