@rdlabo/ionic-theme-ios27 0.2.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -126
- package/dist/native/components/searchable-tabs.d.ts +2 -0
- package/dist/native/components/searchable-tabs.d.ts.map +1 -1
- package/dist/native/components/searchable-tabs.js +131 -21
- package/dist/native/runtime.d.ts.map +1 -1
- package/dist/native/runtime.js +14 -7
- package/docs/migration.md +105 -3
- package/docs/native-ui-shell.md +4 -3
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +243 -64
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabBar.swift +77 -6
- package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +79 -12
- package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +258 -2
- package/package.json +1 -1
- package/src/native/components/searchable-tabs.ts +142 -22
- package/src/native/runtime.ts +11 -6
- package/docs/ios-adaptive.md +0 -90
|
@@ -7,7 +7,18 @@ enum ShellTabBar {
|
|
|
7
7
|
var isValid: Bool { [0, 0.5, 1].contains(x) && [0, 1].contains(y) }
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/// Optimistic tab selection until the Web selected state catches up (or times out).
|
|
11
|
+
struct PendingSelection: Equatable {
|
|
12
|
+
var id: String
|
|
13
|
+
var until: CFAbsoluteTime
|
|
14
|
+
static func start(_ id: String, ttl: CFTimeInterval = 1) -> PendingSelection {
|
|
15
|
+
PendingSelection(id: id, until: CFAbsoluteTimeGetCurrent() + ttl)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
static let kind = ShellComponent.tabBar
|
|
20
|
+
/// Tab bars waiting for an iOS 26 title-layout warmup after the next `fit`.
|
|
21
|
+
private static var pendingTitleWarmup = Set<ObjectIdentifier>()
|
|
11
22
|
|
|
12
23
|
static func make(_ node: ShellControl, rendering: ShellRendering, delegate: UITabBarDelegate) -> UITabBar {
|
|
13
24
|
let control = UITabBar()
|
|
@@ -31,8 +42,15 @@ enum ShellTabBar {
|
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
static func update(_ tabBar: UITabBar, node: ShellControl, rendering: ShellRendering) {
|
|
45
|
+
var pending: PendingSelection?
|
|
46
|
+
update(tabBar, node: node, rendering: rendering, pendingSelection: &pending)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static func update(_ tabBar: UITabBar, node: ShellControl, rendering: ShellRendering,
|
|
50
|
+
pendingSelection: inout PendingSelection?) {
|
|
34
51
|
let items = node.items
|
|
35
52
|
let ids = items.map(\.id)
|
|
53
|
+
var needsTitleWarmup = false
|
|
36
54
|
// Keep UIKit's item identities and interaction state across DOM selection updates.
|
|
37
55
|
if tabBar.items?.map({ $0.accessibilityIdentifier ?? "" }) != ids {
|
|
38
56
|
tabBar.items = items.map { item in
|
|
@@ -40,26 +58,54 @@ enum ShellTabBar {
|
|
|
40
58
|
tab.accessibilityIdentifier = item.id
|
|
41
59
|
return tab
|
|
42
60
|
}
|
|
61
|
+
needsTitleWarmup = true
|
|
43
62
|
}
|
|
44
63
|
let nativeItems = tabBar.items ?? []
|
|
45
64
|
for (tab, item) in zip(nativeItems, items) {
|
|
46
65
|
let title = item.content.label
|
|
47
|
-
if tab.title != title {
|
|
66
|
+
if tab.title != title {
|
|
67
|
+
tab.title = title
|
|
68
|
+
needsTitleWarmup = true
|
|
69
|
+
}
|
|
48
70
|
let icon = rendering.image(item.content)
|
|
49
|
-
if tab.image !== icon {
|
|
71
|
+
if tab.image !== icon {
|
|
72
|
+
tab.image = icon
|
|
73
|
+
needsTitleWarmup = true
|
|
74
|
+
}
|
|
50
75
|
if tab.selectedImage !== icon { tab.selectedImage = icon }
|
|
51
76
|
tab.isEnabled = !item.content.disabled
|
|
52
77
|
tab.accessibilityLabel = item.content.accessibilityLabel
|
|
53
|
-
applyTypography(item.content, to: tab)
|
|
78
|
+
if applyTypography(item.content, to: tab) { needsTitleWarmup = true }
|
|
54
79
|
applyBadge(item.content.badge, to: tab, rendering: rendering)
|
|
55
80
|
}
|
|
56
|
-
let
|
|
57
|
-
|
|
81
|
+
let domSelected = zip(nativeItems, items).first { $0.1.content.selected }
|
|
82
|
+
let selectedItem = domSelected?.0
|
|
83
|
+
if let pending = pendingSelection {
|
|
84
|
+
let pendingItem = zip(nativeItems, items).first { $0.1.id == pending.id }
|
|
85
|
+
let expired = CFAbsoluteTimeGetCurrent() >= pending.until
|
|
86
|
+
let unavailable = pendingItem == nil || pendingItem!.1.content.disabled
|
|
87
|
+
if unavailable || expired {
|
|
88
|
+
// Ionic rejected the tap, the item vanished, or the echo timed out.
|
|
89
|
+
pendingSelection = nil
|
|
90
|
+
if tabBar.selectedItem !== selectedItem { tabBar.selectedItem = selectedItem }
|
|
91
|
+
} else if domSelected?.1.id == pending.id {
|
|
92
|
+
pendingSelection = nil
|
|
93
|
+
if tabBar.selectedItem !== selectedItem { tabBar.selectedItem = selectedItem }
|
|
94
|
+
} else if tabBar.selectedItem !== pendingItem!.0 {
|
|
95
|
+
// Keep the optimistic native selection until the Web selected state catches up.
|
|
96
|
+
tabBar.selectedItem = pendingItem!.0
|
|
97
|
+
}
|
|
98
|
+
} else if tabBar.selectedItem !== selectedItem {
|
|
99
|
+
tabBar.selectedItem = selectedItem
|
|
100
|
+
}
|
|
58
101
|
tabBar.semanticContentAttribute = node.rtl ? .forceRightToLeft : .forceLeftToRight
|
|
59
102
|
tabBar.accessibilityIdentifier = node.id
|
|
103
|
+
// Warm after `fit` settles geometry — not on every motion-driven frame sync.
|
|
104
|
+
if needsTitleWarmup { pendingTitleWarmup.insert(ObjectIdentifier(tabBar)) }
|
|
60
105
|
}
|
|
61
106
|
|
|
62
|
-
|
|
107
|
+
@discardableResult
|
|
108
|
+
static func applyTypography(_ content: ShellItemContent, to item: UITabBarItem) -> Bool {
|
|
63
109
|
let weight: UIFont.Weight
|
|
64
110
|
switch content.fontWeight {
|
|
65
111
|
case ..<150: weight = .ultraLight
|
|
@@ -73,11 +119,14 @@ enum ShellTabBar {
|
|
|
73
119
|
default: weight = .black
|
|
74
120
|
}
|
|
75
121
|
let font = UIFont.systemFont(ofSize: content.fontSize, weight: weight)
|
|
122
|
+
var changed = false
|
|
76
123
|
for state in [UIControl.State.normal, .selected] {
|
|
77
124
|
if item.titleTextAttributes(for: state)?[.font] as? UIFont != font {
|
|
78
125
|
item.setTitleTextAttributes([.font: font], for: state)
|
|
126
|
+
changed = true
|
|
79
127
|
}
|
|
80
128
|
}
|
|
129
|
+
return changed
|
|
81
130
|
}
|
|
82
131
|
|
|
83
132
|
static func applyBadge(_ badge: ShellBadge?, to item: UITabBarItem, rendering: ShellRendering) {
|
|
@@ -128,9 +177,31 @@ enum ShellTabBar {
|
|
|
128
177
|
let center = CGPoint(x: bounds.minX + (bounds.width - content.width) * x - content.minX + size.width / 2,
|
|
129
178
|
y: bounds.minY + (bounds.height - content.height) * y - content.minY + size.height / 2)
|
|
130
179
|
if tabBar.center != center { tabBar.center = center }
|
|
180
|
+
if pendingTitleWarmup.remove(ObjectIdentifier(tabBar)) != nil {
|
|
181
|
+
warmUnselectedTitleLayout(tabBar)
|
|
182
|
+
}
|
|
131
183
|
return true
|
|
132
184
|
}
|
|
133
185
|
|
|
186
|
+
/// iOS 26 only: programmatically visit each unselected item so UIKit measures
|
|
187
|
+
/// its title the same way a user tap would. Selection does not call the
|
|
188
|
+
/// tab-bar delegate. No-op on iOS 27+, where first paint already matches Web.
|
|
189
|
+
static func warmUnselectedTitleLayout(_ tabBar: UITabBar) {
|
|
190
|
+
let version = ProcessInfo.processInfo.operatingSystemVersion
|
|
191
|
+
guard version.majorVersion == 26, let items = tabBar.items, items.count > 1 else { return }
|
|
192
|
+
let selected = tabBar.selectedItem
|
|
193
|
+
UIView.performWithoutAnimation {
|
|
194
|
+
for item in items where item !== selected {
|
|
195
|
+
tabBar.selectedItem = item
|
|
196
|
+
tabBar.layoutIfNeeded()
|
|
197
|
+
}
|
|
198
|
+
if tabBar.selectedItem !== selected {
|
|
199
|
+
tabBar.selectedItem = selected
|
|
200
|
+
tabBar.layoutIfNeeded()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
134
205
|
private static func contentFrame(_ tabBar: UITabBar) -> CGRect? {
|
|
135
206
|
func containsControl(_ view: UIView) -> Bool {
|
|
136
207
|
view is UIControl || view.subviews.contains(where: containsControl)
|
|
@@ -20,6 +20,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
20
20
|
private var revision = 0
|
|
21
21
|
private var sequence = 0
|
|
22
22
|
private var keyboardVisible = false
|
|
23
|
+
private var pendingTabSelections: [String: ShellTabBar.PendingSelection] = [:]
|
|
24
|
+
private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:]
|
|
23
25
|
private var restoreTopEdge: (() -> Void)?
|
|
24
26
|
private var observers: [NSObjectProtocol] = []
|
|
25
27
|
|
|
@@ -39,14 +41,21 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
if !keyboard { self.host?.isHidden = true }
|
|
44
|
+
var searchOwnsKeyboard = false
|
|
42
45
|
if #available(iOS 26.0, *) {
|
|
43
46
|
self.searchControllers.values.forEach {
|
|
44
47
|
guard let controller = $0 as? ShellSearchController else { return }
|
|
48
|
+
if controller.ownsKeyboardChrome { searchOwnsKeyboard = true }
|
|
45
49
|
if !keyboard { controller.surface.isHidden = true }
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
|
-
if keyboard {
|
|
49
|
-
|
|
52
|
+
if keyboard {
|
|
53
|
+
if !searchOwnsKeyboard {
|
|
54
|
+
self.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh")
|
|
55
|
+
}
|
|
56
|
+
} else if name == UIDevice.orientationDidChangeNotification {
|
|
57
|
+
self.notifyWebViewMetricsChange()
|
|
58
|
+
}
|
|
50
59
|
})
|
|
51
60
|
}
|
|
52
61
|
for name in [UIApplication.didBecomeActiveNotification, UIResponder.keyboardDidHideNotification,
|
|
@@ -129,6 +138,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
129
138
|
}
|
|
130
139
|
if let control = controls.removeValue(forKey: id) { ShellCrossfade.retire(control, duration: duration) }
|
|
131
140
|
fingerprints.removeValue(forKey: id)
|
|
141
|
+
pendingTabSelections.removeValue(forKey: id)
|
|
142
|
+
pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
private func removeControls(duration: TimeInterval = 0) {
|
|
@@ -136,6 +147,36 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
136
147
|
host?.removeFromSuperview()
|
|
137
148
|
host = nil
|
|
138
149
|
rendering.clear()
|
|
150
|
+
pendingTabSelections.removeAll()
|
|
151
|
+
pendingTabExpiryWorks.values.forEach { $0.cancel() }
|
|
152
|
+
pendingTabExpiryWorks.removeAll()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private func syncTabBar(_ tabBar: UITabBar, id: String, node: ShellControl) {
|
|
156
|
+
var pending = pendingTabSelections[id]
|
|
157
|
+
ShellTabBar.update(tabBar, node: node, rendering: rendering, pendingSelection: &pending)
|
|
158
|
+
if let pending {
|
|
159
|
+
pendingTabSelections[id] = pending
|
|
160
|
+
} else {
|
|
161
|
+
pendingTabSelections.removeValue(forKey: id)
|
|
162
|
+
pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private func schedulePendingTabExpiry(_ id: String, until: CFAbsoluteTime) {
|
|
167
|
+
pendingTabExpiryWorks[id]?.cancel()
|
|
168
|
+
let delay = max(0, until - CFAbsoluteTimeGetCurrent()) + 0.02
|
|
169
|
+
let work = DispatchWorkItem { [weak self] in
|
|
170
|
+
guard let self else { return }
|
|
171
|
+
self.pendingTabExpiryWorks.removeValue(forKey: id)
|
|
172
|
+
guard let pending = self.pendingTabSelections[id],
|
|
173
|
+
CFAbsoluteTimeGetCurrent() >= pending.until,
|
|
174
|
+
let tabBar = self.controls[id] as? UITabBar,
|
|
175
|
+
let node = self.fingerprints[id] else { return }
|
|
176
|
+
self.syncTabBar(tabBar, id: id, node: node)
|
|
177
|
+
}
|
|
178
|
+
pendingTabExpiryWorks[id] = work
|
|
179
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
|
139
180
|
}
|
|
140
181
|
|
|
141
182
|
@objc func update(_ call: CAPPluginCall) {
|
|
@@ -173,7 +214,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
173
214
|
}
|
|
174
215
|
var rejectedControls: [String] = []
|
|
175
216
|
var fabs: [(ShellFab, ShellControl)] = []
|
|
176
|
-
var searches: [(ShellSearchController, ShellControl, CGRect, CGRect)] = []
|
|
217
|
+
var searches: [(ShellSearchController, ShellControl, CGRect, CGRect, UIView?, Bool)] = []
|
|
177
218
|
var rejectedSearches: [String] = []
|
|
178
219
|
UIView.performWithoutAnimation {
|
|
179
220
|
if host.superview !== parent { parent.addSubview(host) }
|
|
@@ -201,9 +242,11 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
201
242
|
if let search = node.search {
|
|
202
243
|
guard let owner = self.bridge?.viewController else { rejectedSearches.append(id); continue }
|
|
203
244
|
let controller: ShellSearchController
|
|
204
|
-
|
|
245
|
+
let previousCover = self.controls[id]
|
|
246
|
+
let replacing = self.searchControllers[id] as? ShellSearchController
|
|
247
|
+
if let existing = replacing { controller = existing }
|
|
205
248
|
else {
|
|
206
|
-
|
|
249
|
+
// Keep the ordinary UITabBar cover until the search controller applies.
|
|
207
250
|
controller = ShellSearchController()
|
|
208
251
|
controller.activate = { [weak self] id in self?.activate(id) }
|
|
209
252
|
controller.changed = { [weak self] id, phase, value, composing, valueVersion in
|
|
@@ -222,7 +265,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
222
265
|
y: webView.bounds.minY + local.minY * scale, width: local.width * scale, height: local.height * scale), to: owner.view)
|
|
223
266
|
let triggerFrame = webView.convert(CGRect(x: webView.bounds.minX + trigger.minX * scale,
|
|
224
267
|
y: webView.bounds.minY + trigger.minY * scale, width: trigger.width * scale, height: trigger.height * scale), to: owner.view)
|
|
225
|
-
searches.append((controller, node, searchBarFrame, triggerFrame))
|
|
268
|
+
searches.append((controller, node, searchBarFrame, triggerFrame, previousCover, replacing == nil))
|
|
226
269
|
continue
|
|
227
270
|
} else if self.searchControllers[id] != nil {
|
|
228
271
|
self.removeControl(id)
|
|
@@ -236,7 +279,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
236
279
|
} else if let segment = self.controls[id] as? ShellSegment, node.kind == ShellSegment.kind {
|
|
237
280
|
segment.update(node, scale: scale, rendering: self.rendering)
|
|
238
281
|
} else if let tabBar = self.controls[id] as? UITabBar, node.kind == ShellTabBar.kind {
|
|
239
|
-
|
|
282
|
+
self.syncTabBar(tabBar, id: id, node: node)
|
|
240
283
|
} else {
|
|
241
284
|
guard let control = ShellComponents.make(node, scale: scale, rendering: self.rendering, tabDelegate: self,
|
|
242
285
|
activate: { [weak self] id in self?.activate(id) }) else { reject(); continue }
|
|
@@ -245,6 +288,9 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
245
288
|
host.addSubview(control)
|
|
246
289
|
}
|
|
247
290
|
self.fingerprints[id] = node
|
|
291
|
+
} else if let tabBar = self.controls[id] as? UITabBar, self.pendingTabSelections[id] != nil {
|
|
292
|
+
// Resolve an in-flight native tap even when other fingerprint fields are unchanged.
|
|
293
|
+
self.syncTabBar(tabBar, id: id, node: node)
|
|
248
294
|
}
|
|
249
295
|
guard let control = self.controls[id] else { reject(); continue }
|
|
250
296
|
if let tabBar = control as? UITabBar {
|
|
@@ -262,13 +308,29 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
262
308
|
for (fab, node) in fabs {
|
|
263
309
|
fab.apply(node, scale: scale, rendering: self.rendering, activate: { [weak self] id in self?.activate(id) })
|
|
264
310
|
}
|
|
265
|
-
for (controller, node, frame, triggerFrame) in searches {
|
|
311
|
+
for (controller, node, frame, triggerFrame, previousCover, created) in searches {
|
|
312
|
+
let id = node.id
|
|
266
313
|
let webFrame = webView.convert(webView.bounds, to: controller.surface.superview)
|
|
267
314
|
if !controller.apply(node, webFrame: webFrame, barFrame: frame, triggerFrame: triggerFrame, rendering: self.rendering) {
|
|
268
|
-
|
|
269
|
-
|
|
315
|
+
if created {
|
|
316
|
+
self.searchControllers.removeValue(forKey: id)
|
|
317
|
+
controller.detach()
|
|
318
|
+
if let previousCover, previousCover.superview != nil {
|
|
319
|
+
self.controls[id] = previousCover
|
|
320
|
+
} else {
|
|
321
|
+
self.controls.removeValue(forKey: id)?.removeFromSuperview()
|
|
322
|
+
self.fingerprints.removeValue(forKey: id)
|
|
323
|
+
self.pendingTabSelections[id] = nil
|
|
324
|
+
self.pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
self.removeControl(id)
|
|
328
|
+
}
|
|
270
329
|
rejectedSearches.append(id)
|
|
271
330
|
} else {
|
|
331
|
+
if created, let previousCover, previousCover !== controller.surface {
|
|
332
|
+
previousCover.removeFromSuperview()
|
|
333
|
+
}
|
|
272
334
|
controller.surface.isHidden = self.keyboardVisible && !controller.ownsKeyboard
|
|
273
335
|
}
|
|
274
336
|
}
|
|
@@ -284,8 +346,13 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
284
346
|
}
|
|
285
347
|
|
|
286
348
|
public func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
|
|
287
|
-
guard item.isEnabled, let
|
|
288
|
-
|
|
349
|
+
guard item.isEnabled, let itemId = item.accessibilityIdentifier else { return }
|
|
350
|
+
if let controlId = controls.first(where: { $0.value === tabBar })?.key {
|
|
351
|
+
let pending = ShellTabBar.PendingSelection.start(itemId)
|
|
352
|
+
pendingTabSelections[controlId] = pending
|
|
353
|
+
schedulePendingTabExpiry(controlId, until: pending.until)
|
|
354
|
+
}
|
|
355
|
+
activate(itemId)
|
|
289
356
|
}
|
|
290
357
|
|
|
291
358
|
private func activate(_ id: String) {
|
|
@@ -237,8 +237,8 @@ final class ShellSnapshotTests: XCTestCase {
|
|
|
237
237
|
let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
238
238
|
"items": [item(["id": "first", "selected": true]), item(["id": "second"]) ]])]).controls.first)
|
|
239
239
|
XCTAssertTrue(controller.apply(node, webFrame: CGRect(x: 0, y: 0, width: 390, height: 844),
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
241
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
242
242
|
}
|
|
243
243
|
try apply("old-search", value: "initial")
|
|
244
244
|
let navigation = try XCTUnwrap(controller.tabs.last?.viewController as? UINavigationController)
|
|
@@ -292,4 +292,260 @@ final class ShellSnapshotTests: XCTestCase {
|
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
@MainActor
|
|
296
|
+
func testTabTitleLayoutWarmupRestoresSelection() throws {
|
|
297
|
+
let image = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { context in
|
|
298
|
+
UIColor.black.setFill()
|
|
299
|
+
context.fill(CGRect(x: 0, y: 0, width: 24, height: 24))
|
|
300
|
+
}.pngData()!.base64EncodedString()
|
|
301
|
+
let items = [
|
|
302
|
+
item(["id": "first", "label": "Index", "selected": true, "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
|
|
303
|
+
item(["id": "second", "label": "Docs", "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
|
|
304
|
+
item(["id": "third", "label": "Library", "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
|
|
305
|
+
]
|
|
306
|
+
let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "width": 280.0, "height": 62.0, "items": items])]).controls.first)
|
|
307
|
+
let bar = UITabBar()
|
|
308
|
+
ShellTabBar.update(bar, node: node, rendering: ShellRendering())
|
|
309
|
+
XCTAssertTrue(ShellTabBar.fit(bar, node: node, bounds: CGRect(x: 20, y: 700, width: 280, height: 62)))
|
|
310
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
311
|
+
// A second fit without content changes must not leave a pending warmup.
|
|
312
|
+
XCTAssertTrue(ShellTabBar.fit(bar, node: node, bounds: CGRect(x: 20, y: 700, width: 280, height: 62)))
|
|
313
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
@MainActor
|
|
317
|
+
func testTabPendingSelectionIgnoresStaleDomEcho() throws {
|
|
318
|
+
let first = item(["id": "first", "selected": true])
|
|
319
|
+
let second = item(["id": "second"])
|
|
320
|
+
let bar = UITabBar()
|
|
321
|
+
let initial = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [first, second]])]).controls.first)
|
|
322
|
+
ShellTabBar.update(bar, node: initial, rendering: ShellRendering())
|
|
323
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
324
|
+
|
|
325
|
+
var pending: ShellTabBar.PendingSelection? = .start("second")
|
|
326
|
+
// Stale Web echo still reports the previous tab.
|
|
327
|
+
ShellTabBar.update(bar, node: initial, rendering: ShellRendering(), pendingSelection: &pending)
|
|
328
|
+
XCTAssertEqual(pending?.id, "second")
|
|
329
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "second")
|
|
330
|
+
|
|
331
|
+
let echoed = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [
|
|
332
|
+
item(["id": "first"]), item(["id": "second", "selected": true]),
|
|
333
|
+
]])]).controls.first)
|
|
334
|
+
ShellTabBar.update(bar, node: echoed, rendering: ShellRendering(), pendingSelection: &pending)
|
|
335
|
+
XCTAssertNil(pending)
|
|
336
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "second")
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
@MainActor
|
|
340
|
+
func testTabPendingSelectionFallsBackWhenDomRejects() throws {
|
|
341
|
+
let first = item(["id": "first", "selected": true])
|
|
342
|
+
let second = item(["id": "second"])
|
|
343
|
+
let bar = UITabBar()
|
|
344
|
+
let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [first, second]])]).controls.first)
|
|
345
|
+
ShellTabBar.update(bar, node: node, rendering: ShellRendering())
|
|
346
|
+
bar.selectedItem = bar.items?.first { $0.accessibilityIdentifier == "second" }
|
|
347
|
+
|
|
348
|
+
var pending: ShellTabBar.PendingSelection? = ShellTabBar.PendingSelection(id: "second", until: CFAbsoluteTimeGetCurrent() - 1)
|
|
349
|
+
ShellTabBar.update(bar, node: node, rendering: ShellRendering(), pendingSelection: &pending)
|
|
350
|
+
XCTAssertNil(pending)
|
|
351
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
352
|
+
|
|
353
|
+
pending = .start("second")
|
|
354
|
+
let disabled = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [
|
|
355
|
+
item(["id": "first", "selected": true]), item(["id": "second", "disabled": true]),
|
|
356
|
+
]])]).controls.first)
|
|
357
|
+
bar.selectedItem = bar.items?.first { $0.accessibilityIdentifier == "second" }
|
|
358
|
+
ShellTabBar.update(bar, node: disabled, rendering: ShellRendering(), pendingSelection: &pending)
|
|
359
|
+
XCTAssertNil(pending)
|
|
360
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
@MainActor
|
|
364
|
+
func testSearchOptimisticTabSelectionIgnoresStaleDomEcho() throws {
|
|
365
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
366
|
+
let controller = ShellSearchController()
|
|
367
|
+
let rendering = ShellRendering()
|
|
368
|
+
let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
369
|
+
let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
|
|
370
|
+
let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
|
|
371
|
+
func node(selected: String) throws -> ShellControl {
|
|
372
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
373
|
+
"closeId": "close", "active": false, "available": true, "focused": false,
|
|
374
|
+
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
375
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
376
|
+
"items": [item(["id": "first", "selected": selected == "first"]),
|
|
377
|
+
item(["id": "second", "selected": selected == "second"])]])]).controls.first)
|
|
378
|
+
}
|
|
379
|
+
_ = controller.apply(try node(selected: "first"), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
380
|
+
let second = try XCTUnwrap(controller.tabs.first { $0.identifier == "second" })
|
|
381
|
+
var activated: [String] = []
|
|
382
|
+
controller.activate = { activated.append($0) }
|
|
383
|
+
XCTAssertTrue(controller.tabBarController(controller, shouldSelectTab: second))
|
|
384
|
+
XCTAssertEqual(activated, ["second"])
|
|
385
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
386
|
+
// Stale Web echo still reports the previous tab.
|
|
387
|
+
_ = controller.apply(try node(selected: "first"), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
388
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
389
|
+
// Web catches up — clear pending without bouncing selection.
|
|
390
|
+
_ = controller.apply(try node(selected: "second"), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
391
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
@MainActor
|
|
395
|
+
func testSearchLocksProjectionWhileActiveAndKeepsApplicationValue() throws {
|
|
396
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
397
|
+
let controller = ShellSearchController()
|
|
398
|
+
let rendering = ShellRendering()
|
|
399
|
+
let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
400
|
+
let shrunk = CGRect(x: 0, y: 0, width: 390, height: 500)
|
|
401
|
+
func node(active: Bool, focused: Bool, value: String = "", valueVersion: Int = 0) throws -> ShellControl {
|
|
402
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
403
|
+
"closeId": "close", "active": active, "available": true, "focused": focused,
|
|
404
|
+
"value": value, "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": valueVersion]
|
|
405
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
406
|
+
"items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
|
|
407
|
+
}
|
|
408
|
+
// Resting layout may reject without a full window hierarchy; surface is still assigned.
|
|
409
|
+
_ = controller.apply(try node(active: false, focused: false), webFrame: resting,
|
|
410
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
411
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
|
|
412
|
+
controller.surface.frame = resting
|
|
413
|
+
// Active (even before focus) freezes Web layout for the whole search session.
|
|
414
|
+
XCTAssertTrue(controller.apply(try node(active: true, focused: false), webFrame: resting,
|
|
415
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
416
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
417
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
418
|
+
XCTAssertTrue(controller.apply(try node(active: true, focused: false), webFrame: shrunk,
|
|
419
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
420
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
421
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
422
|
+
// Application realtime value updates still cross the lock.
|
|
423
|
+
XCTAssertTrue(controller.apply(try node(active: true, focused: true, value: "external", valueVersion: 2), webFrame: shrunk,
|
|
424
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
425
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
426
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
427
|
+
let navigation = try XCTUnwrap(controller.tabs.last?.viewController as? UINavigationController)
|
|
428
|
+
let searchBar = try XCTUnwrap(navigation.topViewController?.navigationItem.searchController?.searchBar)
|
|
429
|
+
XCTAssertEqual(searchBar.text, "external")
|
|
430
|
+
// Blur request returns to presented (active, not focused) without unlocking projection.
|
|
431
|
+
XCTAssertTrue(controller.apply(try node(active: true, focused: false, value: "external", valueVersion: 2), webFrame: shrunk,
|
|
432
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
433
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
434
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
435
|
+
XCTAssertFalse(searchBar.searchTextField.isFirstResponder)
|
|
436
|
+
let tabsBeforeLeave = controller.tabs.map(\.identifier)
|
|
437
|
+
// Leave re-fits; without a window hierarchy fit may reject while still restoring selection.
|
|
438
|
+
_ = controller.apply(try node(active: false, focused: false, value: "external", valueVersion: 2), webFrame: resting,
|
|
439
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
440
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
|
|
441
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
442
|
+
// Leave must reassign tabs even when identifiers match (UISearchTab morph cleanup).
|
|
443
|
+
XCTAssertEqual(controller.tabs.map(\.identifier), tabsBeforeLeave)
|
|
444
|
+
XCTAssertEqual(controller.tabs.count, 3) // first, second, search
|
|
445
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "first")
|
|
446
|
+
// Switching ordinary tabs after leave must keep resting chrome (no deferred morph expand).
|
|
447
|
+
func nodeSelecting(_ id: String) throws -> ShellControl {
|
|
448
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
449
|
+
"closeId": "close", "active": false, "available": true, "focused": false,
|
|
450
|
+
"value": "external", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 2]
|
|
451
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
452
|
+
"items": [item(["id": "first", "selected": id == "first"]),
|
|
453
|
+
item(["id": "second", "selected": id == "second"])]])]).controls.first)
|
|
454
|
+
}
|
|
455
|
+
let tabsAfterLeave = controller.tabs.map(\.identifier)
|
|
456
|
+
_ = controller.apply(try nodeSelecting("second"), webFrame: resting,
|
|
457
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
458
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
|
|
459
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
460
|
+
XCTAssertEqual(controller.tabs.map(\.identifier), tabsAfterLeave)
|
|
461
|
+
_ = controller.apply(try nodeSelecting("first"), webFrame: resting,
|
|
462
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
463
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
|
|
464
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "first")
|
|
465
|
+
XCTAssertEqual(controller.tabs.map(\.identifier), tabsAfterLeave)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
@MainActor
|
|
469
|
+
func testSearchRelocksProjectionWidthAfterRotation() throws {
|
|
470
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
471
|
+
let controller = ShellSearchController()
|
|
472
|
+
let rendering = ShellRendering()
|
|
473
|
+
let portrait = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
474
|
+
let landscape = CGRect(x: 0, y: 0, width: 844, height: 390)
|
|
475
|
+
let keyboardShrunk = CGRect(x: 0, y: 0, width: 844, height: 200)
|
|
476
|
+
func node(active: Bool) throws -> ShellControl {
|
|
477
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
478
|
+
"closeId": "close", "active": active, "available": true, "focused": false,
|
|
479
|
+
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
480
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
481
|
+
"items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
|
|
482
|
+
}
|
|
483
|
+
let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
|
|
484
|
+
let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
|
|
485
|
+
_ = controller.apply(try node(active: false), webFrame: portrait, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
486
|
+
controller.surface.frame = portrait
|
|
487
|
+
XCTAssertTrue(controller.apply(try node(active: true), webFrame: portrait, barFrame: bar, triggerFrame: trigger, rendering: rendering))
|
|
488
|
+
XCTAssertEqual(controller.surface.frame, portrait)
|
|
489
|
+
// Rotation must adopt the new width instead of keeping the portrait lock.
|
|
490
|
+
XCTAssertTrue(controller.apply(try node(active: true), webFrame: landscape, barFrame: bar, triggerFrame: trigger, rendering: rendering))
|
|
491
|
+
XCTAssertEqual(controller.surface.frame, landscape)
|
|
492
|
+
// Keyboard shrink at the same width must still be ignored.
|
|
493
|
+
XCTAssertTrue(controller.apply(try node(active: true), webFrame: keyboardShrunk, barFrame: bar, triggerFrame: trigger, rendering: rendering))
|
|
494
|
+
XCTAssertEqual(controller.surface.frame, landscape)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
@MainActor
|
|
498
|
+
func testSearchPendingSelectionExpiresWithoutLaterApply() throws {
|
|
499
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
500
|
+
let controller = ShellSearchController()
|
|
501
|
+
let rendering = ShellRendering()
|
|
502
|
+
let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
503
|
+
let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
|
|
504
|
+
let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
|
|
505
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
506
|
+
"closeId": "close", "active": false, "available": true, "focused": false,
|
|
507
|
+
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
508
|
+
let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
509
|
+
"items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
|
|
510
|
+
_ = controller.apply(node, webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
511
|
+
let second = try XCTUnwrap(controller.tabs.first { $0.identifier == "second" })
|
|
512
|
+
XCTAssertTrue(controller.tabBarController(controller, shouldSelectTab: second))
|
|
513
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
514
|
+
let expectation = expectation(description: "pending selection expires back to DOM")
|
|
515
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.15) {
|
|
516
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "first")
|
|
517
|
+
expectation.fulfill()
|
|
518
|
+
}
|
|
519
|
+
wait(for: [expectation], timeout: 2)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
@MainActor
|
|
523
|
+
func testSearchEmitsInputAfterRestingToActive() throws {
|
|
524
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
525
|
+
let controller = ShellSearchController()
|
|
526
|
+
let rendering = ShellRendering()
|
|
527
|
+
let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
528
|
+
let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
|
|
529
|
+
let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
|
|
530
|
+
func node(active: Bool) throws -> ShellControl {
|
|
531
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
532
|
+
"closeId": "close", "active": active, "available": true, "focused": active,
|
|
533
|
+
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
534
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
535
|
+
"items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
|
|
536
|
+
}
|
|
537
|
+
// Resting must not permanently close the editing bridge.
|
|
538
|
+
_ = controller.apply(try node(active: false), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
539
|
+
var phases: [String] = []
|
|
540
|
+
controller.changed = { _, phase, _, _, _ in
|
|
541
|
+
phases.append(phase.rawValue)
|
|
542
|
+
return phases.count
|
|
543
|
+
}
|
|
544
|
+
XCTAssertTrue(controller.apply(try node(active: true), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering))
|
|
545
|
+
let navigation = try XCTUnwrap(controller.tabs.last?.viewController as? UINavigationController)
|
|
546
|
+
let searchBar = try XCTUnwrap(navigation.topViewController?.navigationItem.searchController?.searchBar)
|
|
547
|
+
controller.searchBar(searchBar, textDidChange: "query")
|
|
548
|
+
XCTAssertEqual(phases, ["input"])
|
|
549
|
+
}
|
|
550
|
+
|
|
295
551
|
}
|