@rdlabo/ionic-theme-ios27 1.2.1 → 2.0.0-2

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