@rdlabo/ionic-theme-ios27 1.1.0 → 1.2.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.
Files changed (93) hide show
  1. package/README.md +8 -1
  2. package/dist/css/components/ion-list.css +1 -1
  3. package/dist/css/components/ion-menu.css +1 -1
  4. package/dist/css/ionic-theme-ios27.css +1 -1
  5. package/dist/css/vertical-bars.css +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +15 -8
  8. package/dist/native/components/index.d.ts +1 -0
  9. package/dist/native/components/index.d.ts.map +1 -1
  10. package/dist/native/components/index.js +8 -3
  11. package/dist/native/components/ion-back-button.d.ts.map +1 -1
  12. package/dist/native/components/ion-back-button.js +5 -2
  13. package/dist/native/components/ion-button.d.ts.map +1 -1
  14. package/dist/native/components/ion-button.js +10 -2
  15. package/dist/native/components/ion-buttons.d.ts.map +1 -1
  16. package/dist/native/components/ion-buttons.js +12 -4
  17. package/dist/native/components/ion-fab.js +4 -4
  18. package/dist/native/components/ion-menu-button.d.ts.map +1 -1
  19. package/dist/native/components/ion-menu-button.js +5 -4
  20. package/dist/native/components/searchable-tabs.d.ts.map +1 -1
  21. package/dist/native/components/searchable-tabs.js +4 -1
  22. package/dist/native/definitions.d.ts +29 -3
  23. package/dist/native/definitions.d.ts.map +1 -1
  24. package/dist/native/definitions.js +6 -1
  25. package/dist/native/index.d.ts +6 -2
  26. package/dist/native/index.d.ts.map +1 -1
  27. package/dist/native/index.js +154 -17
  28. package/dist/native/prehide.d.ts +5 -0
  29. package/dist/native/prehide.d.ts.map +1 -0
  30. package/dist/native/prehide.js +246 -0
  31. package/dist/native/runtime.d.ts +1 -1
  32. package/dist/native/runtime.d.ts.map +1 -1
  33. package/dist/native/runtime.js +162 -28
  34. package/dist/native/shared/candidate.d.ts.map +1 -1
  35. package/dist/native/shared/candidate.js +7 -3
  36. package/dist/native/shared/dom.d.ts +33 -2
  37. package/dist/native/shared/dom.d.ts.map +1 -1
  38. package/dist/native/shared/dom.js +171 -10
  39. package/dist/native/vertical-bars-web.d.ts +3 -0
  40. package/dist/native/vertical-bars-web.d.ts.map +1 -0
  41. package/dist/native/vertical-bars-web.js +341 -0
  42. package/dist/native-integration/index.d.ts +3 -2
  43. package/dist/native-integration/index.d.ts.map +1 -1
  44. package/dist/native-integration/index.js +11 -5
  45. package/dist/transition/index.d.ts +2 -2
  46. package/dist/transition/index.d.ts.map +1 -1
  47. package/dist/transition/index.js +1 -1
  48. package/dist/transition/ios.transition.d.ts.map +1 -1
  49. package/dist/transition/ios.transition.js +1 -0
  50. package/dist/utils.d.ts +3 -3
  51. package/dist/utils.d.ts.map +1 -1
  52. package/dist/utils.js +1 -1
  53. package/dist/vertical-bars.d.ts +4 -0
  54. package/dist/vertical-bars.d.ts.map +1 -0
  55. package/dist/vertical-bars.js +2 -0
  56. package/docs/iphone-duo.md +255 -0
  57. package/docs/migration.md +2 -2
  58. package/docs/native-ui-shell.md +15 -3
  59. package/docs/special-markup.md +6 -0
  60. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +15 -1
  61. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +358 -0
  62. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +215 -35
  63. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +10 -1
  64. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +29 -0
  65. package/package.json +6 -2
  66. package/src/index.ts +15 -8
  67. package/src/native/components/index.ts +12 -3
  68. package/src/native/components/ion-back-button.ts +8 -2
  69. package/src/native/components/ion-button.ts +12 -2
  70. package/src/native/components/ion-buttons.ts +9 -4
  71. package/src/native/components/ion-fab.ts +4 -4
  72. package/src/native/components/ion-menu-button.ts +5 -4
  73. package/src/native/components/searchable-tabs.ts +9 -1
  74. package/src/native/definitions.ts +40 -3
  75. package/src/native/index.ts +188 -20
  76. package/src/native/prehide.ts +255 -0
  77. package/src/native/runtime.ts +173 -27
  78. package/src/native/shared/candidate.ts +11 -3
  79. package/src/native/shared/dom.ts +212 -11
  80. package/src/native/vertical-bars-web.ts +374 -0
  81. package/src/native-integration/index.ts +13 -8
  82. package/src/styles/components/ion-list.scss +2 -2
  83. package/src/styles/components/ion-menu.scss +2 -2
  84. package/src/styles/ionic-theme-ios27.scss +1 -0
  85. package/src/styles/vertical-bars.scss +342 -0
  86. package/src/transition/index.ts +11 -6
  87. package/src/transition/ios.transition.ts +1 -0
  88. package/src/utils.ts +8 -8
  89. package/src/vertical-bars.ts +3 -0
  90. package/dist/native/lifecycle.d.ts +0 -4
  91. package/dist/native/lifecycle.d.ts.map +0 -1
  92. package/dist/native/lifecycle.js +0 -34
  93. package/src/native/lifecycle.ts +0 -38
@@ -8,13 +8,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
8
8
  public let jsName = "IonicNativeUIShell"
9
9
  public let pluginMethods: [CAPPluginMethod] = [
10
10
  CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
11
- CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise),
11
+ CAPPluginMethod(name: "getDeviceLayout", returnType: CAPPluginReturnPromise),
12
+ CAPPluginMethod(name: "startDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "stopDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise),
12
14
  CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
13
15
  CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise)
14
16
  ]
15
17
  private var host: ShellHost?
18
+ private var verticalBars: ShellVerticalBarsControlling?
16
19
  private var controls: [String: UIView] = [:]
17
- private var searchControllers: [String: UIViewController] = [:]
20
+ private var searchControllers: [String: ShellSearchControlling] = [:]
18
21
  private var fingerprints: [String: ShellControl] = [:]
19
22
  private let rendering = ShellRendering()
20
23
  private var revision = 0
@@ -24,6 +27,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
24
27
  private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:]
25
28
  private var restoreTopEdge: (() -> Void)?
26
29
  private var observers: [NSObjectProtocol] = []
30
+ private var lastVerticalBarEdge: String?
31
+ private var lastVerticalBarInset: CGFloat = 0
32
+ private var verticalBarPlacementObserved = false
33
+ private weak var observedVerticalBarView: UIView?
34
+ private var unregisterVerticalBarObservation: (() -> Void)?
35
+ private weak var observedHingeView: UIView?
36
+ private var hingeInteraction: UIInteraction?
37
+ private var hingeStatus: String?
38
+ private var deviceLayoutMonitoring = 0
39
+ private var lastDeviceLayout: String?
27
40
 
28
41
  public override func load() {
29
42
  for name in [UIApplication.didEnterBackgroundNotification, UIResponder.keyboardWillChangeFrameNotification, UIResponder.keyboardWillHideNotification,
@@ -40,14 +53,14 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
40
53
  self.keyboardVisible = !overlap.isNull && overlap.width > 0 && overlap.height > 0
41
54
  }
42
55
  }
43
- if !keyboard { self.host?.isHidden = true }
56
+ if !keyboard {
57
+ self.host?.isHidden = true
58
+ self.verticalBars?.view.isHidden = true
59
+ }
44
60
  var searchOwnsKeyboard = false
45
- if #available(iOS 26.0, *) {
46
- self.searchControllers.values.forEach {
47
- guard let controller = $0 as? ShellSearchController else { return }
48
- if controller.ownsKeyboardChrome { searchOwnsKeyboard = true }
49
- if !keyboard { controller.surface.isHidden = true }
50
- }
61
+ self.searchControllers.values.forEach { controller in
62
+ if controller.ownsKeyboardChrome { searchOwnsKeyboard = true }
63
+ if !keyboard { controller.surface.isHidden = true }
51
64
  }
52
65
  if keyboard {
53
66
  if !searchOwnsKeyboard {
@@ -55,6 +68,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
55
68
  }
56
69
  } else if name == UIDevice.orientationDidChangeNotification {
57
70
  self.notifyWebViewMetricsChange()
71
+ self.notifyVerticalBarPlacementChange()
58
72
  }
59
73
  })
60
74
  }
@@ -64,6 +78,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
64
78
  if name == UIResponder.keyboardDidHideNotification { self?.keyboardVisible = false }
65
79
  self?.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh", data: name == UIApplication.didBecomeActiveNotification ? "{\"retireSearch\":true}" : "{}")
66
80
  if name == UIApplication.didBecomeActiveNotification { self?.notifyWebViewMetricsChange() }
81
+ if name == UIApplication.didBecomeActiveNotification { self?.notifyVerticalBarPlacementChange() }
82
+ if name == UIApplication.didBecomeActiveNotification { self?.refreshHingeStatus() }
67
83
  })
68
84
  }
69
85
  }
@@ -72,6 +88,17 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
72
88
  observers.forEach(NotificationCenter.default.removeObserver)
73
89
  }
74
90
 
91
+ private func stopDeviceLayoutObservation() {
92
+ unregisterVerticalBarObservation?()
93
+ unregisterVerticalBarObservation = nil
94
+ observedVerticalBarView = nil
95
+ if let interaction = hingeInteraction {
96
+ observedHingeView?.removeInteraction(interaction)
97
+ }
98
+ hingeInteraction = nil
99
+ observedHingeView = nil
100
+ }
101
+
75
102
  private func webViewMetrics() -> JSObject? {
76
103
  guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil }
77
104
  webView.layoutIfNeeded()
@@ -79,35 +106,168 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
79
106
  }
80
107
 
81
108
  private func notifyWebViewMetricsChange() {
82
- guard let metrics = webViewMetrics() else { return }
83
- notifyListeners("webViewMetricsChange", data: metrics)
109
+ notifyDeviceLayoutChange()
110
+ }
111
+
112
+ private func deviceLayout() -> JSObject {
113
+ var layout: JSObject = ["placement": verticalBarPlacement(),
114
+ "webViewMetrics": webViewMetrics() ?? ["radius": 0]]
115
+ layout["hingeStatus"] = hingeStatus ?? NSNull()
116
+ return layout
117
+ }
118
+
119
+ private func notifyDeviceLayoutChange() {
120
+ guard deviceLayoutMonitoring > 0 else { return }
121
+ let layout = deviceLayout()
122
+ let fingerprint = "\(verticalBarEdge() ?? "none"):\(verticalBarInset(for: verticalBarEdge())):\(hingeStatus ?? "none"):\(webViewMetrics()?["radius"] ?? 0)"
123
+ guard fingerprint != lastDeviceLayout else { return }
124
+ lastDeviceLayout = fingerprint
125
+ notifyListeners("deviceLayoutChange", data: layout)
84
126
  }
85
127
 
86
- @objc func getWebViewMetrics(_ call: CAPPluginCall) {
128
+ @objc func getDeviceLayout(_ call: CAPPluginCall) {
87
129
  DispatchQueue.main.async { [weak self] in
88
- guard #available(iOS 26.0, *) else {
89
- call.resolve(["radius": 0])
90
- return
130
+ self?.observeVerticalBarPlacement()
131
+ self?.observeHingeStatus()
132
+ self?.refreshHingeStatus()
133
+ DispatchQueue.main.async {
134
+ call.resolve(self?.deviceLayout() ?? ["placement": ["edge": NSNull(), "inset": 0], "hingeStatus": NSNull(), "webViewMetrics": ["radius": 0]])
135
+ if self?.deviceLayoutMonitoring == 0 { self?.stopDeviceLayoutObservation() }
91
136
  }
92
- guard let metrics = self?.webViewMetrics() else {
93
- call.reject("WebView unavailable")
94
- return
137
+ }
138
+ }
139
+
140
+ @objc func startDeviceLayoutMonitoring(_ call: CAPPluginCall) {
141
+ DispatchQueue.main.async { [weak self] in
142
+ self?.deviceLayoutMonitoring += 1
143
+ self?.lastDeviceLayout = nil
144
+ self?.observeVerticalBarPlacement()
145
+ self?.observeHingeStatus()
146
+ self?.refreshHingeStatus()
147
+ call.resolve()
148
+ }
149
+ }
150
+
151
+ @objc func stopDeviceLayoutMonitoring(_ call: CAPPluginCall) {
152
+ DispatchQueue.main.async { [weak self] in
153
+ if let self, self.deviceLayoutMonitoring > 0 {
154
+ self.deviceLayoutMonitoring -= 1
155
+ if self.deviceLayoutMonitoring == 0 {
156
+ self.lastDeviceLayout = nil
157
+ self.stopDeviceLayoutObservation()
158
+ }
159
+ }
160
+ call.resolve()
161
+ }
162
+ }
163
+
164
+ // Logical edge in the reading direction, matching UIVerticalBarEdge.
165
+ private func verticalBarEdge() -> String? {
166
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
167
+ if #available(iOS 27.1, *), let webView = bridge?.webView {
168
+ switch webView.traitCollection.verticalBarEdge {
169
+ case .leading: return "leading"
170
+ case .trailing: return "trailing"
171
+ default: break
95
172
  }
96
- call.resolve(metrics)
97
173
  }
174
+ #endif
175
+ // Toolchains older than the verticalBarEdge trait still expose the rail
176
+ // as a deep safe-area inset on the physical edge (iPhone Duo reserves
177
+ // ~80pt; ordinary iPhones stay below 70pt even in landscape).
178
+ guard let webView = bridge?.webView else { return nil }
179
+ webView.layoutIfNeeded()
180
+ let rtl = webView.effectiveUserInterfaceLayoutDirection == .rightToLeft
181
+ if webView.safeAreaInsets.right >= 70 { return rtl ? "leading" : "trailing" }
182
+ if webView.safeAreaInsets.left >= 70 { return rtl ? "trailing" : "leading" }
183
+ return nil
184
+ }
185
+
186
+ private func verticalBarInset(for edge: String?) -> CGFloat {
187
+ guard let edge, let webView = bridge?.webView else { return 0 }
188
+ webView.layoutIfNeeded()
189
+ let rtl = webView.effectiveUserInterfaceLayoutDirection == .rightToLeft
190
+ let physicalRight = (edge == "trailing") != rtl
191
+ return physicalRight ? webView.safeAreaInsets.right : webView.safeAreaInsets.left
192
+ }
193
+
194
+ private func verticalBarPlacement() -> JSObject {
195
+ if let edge = verticalBarEdge() { return ["edge": edge, "inset": Double(verticalBarInset(for: edge))] }
196
+ return ["edge": NSNull(), "inset": 0]
197
+ }
198
+
199
+ private func notifyVerticalBarPlacementChange() {
200
+ let edge = verticalBarEdge()
201
+ let inset = verticalBarInset(for: edge)
202
+ guard !verticalBarPlacementObserved || edge != lastVerticalBarEdge || abs(inset - lastVerticalBarInset) > 0.5 else { return }
203
+ verticalBarPlacementObserved = true
204
+ lastVerticalBarEdge = edge
205
+ lastVerticalBarInset = inset
206
+ notifyDeviceLayoutChange()
207
+ }
208
+
209
+ private func observeVerticalBarPlacement() {
210
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
211
+ if #available(iOS 27.1, *), let webView = bridge?.webView, observedVerticalBarView !== webView {
212
+ unregisterVerticalBarObservation?()
213
+ observedVerticalBarView = webView
214
+ let traits: [UITrait] = [UITraitLayoutDirection.self] + UITraitCollection.systemTraitsAffectingVerticalBarEdge
215
+ let registration = webView.registerForTraitChanges(traits) { [weak self] (_: UIView, _: UITraitCollection) in
216
+ self?.notifyVerticalBarPlacementChange()
217
+ }
218
+ unregisterVerticalBarObservation = { [weak webView] in webView?.unregisterForTraitChanges(registration) }
219
+ }
220
+ #endif
221
+ notifyVerticalBarPlacementChange()
222
+ }
223
+
224
+ private func observeHingeStatus() {
225
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
226
+ if #available(iOS 27.1, *), let webView = bridge?.webView, observedHingeView !== webView {
227
+ if let interaction = hingeInteraction {
228
+ observedHingeView?.removeInteraction(interaction)
229
+ }
230
+ observedHingeView = webView
231
+ let interaction = UIHingeInteraction { [weak self] _, update in
232
+ let status: String?
233
+ switch update.hinge?.status {
234
+ case .closed: status = "closed"
235
+ case .partiallyOpen: status = "partiallyOpen"
236
+ case .fullyOpen: status = "fullyOpen"
237
+ default: status = nil
238
+ }
239
+ guard status != self?.hingeStatus else { return }
240
+ self?.hingeStatus = status
241
+ self?.notifyDeviceLayoutChange()
242
+ }
243
+ hingeInteraction = interaction
244
+ webView.addInteraction(interaction)
245
+ }
246
+ #endif
247
+ }
248
+
249
+ private func refreshHingeStatus() {
250
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
251
+ if #available(iOS 27.1, *), let interaction = hingeInteraction as? UIHingeInteraction {
252
+ interaction.isEnabled = false
253
+ interaction.isEnabled = true
254
+ }
255
+ #endif
98
256
  }
99
257
 
100
258
  @objc func configure(_ call: CAPPluginCall) {
101
259
  DispatchQueue.main.async { [weak self] in
260
+ self?.observeVerticalBarPlacement()
102
261
  // A new JS context starts revision numbering again (live reload / navigation).
103
262
  self?.restoreTopEdge?()
104
263
  self?.restoreTopEdge = nil
105
264
  self?.removeControls()
106
265
  self?.revision = 0
107
266
  if #available(iOS 26.0, *) {
267
+ self?.bridge?.webView?.layoutIfNeeded()
108
268
  // Ionic already paints the header edge; a second native effect can
109
269
  // add a dark scrim when the OS and Web themes differ.
110
- if let effect = self?.bridge?.webView?.scrollView.topEdgeEffect {
270
+ if call.getBool("verticalBarsOnly") != true, let effect = self?.bridge?.webView?.scrollView.topEdgeEffect {
111
271
  let hidden = effect.isHidden
112
272
  effect.isHidden = true
113
273
  self?.restoreTopEdge = { [weak effect] in effect?.isHidden = hidden }
@@ -133,9 +293,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
133
293
  }
134
294
 
135
295
  private func removeControl(_ id: String, duration: TimeInterval = 0) {
136
- if #available(iOS 26.0, *) {
137
- (searchControllers.removeValue(forKey: id) as? ShellSearchController)?.detach()
138
- }
296
+ searchControllers.removeValue(forKey: id)?.detach()
139
297
  if let control = controls.removeValue(forKey: id) { ShellCrossfade.retire(control, duration: duration) }
140
298
  fingerprints.removeValue(forKey: id)
141
299
  pendingTabSelections.removeValue(forKey: id)
@@ -146,6 +304,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
146
304
  Array(controls.keys).forEach { removeControl($0, duration: duration) }
147
305
  host?.removeFromSuperview()
148
306
  host = nil
307
+ verticalBars?.detach()
308
+ verticalBars = nil
149
309
  rendering.clear()
150
310
  pendingTabSelections.removeAll()
151
311
  pendingTabExpiryWorks.values.forEach { $0.cancel() }
@@ -194,19 +354,14 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
194
354
  }
195
355
  let duration = ShellCrossfade.duration(snapshot.transitionDuration)
196
356
  let existing = Set(self.controls.keys)
197
- let snapshots = snapshot.controls
357
+ let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars }
358
+ let snapshots = snapshot.controls.filter { $0.placement != .verticalBars }
198
359
  let width = snapshot.viewportWidth
199
360
  self.revision = next
200
- if snapshots.isEmpty {
361
+ if snapshots.isEmpty && verticalBars.isEmpty {
201
362
  self.removeControls(duration: duration)
202
363
  call.resolve(["revision": next]); return
203
364
  }
204
- let host = self.host ?? ShellHost()
205
- self.host = host
206
- host.frame = parent.bounds
207
- host.autoresizingMask = [.flexibleWidth, .flexibleHeight]
208
- host.backgroundColor = .clear
209
- host.isAccessibilityElement = false
210
365
  let scale = webView.bounds.width / width
211
366
  let retained = Set(snapshots.map(\.id))
212
367
  for id in Array(self.controls.keys) where !retained.contains(id) {
@@ -214,8 +369,33 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
214
369
  }
215
370
  var rejectedControls: [String] = []
216
371
  var fabs: [(ShellFab, ShellControl)] = []
217
- var searches: [(ShellSearchController, ShellControl, CGRect, CGRect, UIView?, Bool)] = []
372
+ var searches: [(ShellSearchControlling, ShellControl, CGRect, CGRect, UIView?, Bool)] = []
218
373
  var rejectedSearches: [String] = []
374
+ if verticalBars.isEmpty || self.keyboardVisible {
375
+ self.verticalBars?.detach()
376
+ self.verticalBars = nil
377
+ if self.keyboardVisible { rejectedControls.append(contentsOf: verticalBars.map(\.id)) }
378
+ } else if let owner = self.bridge?.viewController {
379
+ let rail = self.verticalBars ?? ShellVerticalBarsController(activate: { [weak self] id in self?.activate(id) })
380
+ self.verticalBars = rail
381
+ rail.attach(to: owner, in: owner.view)
382
+ rail.apply(verticalBars, rendering: self.rendering, edge: snapshot.verticalBarEdge ?? "right")
383
+ rail.view.isHidden = false
384
+ } else {
385
+ rejectedControls.append(contentsOf: verticalBars.map(\.id))
386
+ }
387
+ if snapshots.isEmpty {
388
+ self.host?.removeFromSuperview()
389
+ self.host = nil
390
+ call.resolve(["revision": next, "rejectedControls": rejectedControls])
391
+ return
392
+ }
393
+ let host = self.host ?? ShellHost()
394
+ self.host = host
395
+ host.frame = parent.bounds
396
+ host.autoresizingMask = [.flexibleWidth, .flexibleHeight]
397
+ host.backgroundColor = .clear
398
+ host.isAccessibilityElement = false
219
399
  UIView.performWithoutAnimation {
220
400
  if host.superview !== parent { parent.addSubview(host) }
221
401
  for node in snapshots {
@@ -233,7 +413,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
233
413
  rejectedControls.append(id)
234
414
  }
235
415
  // Only native search owns its keyboard; other controls return to Web.
236
- if self.keyboardVisible && (self.searchControllers[id] as? ShellSearchController)?.ownsKeyboard != true {
416
+ if self.keyboardVisible && self.searchControllers[id]?.ownsKeyboard != true {
237
417
  reject(); continue
238
418
  }
239
419
  let local = node.frame.rect
@@ -241,9 +421,9 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
241
421
  width: local.width * scale, height: local.height * scale), to: parent)
242
422
  if let search = node.search {
243
423
  guard let owner = self.bridge?.viewController else { rejectedSearches.append(id); continue }
244
- let controller: ShellSearchController
424
+ let controller: ShellSearchControlling
245
425
  let previousCover = self.controls[id]
246
- let replacing = self.searchControllers[id] as? ShellSearchController
426
+ let replacing = self.searchControllers[id]
247
427
  if let existing = replacing { controller = existing }
248
428
  else {
249
429
  // Keep the ordinary UITabBar cover until the search controller applies.
@@ -11,10 +11,12 @@ struct ShellSnapshot: Decodable {
11
11
  let revision: Int
12
12
  let transitionDuration: Double?
13
13
  let viewportWidth: Double
14
+ let verticalBarEdge: String?
14
15
  let controls: [ShellControl]
15
16
 
16
17
  var isValid: Bool {
17
18
  revision >= 0 && (transitionDuration.map { $0.isFinite && $0 >= 0 && $0 <= 500 } ?? true) && viewportWidth.isFinite && viewportWidth > 0 && controls.count <= 100 &&
19
+ (verticalBarEdge == nil || verticalBarEdge == "left" || verticalBarEdge == "right") &&
18
20
  Set(controls.map(\.id)).count == controls.count && controls.allSatisfy(\.isValid)
19
21
  }
20
22
  }
@@ -31,8 +33,12 @@ struct ShellFrame: Decodable, Equatable {
31
33
  }
32
34
 
33
35
  struct ShellControl: Decodable, Equatable {
36
+ enum Placement: String, Decodable { case verticalBars = "vertical-bars" }
37
+ enum ToolbarSlot: String, Decodable { case start, end }
34
38
  let id: String
35
39
  let kind: ShellComponent
40
+ let placement: Placement?
41
+ let toolbarSlot: ToolbarSlot?
36
42
  let frame: ShellFrame
37
43
  let items: [ShellItem]
38
44
  let dark: Bool
@@ -40,12 +46,14 @@ struct ShellControl: Decodable, Equatable {
40
46
  let tabBarAnchor: ShellTabBar.Anchor?
41
47
  let search: ShellSearch?
42
48
 
43
- private enum CodingKeys: String, CodingKey { case id, kind, items, dark, rtl, tabBarAnchor, search }
49
+ private enum CodingKeys: String, CodingKey { case id, kind, placement, toolbarSlot, items, dark, rtl, tabBarAnchor, search }
44
50
 
45
51
  init(from decoder: Decoder) throws {
46
52
  let values = try decoder.container(keyedBy: CodingKeys.self)
47
53
  id = try values.decode(String.self, forKey: .id)
48
54
  kind = try values.decode(ShellComponent.self, forKey: .kind)
55
+ placement = try values.decodeIfPresent(Placement.self, forKey: .placement)
56
+ toolbarSlot = try values.decodeIfPresent(ToolbarSlot.self, forKey: .toolbarSlot)
49
57
  frame = try ShellFrame(from: decoder)
50
58
  items = try values.decode([ShellItem].self, forKey: .items)
51
59
  dark = try values.decode(Bool.self, forKey: .dark)
@@ -57,6 +65,7 @@ struct ShellControl: Decodable, Equatable {
57
65
  var isValid: Bool {
58
66
  ShellComponents.supported.contains(kind) && !id.isEmpty && frame.isValid && !items.isEmpty && items.count <= 30 &&
59
67
  (!ShellButton.kinds.contains(kind) || items.count == 1) &&
68
+ (toolbarSlot == nil || (placement == .verticalBars && [.button, .buttons, .menuButton].contains(kind))) &&
60
69
  Set(items.map(\.id)).count == items.count && items.allSatisfy(\.isValid) &&
61
70
  (tabBarAnchor.map { kind == .tabBar && $0.isValid } ?? true) &&
62
71
  (search.map { kind == .tabBar && $0.isValid } ?? true)
@@ -37,6 +37,35 @@ final class ShellSnapshotTests: XCTestCase {
37
37
  }
38
38
  }
39
39
 
40
+ @MainActor func testVerticalBarsTabOptimismWaitsForWebAndRollsBackWhenStale() throws {
41
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive tabs") }
42
+ func tabs(_ selected: String, includeRight: Bool = true) throws -> [ShellControl] {
43
+ var items = [item(["id": "left", "selected": selected == "left"])]
44
+ if includeRight {
45
+ items.append(item(["id": "right", "selected": selected == "right",
46
+ "badge": ["value": "3", "color": "rgb(255, 0, 0)", "textColor": "rgb(255, 255, 255)"]]))
47
+ }
48
+ return try decode([control(["kind": "ion-tab-bar", "items": items])]).controls
49
+ }
50
+ let model = ShellVerticalBarsModel()
51
+ let rendering = ShellRendering()
52
+ model.apply(try tabs("left"), rendering: rendering, now: 100)
53
+ XCTAssertEqual(model.tabs.last?.badge?.value, "3")
54
+ model.select("right", now: 100, ttl: 10)
55
+ model.apply(try tabs("left"), rendering: rendering, now: 101)
56
+ XCTAssertEqual(model.selection, "right", "a stale Web echo must not undo the optimistic selection")
57
+ model.apply(try tabs("right"), rendering: rendering, now: 102)
58
+ XCTAssertEqual(model.selection, "right", "the matching Web echo confirms the selection")
59
+
60
+ model.apply(try tabs("left"), rendering: rendering, now: 200)
61
+ model.select("right", now: 200, ttl: 10)
62
+ model.apply(try tabs("left"), rendering: rendering, now: 211)
63
+ XCTAssertEqual(model.selection, "left", "an expired selection rolls back to Web state")
64
+ model.select("right", now: 220, ttl: 10)
65
+ model.apply(try tabs("left", includeRight: false), rendering: rendering, now: 221)
66
+ XCTAssertEqual(model.selection, "left", "a removed target rolls back immediately")
67
+ }
68
+
40
69
  @MainActor func testSegmentSelectionEchoPreservesNativeViewsAndActionsUseUpdatedItems() throws {
41
70
  guard #available(iOS 26.0, *) else { return }
42
71
  func node(_ items: [JSObject]) throws -> ShellControl {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rdlabo/ionic-theme-ios27",
3
3
  "private": false,
4
- "version": "1.1.0",
4
+ "version": "1.2.0-0",
5
5
  "description": "iOS27 Theme for Ionic Framework",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
@@ -16,6 +16,10 @@
16
16
  "./native": {
17
17
  "types": "./dist/native/index.d.ts",
18
18
  "import": "./dist/native/index.js"
19
+ },
20
+ "./vertical-bars": {
21
+ "types": "./dist/vertical-bars.d.ts",
22
+ "import": "./dist/vertical-bars.js"
19
23
  }
20
24
  },
21
25
  "files": [
@@ -31,7 +35,7 @@
31
35
  "prebuild:css": "rdlabo-copy-structured-list src/styles/utils/structured-list.scss",
32
36
  "build:css": "rm -rf dist/css && sass src/styles:dist/css --style=compressed --no-source-map",
33
37
  "build:ts": "tsc",
34
- "docgen": "docgen --project tsconfig.docgen.json --api NativeUIShellHandle --output-readme docs/native-ui-shell.md",
38
+ "docgen": "docgen --project tsconfig.docgen.json --api NativeUIShellHandle --output-readme docs/native-ui-shell.md && docgen --project tsconfig.docgen.json --api VerticalControlAreaHandle --output-readme docs/iphone-duo.md",
35
39
  "build:demo": "npm run build && cd demo && npm install && npm run build -- --configuration=production",
36
40
  "lint": "prettier --check \"./**/*.{scss,ts}\" && prettier --parser angular --check \"./**/*.html\"",
37
41
  "fmt": "prettier --write \"./**/*.{scss,ts}\" && prettier --parser angular --write \"./**/*.html\"",
package/src/index.ts CHANGED
@@ -14,23 +14,30 @@ export const registerTabBarEffect = (targetElement: HTMLElement): registeredEffe
14
14
  const win = targetElement.ownerDocument.defaultView;
15
15
  if (!targetElement.classList.contains('ios') || !win) return undefined;
16
16
  const reducedMotion = win.matchMedia('(prefers-reduced-motion: reduce)');
17
+ const verticalBarsRoot = targetElement.closest('ion-app');
17
18
  let effect: registeredEffect | undefined;
18
19
  const update = () => {
19
20
  effect?.destroy();
20
- effect = reducedMotion.matches
21
- ? undefined
22
- : registerEffect(targetElement, 'ion-tab-button', 'tab-selected', {
23
- small: 'scale(1.1, 1)',
24
- medium: 'scale(1.2)',
25
- large: 'scale(1.15, 1.4)',
26
- xlarge: 'scale(1.3)',
27
- });
21
+ const isVertical =
22
+ !!verticalBarsRoot?.classList.contains('ios-theme-vertical-bars') && !targetElement.closest('ion-menu, ion-modal, ion-popover');
23
+ effect =
24
+ reducedMotion.matches || isVertical
25
+ ? undefined
26
+ : registerEffect(targetElement, 'ion-tab-button', 'tab-selected', {
27
+ small: 'scale(1.1, 1)',
28
+ medium: 'scale(1.2)',
29
+ large: 'scale(1.15, 1.4)',
30
+ xlarge: 'scale(1.3)',
31
+ });
28
32
  };
29
33
  update();
30
34
  reducedMotion.addEventListener('change', update);
35
+ const placementObserver = new win.MutationObserver(update);
36
+ if (verticalBarsRoot) placementObserver.observe(verticalBarsRoot, { attributes: true, attributeFilter: ['class'] });
31
37
  return {
32
38
  destroy: () => {
33
39
  reducedMotion.removeEventListener('change', update);
40
+ placementObserver.disconnect();
34
41
  effect?.destroy();
35
42
  effect = undefined;
36
43
  },
@@ -5,7 +5,7 @@ import * as menuButton from './ion-menu-button';
5
5
  import * as tabBar from './ion-tab-bar';
6
6
  import * as segment from './ion-segment';
7
7
  import * as fab from './ion-fab';
8
- import { visible } from '../shared/dom';
8
+ import { isDisabledButtonGroupChild, isVerticalBarsSource, visible } from '../shared/dom';
9
9
  import type { Candidate, Identify } from '../shared/candidate';
10
10
 
11
11
  // Static composition only. Each component declares its own tag, discovery and reader.
@@ -27,10 +27,19 @@ export const motionSelector = [
27
27
  ...components.filter((component) => 'tracksMotion' in component && component.tracksMotion).map((component) => component.tag),
28
28
  ].join(', ');
29
29
 
30
+ export const isVerticalBarsCandidate = isVerticalBarsSource;
31
+
30
32
  export const readCandidate = (element: HTMLElement, id: Identify): Candidate | undefined => {
31
- if (!element.classList.contains('ios') || !visible(element) || element.closest('ion-modal, ion-popover')) return;
33
+ const verticalBars = isVerticalBarsCandidate(element);
34
+ if ((!element.classList.contains('ios') && !verticalBars) || !visible(element, verticalBars) || element.closest('ion-modal, ion-popover'))
35
+ return;
32
36
  const style = getComputedStyle(element);
33
- if (!style.getPropertyValue('--ios-theme-glass-background-rgb').trim() && !style.getPropertyValue('--ios26-glass-background-rgb').trim())
37
+ if (
38
+ !isDisabledButtonGroupChild(element) &&
39
+ !style.getPropertyValue('--ios-theme-glass-background-rgb').trim() &&
40
+ !style.getPropertyValue('--ios26-glass-background-rgb').trim() &&
41
+ !verticalBars
42
+ )
34
43
  return;
35
44
  if (element.contains(element.ownerDocument.activeElement)) return;
36
45
  return components.find((component) => component.tag === element.localName)?.read(element, id);
@@ -1,12 +1,18 @@
1
1
  import { createCandidate, appendItem } from '../shared/candidate';
2
2
  import type { Candidate, Identify } from '../shared/candidate';
3
- import { inFixedToolbar } from '../shared/dom';
3
+ import { inFixedToolbar, isVerticalBarsSource } from '../shared/dom';
4
4
 
5
5
  export const tag = 'ion-back-button';
6
6
 
7
7
  export const read = (element: HTMLElement, id: Identify): Candidate | undefined => {
8
8
  const button = element as HTMLIonBackButtonElement;
9
- if (!inFixedToolbar(element) || button.icon !== undefined || button.color !== undefined || !button.shadowRoot) return;
9
+ if (
10
+ !button.shadowRoot ||
11
+ button.icon !== undefined ||
12
+ button.color !== undefined ||
13
+ (!isVerticalBarsSource(element) && !inFixedToolbar(element))
14
+ )
15
+ return;
10
16
  const candidate = createCandidate(element, tag, id);
11
17
  const label = button.shadowRoot.querySelector('[part="text"]')?.textContent?.trim() ?? '';
12
18
  return appendItem(candidate, element, id, button.shadowRoot, label) ? candidate : undefined;
@@ -1,12 +1,22 @@
1
1
  import { createCandidate, appendItem } from '../shared/candidate';
2
2
  import type { Candidate, Identify } from '../shared/candidate';
3
- import { inFixedToolbar } from '../shared/dom';
3
+ import { inFixedToolbar, isVerticalBarsToolbarAction, isVerticalBarsToolbarGroup } from '../shared/dom';
4
4
 
5
5
  export const tag = 'ion-button';
6
6
 
7
7
  export const read = (element: HTMLElement, id: Identify): Candidate | undefined => {
8
8
  const button = element as HTMLIonButtonElement;
9
- if (!inFixedToolbar(element) || button.fill !== 'default' || button.classList.contains('ion-color')) return;
9
+ const verticalBars = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest('ion-app.ios-theme-vertical-bars');
10
+ if (verticalBars && element.parentElement && isVerticalBarsToolbarGroup(element.parentElement)) return;
11
+ const fill = button.fill ?? 'default';
12
+ if (
13
+ !inFixedToolbar(element) ||
14
+ (verticalBars && !isVerticalBarsToolbarAction(element)) ||
15
+ (!verticalBars && fill !== 'default') ||
16
+ (verticalBars && !['default', 'clear'].includes(fill)) ||
17
+ button.classList.contains('ion-color')
18
+ )
19
+ return;
10
20
  const candidate = createCandidate(element, tag, id);
11
21
  return appendItem(candidate, element, id) ? candidate : undefined;
12
22
  };
@@ -1,6 +1,6 @@
1
1
  import { createCandidate, appendItem } from '../shared/candidate';
2
2
  import type { Candidate, Identify } from '../shared/candidate';
3
- import { inFixedToolbar } from '../shared/dom';
3
+ import { childElements, verticalBarsToolbarActions, inFixedToolbar, isVerticalBarsToolbarGroup } from '../shared/dom';
4
4
  import * as menuButton from './ion-menu-button';
5
5
 
6
6
  export const tag = 'ion-buttons';
@@ -8,18 +8,23 @@ export const tracksMotion = true;
8
8
 
9
9
  export const read = (element: HTMLElement, id: Identify): Candidate | undefined => {
10
10
  if (!inFixedToolbar(element)) return;
11
- const children = Array.from(element.children) as HTMLElement[];
11
+ let children = childElements(element);
12
12
  if (children.length === 1) return menuButton.read(element, id);
13
+ const verticalBars = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest('ion-app.ios-theme-vertical-bars');
14
+ if (verticalBars && !isVerticalBarsToolbarGroup(element)) return;
15
+ if (verticalBars) children = verticalBarsToolbarActions(element);
13
16
  if (
14
17
  !children.length ||
15
18
  children.some(
16
19
  (child) =>
17
- !child.matches(`${menuButton.tag}.ios`) &&
18
- (!child.matches('ion-button.ios.button-clear') || (child as HTMLIonButtonElement).fill !== 'clear'),
20
+ !child.matches(`${menuButton.tag}${verticalBars ? '' : '.ios'}`) &&
21
+ (!child.matches(`ion-button${verticalBars ? '' : '.ios.button-clear'}`) ||
22
+ !(verticalBars ? ['default', 'clear'] : ['clear']).includes((child as HTMLIonButtonElement).fill ?? 'default')),
19
23
  )
20
24
  )
21
25
  return;
22
26
  const candidate = createCandidate(element, tag, id);
27
+ if (verticalBars && children.length !== childElements(element).length) candidate.sources = children;
23
28
  for (const child of children) {
24
29
  const supported = child.matches(menuButton.tag)
25
30
  ? menuButton.append(candidate, child as HTMLIonMenuButtonElement, id)