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