@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.
@@ -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 { 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 { 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 selectedItem = zip(nativeItems, items).first { $0.1.content.selected }?.0
57
- if tabBar.selectedItem !== selectedItem { tabBar.selectedItem = selectedItem }
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
- static func applyTypography(_ content: ShellItemContent, to item: UITabBarItem) {
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 { self.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh") }
49
- else if name == UIDevice.orientationDidChangeNotification { self.notifyWebViewMetricsChange() }
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
- if let existing = self.searchControllers[id] as? ShellSearchController { controller = existing }
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
- self.removeControl(id)
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
- ShellTabBar.update(tabBar, node: node, rendering: self.rendering)
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
- let id = node.id
269
- self.removeControl(id)
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 id = item.accessibilityIdentifier else { return }
288
- activate(id)
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
- barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
241
- triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
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
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rdlabo/ionic-theme-ios27",
3
3
  "private": false,
4
- "version": "0.2.1",
4
+ "version": "1.0.0",
5
5
  "description": "iOS27 Theme for Ionic Framework",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",