@rdlabo/ionic-theme-ios27 0.2.1 → 1.0.1-beta.pr194.sha00da872ba249

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 (34) hide show
  1. package/README.md +134 -122
  2. package/dist/css/components/ion-segment.css +1 -1
  3. package/dist/css/components/ion-toolbar.css +1 -1
  4. package/dist/css/ionic-theme-ios27-dark-always.css +1 -1
  5. package/dist/css/ionic-theme-ios27-dark-class.css +1 -1
  6. package/dist/css/ionic-theme-ios27-dark-system.css +1 -1
  7. package/dist/css/ionic-theme-ios27.css +1 -1
  8. package/dist/css/utils/structured-list.css +1 -0
  9. package/dist/native/components/searchable-tabs.d.ts +2 -0
  10. package/dist/native/components/searchable-tabs.d.ts.map +1 -1
  11. package/dist/native/components/searchable-tabs.js +131 -21
  12. package/dist/native/runtime.d.ts.map +1 -1
  13. package/dist/native/runtime.js +14 -7
  14. package/docs/migration.md +105 -3
  15. package/docs/native-ui-shell.md +15 -3
  16. package/docs/special-markup.md +9 -0
  17. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +243 -64
  18. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabBar.swift +77 -6
  19. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +79 -12
  20. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +258 -2
  21. package/node_modules/@rdlabo/ionic-theme-utils/package.json +4 -0
  22. package/node_modules/@rdlabo/ionic-theme-utils/scripts/copy-structured-list.mjs +17 -0
  23. package/package.json +4 -3
  24. package/src/native/components/searchable-tabs.ts +142 -22
  25. package/src/native/runtime.ts +11 -6
  26. package/src/styles/components/ion-list.scss +1 -1
  27. package/src/styles/components/ion-segment.scss +19 -0
  28. package/src/styles/components/ion-toolbar.scss +11 -0
  29. package/src/styles/md-ion-list-inset.scss +1 -1
  30. package/src/styles/utils/dark/ion-list.scss +1 -1
  31. package/src/styles/utils/dark/ion-segment.scss +20 -0
  32. package/src/styles/utils/structured-list.scss +310 -0
  33. package/docs/ios-adaptive.md +0 -90
  34. /package/docs/{experimental-animation.md → animation.md} +0 -0
@@ -47,7 +47,24 @@ private final class ShellSearchInputDelegate: NSObject, UITextFieldDelegate {
47
47
 
48
48
  @available(iOS 26.0, *)
49
49
  final class ShellSearchController: UITabBarController, UITabBarControllerDelegate, UISearchBarDelegate {
50
+ // Wire stays active+focused; local session drives chrome (idle / presented / focused).
51
+ private enum Session: Equatable { case idle, presented, focused }
52
+
53
+ private final class IdleBarDelegate: NSObject, UITabBarDelegate {
54
+ var select: ((String) -> Void)?
55
+ func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
56
+ guard item.isEnabled, let id = item.accessibilityIdentifier else { return }
57
+ select?(id)
58
+ }
59
+ }
60
+
50
61
  let surface = ShellSearchHost()
62
+ /// Ordinary `UITabBar` chrome while searchable is registered but the session is idle.
63
+ /// Matches `ShellTabBar.fit` so Index/Album resting tabs do not jump.
64
+ private let idleBar = UITabBar()
65
+ private let idleBarDelegate = IdleBarDelegate()
66
+ /// Resting search trigger pinned to the Web FAB while `available && !active`.
67
+ private var searchTrigger: UIButton?
51
68
  private let search = UISearchController(searchResultsController: nil)
52
69
  private let inputDelegate = ShellSearchInputDelegate()
53
70
  private var searchTab: UISearchTab!
@@ -59,7 +76,13 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
59
76
  private var valueVersion = -1
60
77
  private var lastLayout = ""
61
78
  private var layoutItems: [ShellItemContent] = []
79
+ private var lockedWebFrame: CGRect? // frozen while search is active (width changes re-lock)
80
+ private var session: Session = .idle
81
+ private var focusWork: DispatchWorkItem?
82
+ private var pendingSelection: ShellTabBar.PendingSelection? // optimistic ordinary tab
83
+ private var pendingExpiryWork: DispatchWorkItem?
62
84
  var ownsKeyboard: Bool { search.searchBar.searchTextField.isFirstResponder }
85
+ var ownsKeyboardChrome: Bool { session == .focused || ownsKeyboard }
63
86
  var activate: ((String) -> Void)?
64
87
  var changed: ((String, ShellSearchPhase, String, Bool, Int) -> Int)?
65
88
 
@@ -72,6 +95,14 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
72
95
  delegate = self
73
96
  mode = .tabBar
74
97
  ShellTabBar.configureLayout(tabBar)
98
+ ShellTabBar.configureLayout(idleBar)
99
+ // Must not use self as idleBar.delegate — UITabBarController asserts item↔VC pairing.
100
+ idleBar.delegate = idleBarDelegate
101
+ idleBarDelegate.select = { [weak self] id in
102
+ self?.armPendingSelection(id)
103
+ self?.activate?(id)
104
+ }
105
+ idleBar.isHidden = true
75
106
  search.obscuresBackgroundDuringPresentation = false
76
107
  search.hidesNavigationBarDuringPresentation = false
77
108
  search.searchBar.delegate = self
@@ -84,6 +115,7 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
84
115
  child.view.backgroundColor = .clear
85
116
  child.definesPresentationContext = true
86
117
  child.navigationItem.searchController = self?.search
118
+ child.navigationItem.hidesSearchBarWhenScrolling = false
87
119
  child.navigationItem.preferredSearchBarPlacement = .integrated
88
120
  let navigation = UINavigationController(rootViewController: child)
89
121
  navigation.view.backgroundColor = .clear
@@ -93,17 +125,124 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
93
125
  inputDelegate.clear = { [weak self] in self?.emit(.clear) }
94
126
  }
95
127
 
128
+ deinit {
129
+ focusWork?.cancel()
130
+ pendingExpiryWork?.cancel()
131
+ }
132
+
133
+ private func wantedSession(active: Bool, focused: Bool) -> Session {
134
+ if !active { return .idle }
135
+ return focused ? .focused : .presented
136
+ }
137
+
138
+ private func endEditing() {
139
+ focusWork?.cancel()
140
+ focusWork = nil
141
+ if search.searchBar.searchTextField.isFirstResponder {
142
+ search.searchBar.searchTextField.resignFirstResponder()
143
+ }
144
+ if search.isActive { search.isActive = false }
145
+ }
146
+
147
+ private func applySession(_ wanted: Session, selectingSearchTab: Bool) {
148
+ if wanted == session && !selectingSearchTab { return }
149
+ if wanted == .idle {
150
+ endEditing()
151
+ session = .idle
152
+ return
153
+ }
154
+ if selectingSearchTab || selectedTab !== searchTab { selectedTab = searchTab }
155
+ if wanted == .presented {
156
+ if session == .focused { endEditing() }
157
+ session = .presented
158
+ return
159
+ }
160
+ session = .focused
161
+ if !search.isActive { search.isActive = true }
162
+ guard !search.searchBar.searchTextField.isFirstResponder, focusWork == nil else { return }
163
+ let work = DispatchWorkItem { [weak self] in
164
+ guard let self else { return }
165
+ self.focusWork = nil
166
+ guard self.session == .focused, !self.search.searchBar.searchTextField.isFirstResponder else { return }
167
+ if !self.search.isActive { self.search.isActive = true }
168
+ _ = self.search.searchBar.searchTextField.becomeFirstResponder()
169
+ }
170
+ focusWork = work
171
+ DispatchQueue.main.async(execute: work)
172
+ }
173
+
174
+ private func armPendingSelection(_ id: String) {
175
+ pendingSelection = .start(id)
176
+ schedulePendingExpiry()
177
+ }
178
+
179
+ private func clearPendingSelection() {
180
+ pendingExpiryWork?.cancel()
181
+ pendingExpiryWork = nil
182
+ pendingSelection = nil
183
+ }
184
+
185
+ private func schedulePendingExpiry() {
186
+ pendingExpiryWork?.cancel()
187
+ pendingExpiryWork = nil
188
+ guard let pending = pendingSelection else { return }
189
+ let delay = max(0, pending.until - CFAbsoluteTimeGetCurrent()) + 0.02
190
+ let work = DispatchWorkItem { [weak self] in
191
+ guard let self else { return }
192
+ self.pendingExpiryWork = nil
193
+ guard let pending = self.pendingSelection, CFAbsoluteTimeGetCurrent() >= pending.until else { return }
194
+ self.pendingSelection = nil
195
+ // No later apply: revert idle chrome + controller selection to the last DOM selected id.
196
+ if let item = self.idleBar.items?.first(where: { $0.accessibilityIdentifier == self.selectedID }) {
197
+ self.idleBar.selectedItem = item
198
+ }
199
+ if let selected = self.ordinary[self.selectedID], self.selectedTab !== selected {
200
+ self.selectedTab = selected
201
+ }
202
+ }
203
+ pendingExpiryWork = work
204
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
205
+ }
206
+
207
+ private func resolveOrdinarySelection(_ items: [ShellItem], fallback: UITab?) {
208
+ let domSelected = ordinary[selectedID] ?? fallback
209
+ guard let pending = pendingSelection else {
210
+ pendingExpiryWork?.cancel()
211
+ pendingExpiryWork = nil
212
+ if selectedTab !== domSelected { selectedTab = domSelected }
213
+ return
214
+ }
215
+ let pendingTab = ordinary[pending.id]
216
+ let expired = CFAbsoluteTimeGetCurrent() >= pending.until
217
+ let unavailable = pendingTab == nil || (items.first { $0.id == pending.id }?.content.disabled ?? true)
218
+ if unavailable || expired {
219
+ clearPendingSelection()
220
+ selectedTab = domSelected
221
+ } else if selectedID == pending.id {
222
+ clearPendingSelection()
223
+ if selectedTab !== domSelected { selectedTab = domSelected }
224
+ } else if selectedTab !== pendingTab {
225
+ selectedTab = pendingTab
226
+ }
227
+ }
228
+
96
229
  func attach(to parent: UIViewController, in container: UIView) {
97
230
  parent.addChild(self)
98
231
  container.addSubview(surface)
99
232
  surface.addSubview(view)
233
+ if idleBar.superview !== surface { surface.addSubview(idleBar) }
100
234
  didMove(toParent: parent)
101
235
  }
102
236
 
103
237
  func detach() {
104
238
  closing = true
105
- search.isActive = false
239
+ lockedWebFrame = nil
240
+ clearPendingSelection()
241
+ applySession(.idle, selectingSearchTab: false)
106
242
  willMove(toParent: nil)
243
+ idleBar.removeFromSuperview()
244
+ searchTrigger?.removeFromSuperview()
245
+ searchTrigger = nil
107
246
  surface.removeFromSuperview()
108
247
  view.removeFromSuperview()
109
248
  removeFromParent()
@@ -117,18 +256,16 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
117
256
  editingSequence = 0
118
257
  valueVersion = -1
119
258
  }
259
+ let previousAvailable = self.configuration?.available
260
+ let wasActive = self.configuration.map { $0.available && $0.active } ?? false
120
261
  self.configuration = configuration
121
262
  let available = configuration.available
122
263
  let active = available && configuration.active
123
- // UIKit may clear the field while changing tabs. Retiring must not
124
- // advance native editing beyond the last input accepted by Ionic.
125
- if !active { closing = true }
264
+ let wanted = wantedSession(active: active, focused: configuration.focused)
126
265
  let items = snapshot.items
127
266
  let content = items.map(\.content)
128
267
  if layoutItems != content {
129
268
  layoutItems = content
130
- // Defer fitting while search is active, but keep the resting layout
131
- // invalid until new labels, typography, icons and badges are measured.
132
269
  lastLayout = ""
133
270
  }
134
271
  let ids = items.map(\.id)
@@ -153,7 +290,11 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
153
290
  searchTab.image = rendering.image(trigger.content)
154
291
  searchTab.isEnabled = !configuration.disabled
155
292
  let requested = ids.compactMap { ordinary[$0] } + (available ? [searchTab!] : [])
156
- if tabs.map(\.identifier) != requested.map(\.identifier) { tabs = requested; lastLayout = "" }
293
+ if !active, wasActive || tabs.isEmpty || tabs.map(\.identifier) != requested.map(\.identifier) {
294
+ // Leave / availability flips rebuild tabs (UISearchTab morph cleanup when leaving).
295
+ tabs = requested
296
+ lastLayout = ""
297
+ }
157
298
  for item in items {
158
299
  if let nativeItem = ordinary[item.id]?.viewController?.tabBarItem {
159
300
  nativeItem.accessibilityLabel = item.content.accessibilityLabel
@@ -161,7 +302,6 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
161
302
  ShellTabBar.applyBadge(item.content.badge, to: nativeItem, rendering: rendering)
162
303
  }
163
304
  }
164
- // This is a public iOS 27 property; dynamic dispatch also supports apps built with SDK 26.
165
305
  let prominent = NSSelectorFromString("setProminentTabIdentifier:")
166
306
  if responds(to: prominent) { setValue(available ? searchTab.identifier : nil, forKey: "prominentTabIdentifier") }
167
307
  let field = configuration.field
@@ -180,74 +320,107 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
180
320
  inputDelegate.original = search.searchBar.searchTextField.delegate
181
321
  search.searchBar.searchTextField.delegate = inputDelegate
182
322
  }
183
- surface.frame = webFrame
323
+ let layout = "\(webFrame):\(barFrame):\(triggerFrame):\(available):\(snapshot.rtl)"
324
+ // Resting keeps closing=true so hidden UISearchBar noise cannot emit; clear it while active.
325
+ closing = !active
184
326
  surface.isHidden = false
185
327
  surface.overrideUserInterfaceStyle = snapshot.dark ? .dark : .light
328
+ if active {
329
+ // UISearchTab session owns the bar + keyboard; freeze Web layout for the session.
330
+ if tabs.isEmpty || tabs.map(\.identifier) != requested.map(\.identifier) {
331
+ tabs = requested
332
+ }
333
+ let reveal = {
334
+ self.idleBar.isHidden = true
335
+ self.searchTrigger?.isHidden = true
336
+ self.view.isHidden = false
337
+ self.view.frame = self.surface.bounds
338
+ }
339
+ if !wasActive {
340
+ UIView.transition(with: surface, duration: 0.35, options: [.transitionCrossDissolve, .beginFromCurrentState], animations: reveal)
341
+ } else {
342
+ reveal()
343
+ }
344
+ let nextLock = surface.bounds.isEmpty ? webFrame : surface.frame
345
+ if let locked = lockedWebFrame, abs(locked.width - webFrame.width) > 0.5 {
346
+ // Rotation / size-class change: adopt the new width while still ignoring keyboard shrink.
347
+ lockedWebFrame = webFrame
348
+ } else if lockedWebFrame == nil {
349
+ lockedWebFrame = nextLock
350
+ }
351
+ if let lockedWebFrame, surface.frame != lockedWebFrame { surface.frame = lockedWebFrame }
352
+ clearPendingSelection()
353
+ applySession(wanted, selectingSearchTab: !wasActive)
354
+ return true
355
+ }
356
+
357
+ surface.frame = webFrame
186
358
  view.semanticContentAttribute = snapshot.rtl ? .forceRightToLeft : .forceLeftToRight
187
- // Only measure the resting tabs. UIKit owns all frames during search and keyboard movement.
188
- let layout = "\(webFrame):\(barFrame):\(triggerFrame):\(available):\(snapshot.rtl)"
189
- if !active && lastLayout != layout {
190
- closing = true
191
- search.isActive = false
192
- selectedTab = ordinary[selectedID] ?? requested.first
193
- view.frame = surface.bounds
194
- view.layoutIfNeeded()
195
- guard fit(barFrame: surface.convert(barFrame, from: surface.superview), triggerFrame: surface.convert(triggerFrame, from: surface.superview),
196
- available: available, anchor: snapshot.tabBarAnchor) else { return false }
197
- lastLayout = layout
359
+ idleBar.semanticContentAttribute = snapshot.rtl ? .forceRightToLeft : .forceLeftToRight
360
+ if lockedWebFrame != nil {
361
+ lockedWebFrame = nil
362
+ lastLayout = ""
198
363
  }
199
- let wanted = active ? searchTab : ordinary[selectedID]
200
- if selectedTab !== wanted { selectedTab = wanted }
201
- closing = !active
202
- if !active { search.isActive = false }
203
- else if configuration.focused && !search.searchBar.searchTextField.isFirstResponder {
204
- search.isActive = true
205
- search.searchBar.searchTextField.becomeFirstResponder()
364
+ applySession(.idle, selectingSearchTab: false)
365
+
366
+ // Resting chrome: ordinary UITabBar (+ FAB trigger when available). UISearchTab stays off-screen.
367
+ if idleBar.superview !== surface { surface.addSubview(idleBar) }
368
+ let localBar = surface.convert(barFrame, from: surface.superview)
369
+ let localTrigger = surface.convert(triggerFrame, from: surface.superview)
370
+ // Apply optimistic selection to the visible idle bar before reconciling the hidden controller.
371
+ ShellTabBar.update(idleBar, node: snapshot, rendering: rendering, pendingSelection: &pendingSelection)
372
+ resolveOrdinarySelection(items, fallback: requested.first)
373
+ if !available {
374
+ searchTrigger?.removeFromSuperview()
375
+ searchTrigger = nil
376
+ } else {
377
+ let triggerButton = ShellButton.render(configuration.trigger.content, glass: true, existing: searchTrigger, rendering: rendering) { [weak self] id in
378
+ self?.activate?(id)
379
+ }
380
+ if searchTrigger !== triggerButton {
381
+ searchTrigger?.removeFromSuperview()
382
+ searchTrigger = triggerButton
383
+ surface.addSubview(triggerButton)
384
+ }
385
+ triggerButton.frame = localTrigger
206
386
  }
207
- return true
208
- }
209
387
 
210
- private func fit(barFrame: CGRect, triggerFrame: CGRect, available: Bool, anchor: ShellTabBar.Anchor?) -> Bool {
211
- func descendants(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(descendants) }
212
- let groups = tabBar.subviews.filter { descendants($0).contains { $0 is UIControl } }
213
- guard ordinary.count > 1,
214
- let group = groups.first(where: { descendants($0).filter { $0 is UIControl }.count >= ordinary.count }) else { return false }
215
- let searchControl = groups.filter { $0 !== group }.flatMap(descendants).first { $0 is UIControl }
216
- let rect = group.convert(group.bounds, to: surface)
217
- let x = anchor?.x ?? 0
218
- let dx = barFrame.minX + (barFrame.width - rect.width) * x - rect.minX
219
- var frame = view.frame
220
- frame.origin.x += dx
221
- frame.size.height += barFrame.maxY - rect.maxY
222
- var targetSearch: CGFloat?
223
- if available {
224
- guard let control = searchControl else { return false }
225
- let target = triggerFrame.midX
226
- targetSearch = target
227
- frame.size.width += target - control.convert(control.bounds, to: surface).midX - dx
388
+ let revealResting = {
389
+ self.view.isHidden = true
390
+ self.idleBar.isHidden = false
391
+ self.idleBar.overrideUserInterfaceStyle = snapshot.dark ? .dark : .light
392
+ self.searchTrigger?.isHidden = !available
393
+ self.searchTrigger?.overrideUserInterfaceStyle = snapshot.dark ? .dark : .light
394
+ }
395
+ let animateChrome = wasActive || (previousAvailable != nil && previousAvailable != available)
396
+ if animateChrome {
397
+ UIView.transition(with: surface, duration: 0.35, options: [.transitionCrossDissolve, .beginFromCurrentState], animations: revealResting)
228
398
  } else {
229
- frame.size.width += barFrame.width - rect.width
399
+ revealResting()
230
400
  }
231
- view.frame = frame
232
- view.layoutIfNeeded()
233
- let final = group.convert(group.bounds, to: surface)
234
- guard abs(final.minX + final.width * x - (barFrame.minX + barFrame.width * x)) <= 1,
235
- abs(final.maxY - barFrame.maxY) <= 1 else { return false }
236
- if let targetSearch,
237
- let control = searchControl {
238
- let rect = control.convert(control.bounds, to: surface)
239
- return abs(rect.midX - targetSearch) <= 1 && abs(rect.midY - triggerFrame.midY) <= 1
401
+ if lastLayout != layout {
402
+ guard ShellTabBar.fit(idleBar, node: snapshot, bounds: localBar) else { return false }
403
+ lastLayout = layout
240
404
  }
241
405
  return true
242
406
  }
243
407
 
244
408
  func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool {
245
409
  guard let configuration else { return false }
246
- let active = configuration.active
247
- if tab === searchTab { activate?(configuration.trigger.id) }
248
- else if active && tab.identifier == selectedID { activate?(configuration.closeId) }
249
- else { activate?(tab.identifier) }
250
- return false
410
+ if tab === searchTab {
411
+ clearPendingSelection()
412
+ activate?(configuration.trigger.id)
413
+ return true
414
+ }
415
+ if configuration.active {
416
+ if tab.identifier == selectedID { activate?(configuration.closeId) }
417
+ else { activate?(tab.identifier) }
418
+ return false
419
+ }
420
+ armPendingSelection(tab.identifier)
421
+ if selectedTab !== tab { selectedTab = tab }
422
+ activate?(tab.identifier)
423
+ return true
251
424
  }
252
425
 
253
426
  private func emit(_ phase: ShellSearchPhase) {
@@ -255,8 +428,14 @@ final class ShellSearchController: UITabBarController, UITabBarControllerDelegat
255
428
  editingSequence = changed?(configuration.id, phase, search.searchBar.text ?? "", search.searchBar.searchTextField.markedTextRange != nil, valueVersion) ?? editingSequence
256
429
  }
257
430
  func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { emit(.input) }
258
- func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { emit(.focus) }
259
- func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { emit(.blur) }
431
+ func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
432
+ if session != .idle { session = .focused }
433
+ emit(.focus)
434
+ }
435
+ func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
436
+ if session == .focused { session = .presented }
437
+ emit(.blur)
438
+ }
260
439
  func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { emit(.commit) }
261
440
  func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
262
441
  closing = true
@@ -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)