@rdlabo/ionic-theme-ios27 1.2.0-0 → 1.2.0-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +12 -2
  2. package/dist/css/components/ion-tabs.css +1 -1
  3. package/dist/css/ionic-theme-ios27.css +1 -1
  4. package/dist/css/vertical-bars.css +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +32 -12
  8. package/dist/native/components/index.d.ts.map +1 -1
  9. package/dist/native/components/index.js +4 -1
  10. package/dist/native/components/ion-button.d.ts.map +1 -1
  11. package/dist/native/components/ion-button.js +3 -3
  12. package/dist/native/components/ion-buttons.d.ts.map +1 -1
  13. package/dist/native/components/ion-buttons.js +3 -2
  14. package/dist/native/definitions.d.ts +8 -15
  15. package/dist/native/definitions.d.ts.map +1 -1
  16. package/dist/native/definitions.js +1 -6
  17. package/dist/native/index.d.ts +1 -2
  18. package/dist/native/index.d.ts.map +1 -1
  19. package/dist/native/index.js +64 -28
  20. package/dist/native/prehide.d.ts.map +1 -1
  21. package/dist/native/prehide.js +6 -4
  22. package/dist/native/runtime.d.ts.map +1 -1
  23. package/dist/native/runtime.js +27 -12
  24. package/dist/native/shared/candidate.d.ts.map +1 -1
  25. package/dist/native/shared/candidate.js +8 -0
  26. package/dist/native/shared/dom.d.ts.map +1 -1
  27. package/dist/native/shared/dom.js +11 -10
  28. package/dist/native/shared/modal.d.ts +8 -0
  29. package/dist/native/shared/modal.d.ts.map +1 -0
  30. package/dist/native/shared/modal.js +143 -0
  31. package/dist/native/vertical-bars-web.d.ts.map +1 -1
  32. package/dist/native/vertical-bars-web.js +11 -3
  33. package/dist/native-integration/index.d.ts.map +1 -1
  34. package/dist/native-integration/index.js +7 -2
  35. package/dist/native-integration/transition.d.ts +3 -0
  36. package/dist/native-integration/transition.d.ts.map +1 -0
  37. package/dist/native-integration/transition.js +7 -0
  38. package/dist/sheets-of-glass/animations.d.ts +13 -4
  39. package/dist/sheets-of-glass/animations.d.ts.map +1 -1
  40. package/dist/sheets-of-glass/animations.js +56 -40
  41. package/dist/sheets-of-glass/index.d.ts +1 -1
  42. package/dist/sheets-of-glass/index.d.ts.map +1 -1
  43. package/dist/sheets-of-glass/index.js +64 -20
  44. package/dist/transition/ios.transition.d.ts.map +1 -1
  45. package/dist/transition/ios.transition.js +2 -3
  46. package/dist/vertical-bars.d.ts +2 -2
  47. package/dist/vertical-bars.d.ts.map +1 -1
  48. package/dist/vertical-bars.js +1 -1
  49. package/docs/iphone-duo-with-original-theme.md +171 -0
  50. package/docs/iphone-duo.md +105 -50
  51. package/docs/migration.md +23 -0
  52. package/docs/native-ui-shell.md +10 -6
  53. package/docs/special-markup.md +3 -14
  54. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +23 -4
  55. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +15 -171
  56. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +5 -0
  57. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +30 -0
  58. package/package.json +1 -1
  59. package/src/index.ts +41 -9
  60. package/src/native/components/index.ts +6 -1
  61. package/src/native/components/ion-button.ts +4 -3
  62. package/src/native/components/ion-buttons.ts +4 -2
  63. package/src/native/definitions.ts +11 -19
  64. package/src/native/index.ts +67 -30
  65. package/src/native/prehide.ts +7 -4
  66. package/src/native/runtime.ts +28 -11
  67. package/src/native/shared/candidate.ts +10 -0
  68. package/src/native/shared/dom.ts +11 -10
  69. package/src/native/shared/modal.ts +157 -0
  70. package/src/native/vertical-bars-web.ts +12 -3
  71. package/src/native-integration/index.ts +8 -2
  72. package/src/native-integration/transition.ts +15 -0
  73. package/src/sheets-of-glass/animations.ts +66 -41
  74. package/src/sheets-of-glass/index.ts +82 -16
  75. package/src/styles/components/ion-tabs.scss +7 -4
  76. package/src/styles/vertical-bars.scss +102 -35
  77. package/src/transition/ios.transition.ts +4 -4
  78. package/src/vertical-bars.ts +2 -2
@@ -8,9 +8,7 @@ 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: "getDeviceLayout", returnType: CAPPluginReturnPromise),
12
- CAPPluginMethod(name: "startDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise),
13
- CAPPluginMethod(name: "stopDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise),
11
+ CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise),
14
12
  CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
15
13
  CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise)
16
14
  ]
@@ -27,16 +25,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
27
25
  private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:]
28
26
  private var restoreTopEdge: (() -> Void)?
29
27
  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?
28
+ private var lastWebViewRadius: Double?
40
29
 
41
30
  public override func load() {
42
31
  for name in [UIApplication.didEnterBackgroundNotification, UIResponder.keyboardWillChangeFrameNotification, UIResponder.keyboardWillHideNotification,
@@ -68,7 +57,6 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
68
57
  }
69
58
  } else if name == UIDevice.orientationDidChangeNotification {
70
59
  self.notifyWebViewMetricsChange()
71
- self.notifyVerticalBarPlacementChange()
72
60
  }
73
61
  })
74
62
  }
@@ -78,8 +66,6 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
78
66
  if name == UIResponder.keyboardDidHideNotification { self?.keyboardVisible = false }
79
67
  self?.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh", data: name == UIApplication.didBecomeActiveNotification ? "{\"retireSearch\":true}" : "{}")
80
68
  if name == UIApplication.didBecomeActiveNotification { self?.notifyWebViewMetricsChange() }
81
- if name == UIApplication.didBecomeActiveNotification { self?.notifyVerticalBarPlacementChange() }
82
- if name == UIApplication.didBecomeActiveNotification { self?.refreshHingeStatus() }
83
69
  })
84
70
  }
85
71
  }
@@ -88,17 +74,6 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
88
74
  observers.forEach(NotificationCenter.default.removeObserver)
89
75
  }
90
76
 
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
-
102
77
  private func webViewMetrics() -> JSObject? {
103
78
  guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil }
104
79
  webView.layoutIfNeeded()
@@ -106,158 +81,21 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
106
81
  }
107
82
 
108
83
  private func notifyWebViewMetricsChange() {
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)
126
- }
127
-
128
- @objc func getDeviceLayout(_ call: CAPPluginCall) {
129
- DispatchQueue.main.async { [weak self] in
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() }
136
- }
137
- }
84
+ let metrics = webViewMetrics() ?? ["radius": 0]
85
+ let radius = metrics["radius"] as? Double ?? 0
86
+ guard radius != lastWebViewRadius else { return }
87
+ lastWebViewRadius = radius
88
+ notifyListeners("webViewMetricsChange", data: metrics)
138
89
  }
139
90
 
140
- @objc func startDeviceLayoutMonitoring(_ call: CAPPluginCall) {
91
+ @objc func getWebViewMetrics(_ call: CAPPluginCall) {
141
92
  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()
93
+ call.resolve(self?.webViewMetrics() ?? ["radius": 0])
161
94
  }
162
95
  }
163
96
 
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
172
- }
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
256
- }
257
-
258
97
  @objc func configure(_ call: CAPPluginCall) {
259
98
  DispatchQueue.main.async { [weak self] in
260
- self?.observeVerticalBarPlacement()
261
99
  // A new JS context starts revision numbering again (live reload / navigation).
262
100
  self?.restoreTopEdge?()
263
101
  self?.restoreTopEdge = nil
@@ -352,6 +190,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
352
190
  guard let snapshot = try? call.decode(ShellSnapshot.self), snapshot.isValid else {
353
191
  call.reject("Invalid control snapshot"); return
354
192
  }
193
+ self.notifyWebViewMetricsChange()
355
194
  let duration = ShellCrossfade.duration(snapshot.transitionDuration)
356
195
  let existing = Set(self.controls.keys)
357
196
  let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars }
@@ -379,6 +218,11 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
379
218
  let rail = self.verticalBars ?? ShellVerticalBarsController(activate: { [weak self] id in self?.activate(id) })
380
219
  self.verticalBars = rail
381
220
  rail.attach(to: owner, in: owner.view)
221
+ if let frame = snapshot.verticalBarFrame {
222
+ rail.view.frame = webView.convert(frame.rect.applying(CGAffineTransform(scaleX: scale, y: scale)), to: owner.view)
223
+ } else {
224
+ rail.view.frame = owner.view.bounds
225
+ }
382
226
  rail.apply(verticalBars, rendering: self.rendering, edge: snapshot.verticalBarEdge ?? "right")
383
227
  rail.view.isHidden = false
384
228
  } else {
@@ -11,11 +11,13 @@ struct ShellSnapshot: Decodable {
11
11
  let revision: Int
12
12
  let transitionDuration: Double?
13
13
  let viewportWidth: Double
14
+ let verticalBarFrame: ShellFrame?
14
15
  let verticalBarEdge: String?
15
16
  let controls: [ShellControl]
16
17
 
17
18
  var isValid: Bool {
18
19
  revision >= 0 && (transitionDuration.map { $0.isFinite && $0 >= 0 && $0 <= 500 } ?? true) && viewportWidth.isFinite && viewportWidth > 0 && controls.count <= 100 &&
20
+ (verticalBarFrame.map { $0.isValid } ?? true) &&
19
21
  (verticalBarEdge == nil || verticalBarEdge == "left" || verticalBarEdge == "right") &&
20
22
  Set(controls.map(\.id)).count == controls.count && controls.allSatisfy(\.isValid)
21
23
  }
@@ -105,6 +107,9 @@ struct ShellItemContent: Decodable, Equatable {
105
107
  let fontSize: Double
106
108
  let fontWeight: Double
107
109
  let color: String
110
+ let backgroundColor: String?
111
+ let borderColor: String?
112
+ let borderWidth: CGFloat?
108
113
  let badge: ShellBadge?
109
114
  var icon: String?
110
115
  var iconWidth: Double?
@@ -22,6 +22,18 @@ final class ShellSnapshotTests: XCTestCase {
22
22
  try JSValueDecoder().decode(ShellSnapshot.self, from: ["revision": 1, "viewportWidth": width, "controls": controls])
23
23
  }
24
24
 
25
+ func testModalRailFrameIsOptionalAndRequiresPositiveBounds() throws {
26
+ var payload: JSObject = ["revision": 1, "viewportWidth": 466.0, "controls": [control()]]
27
+ let page = try JSValueDecoder().decode(ShellSnapshot.self, from: payload)
28
+ XCTAssertNil(page.verticalBarFrame)
29
+ for height in [339.0, 0.0, -1.0] {
30
+ payload["verticalBarFrame"] = ["x": 0.0, "y": 339.0, "width": 466.0, "height": height]
31
+ let modal = try JSValueDecoder().decode(ShellSnapshot.self, from: payload)
32
+ XCTAssertEqual(modal.isValid, height > 0)
33
+ XCTAssertEqual(modal.verticalBarFrame?.y, 339)
34
+ }
35
+ }
36
+
25
37
  @MainActor func testTabTypographyPreservesCSSWeightsAndSize() throws {
26
38
  let weights: [UIFont.Weight] = [.ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .black]
27
39
  let tab = UITabBarItem()
@@ -37,6 +49,24 @@ final class ShellSnapshotTests: XCTestCase {
37
49
  }
38
50
  }
39
51
 
52
+ @MainActor func testVerticalBarsPreserveButtonAppearance() throws {
53
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive controls") }
54
+ let controls = try decode([control(["kind": "ion-buttons", "items": [
55
+ item(["backgroundColor": "rgb(0, 122, 255)"]),
56
+ item(["id": "outline", "borderColor": "rgb(255, 0, 0)", "borderWidth": 2.0])
57
+ ]])]).controls
58
+ XCTAssertEqual(controls.first?.items.first?.content.backgroundColor, "rgb(0, 122, 255)")
59
+ let model = ShellVerticalBarsModel()
60
+ model.apply(controls, rendering: ShellRendering())
61
+ XCTAssertNotNil(model.groups.first?.items.first?.background)
62
+ XCTAssertNotNil(model.groups.first?.items.last?.borderColor)
63
+ XCTAssertEqual(model.groups.first?.items.last?.borderWidth, 2)
64
+ model.apply(try decode([control(["kind": "ion-button"])]).controls, rendering: ShellRendering())
65
+ XCTAssertNil(model.groups.first?.items.first?.background)
66
+ XCTAssertNil(model.groups.first?.items.first?.borderColor)
67
+ XCTAssertEqual(model.groups.first?.items.first?.borderWidth, 0)
68
+ }
69
+
40
70
  @MainActor func testVerticalBarsTabOptimismWaitsForWebAndRollsBackWhenStale() throws {
41
71
  guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive tabs") }
42
72
  func tabs(_ selected: String, includeRight: Bool = true) 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.2.0-0",
4
+ "version": "1.2.0-2",
5
5
  "description": "iOS27 Theme for Ionic Framework",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  export * from './tab-bar-searchable';
9
9
  export * from './searchbar';
10
10
  export * from './transition/ios.transition';
11
+ export { withNativeUIShellTransition } from './native-integration/transition';
11
12
  export { registerSegmentEffect } from './segment';
12
13
 
13
14
  export const registerTabBarEffect = (targetElement: HTMLElement): registeredEffect | undefined => {
@@ -16,24 +17,55 @@ export const registerTabBarEffect = (targetElement: HTMLElement): registeredEffe
16
17
  const reducedMotion = win.matchMedia('(prefers-reduced-motion: reduce)');
17
18
  const verticalBarsRoot = targetElement.closest('ion-app');
18
19
  let effect: registeredEffect | undefined;
20
+ let placement: 'disabled' | 'horizontal' | 'left' | 'right' | undefined;
19
21
  const update = () => {
20
- effect?.destroy();
21
22
  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', {
23
+ !!verticalBarsRoot?.classList.contains('ios-theme-vertical-bars') &&
24
+ targetElement.matches('ion-tabs > ion-tab-bar:not(.ios-theme-disabled, .ios26-disabled)') &&
25
+ !targetElement.closest('ion-menu, ion-modal, ion-popover');
26
+ const disabled = reducedMotion.matches || !targetElement.matches('.ios:not(.ios-theme-disabled, .ios26-disabled)');
27
+ const nextPlacement = disabled
28
+ ? 'disabled'
29
+ : isVertical
30
+ ? verticalBarsRoot?.classList.contains('ios-theme-vertical-bars-left')
31
+ ? 'left'
32
+ : 'right'
33
+ : 'horizontal';
34
+ // Gesture/activation classes also change during interaction. Rebuild only when
35
+ // eligibility or placement changes, retaining cancellation when the rail changes sides.
36
+ if (placement === nextPlacement) return;
37
+ placement = nextPlacement;
38
+ effect?.destroy();
39
+ effect = disabled
40
+ ? undefined
41
+ : registerEffect(
42
+ targetElement,
43
+ 'ion-tab-button',
44
+ 'tab-selected',
45
+ {
27
46
  small: 'scale(1.1, 1)',
28
47
  medium: 'scale(1.2)',
29
48
  large: 'scale(1.15, 1.4)',
30
49
  xlarge: 'scale(1.3)',
31
- });
50
+ },
51
+ isVertical,
52
+ );
32
53
  };
33
54
  update();
34
55
  reducedMotion.addEventListener('change', update);
35
- const placementObserver = new win.MutationObserver(update);
36
- if (verticalBarsRoot) placementObserver.observe(verticalBarsRoot, { attributes: true, attributeFilter: ['class'] });
56
+ const placementObserver = new win.MutationObserver((records) => {
57
+ if (
58
+ records.some((record) =>
59
+ record.type === 'attributes'
60
+ ? record.target === targetElement || record.target === verticalBarsRoot
61
+ : [...Array.from(record.addedNodes), ...Array.from(record.removedNodes)].some((node) => node.contains(targetElement)),
62
+ )
63
+ )
64
+ update();
65
+ });
66
+ if (verticalBarsRoot)
67
+ placementObserver.observe(verticalBarsRoot, { attributes: true, attributeFilter: ['class'], childList: true, subtree: true });
68
+ placementObserver.observe(targetElement, { attributes: true, attributeFilter: ['class'] });
37
69
  return {
38
70
  destroy: () => {
39
71
  reducedMotion.removeEventListener('change', update);
@@ -31,7 +31,12 @@ export const isVerticalBarsCandidate = isVerticalBarsSource;
31
31
 
32
32
  export const readCandidate = (element: HTMLElement, id: Identify): Candidate | undefined => {
33
33
  const verticalBars = isVerticalBarsCandidate(element);
34
- if ((!element.classList.contains('ios') && !verticalBars) || !visible(element, verticalBars) || element.closest('ion-modal, ion-popover'))
34
+ if (
35
+ (!element.classList.contains('ios') && !verticalBars) ||
36
+ !visible(element, verticalBars) ||
37
+ element.closest('ion-popover') ||
38
+ (element.closest('ion-modal') && !verticalBars)
39
+ )
35
40
  return;
36
41
  const style = getComputedStyle(element);
37
42
  if (
@@ -1,3 +1,4 @@
1
+ import { modalUsesVerticalBars } from '../shared/modal';
1
2
  import { createCandidate, appendItem } from '../shared/candidate';
2
3
  import type { Candidate, Identify } from '../shared/candidate';
3
4
  import { inFixedToolbar, isVerticalBarsToolbarAction, isVerticalBarsToolbarGroup } from '../shared/dom';
@@ -6,15 +7,15 @@ export const tag = 'ion-button';
6
7
 
7
8
  export const read = (element: HTMLElement, id: Identify): Candidate | undefined => {
8
9
  const button = element as HTMLIonButtonElement;
9
- const verticalBars = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest('ion-app.ios-theme-vertical-bars');
10
+ const verticalBars =
11
+ !element.closest('ion-menu, ion-popover') && modalUsesVerticalBars(element) && !!element.closest('ion-app.ios-theme-vertical-bars');
10
12
  if (verticalBars && element.parentElement && isVerticalBarsToolbarGroup(element.parentElement)) return;
11
13
  const fill = button.fill ?? 'default';
12
14
  if (
13
15
  !inFixedToolbar(element) ||
14
16
  (verticalBars && !isVerticalBarsToolbarAction(element)) ||
15
17
  (!verticalBars && fill !== 'default') ||
16
- (verticalBars && !['default', 'clear'].includes(fill)) ||
17
- button.classList.contains('ion-color')
18
+ (!verticalBars && button.classList.contains('ion-color'))
18
19
  )
19
20
  return;
20
21
  const candidate = createCandidate(element, tag, id);
@@ -1,3 +1,4 @@
1
+ import { modalUsesVerticalBars } from '../shared/modal';
1
2
  import { createCandidate, appendItem } from '../shared/candidate';
2
3
  import type { Candidate, Identify } from '../shared/candidate';
3
4
  import { childElements, verticalBarsToolbarActions, inFixedToolbar, isVerticalBarsToolbarGroup } from '../shared/dom';
@@ -10,7 +11,8 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined
10
11
  if (!inFixedToolbar(element)) return;
11
12
  let children = childElements(element);
12
13
  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
+ const verticalBars =
15
+ !element.closest('ion-menu, ion-popover') && modalUsesVerticalBars(element) && !!element.closest('ion-app.ios-theme-vertical-bars');
14
16
  if (verticalBars && !isVerticalBarsToolbarGroup(element)) return;
15
17
  if (verticalBars) children = verticalBarsToolbarActions(element);
16
18
  if (
@@ -19,7 +21,7 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined
19
21
  (child) =>
20
22
  !child.matches(`${menuButton.tag}${verticalBars ? '' : '.ios'}`) &&
21
23
  (!child.matches(`ion-button${verticalBars ? '' : '.ios.button-clear'}`) ||
22
- !(verticalBars ? ['default', 'clear'] : ['clear']).includes((child as HTMLIonButtonElement).fill ?? 'default')),
24
+ (!verticalBars && (child as HTMLIonButtonElement).fill !== 'clear')),
23
25
  )
24
26
  )
25
27
  return;
@@ -44,14 +44,10 @@ export type VerticalBarEdge = 'leading' | 'trailing' | null;
44
44
 
45
45
  export interface VerticalBarPlacement {
46
46
  edge: VerticalBarEdge;
47
- /** UIKit safe-area inset on the vertical-bar edge, in points. */
48
- inset: number;
49
- }
50
-
51
- export enum HingeStatus {
52
- Closed = 'closed',
53
- PartiallyOpen = 'partiallyOpen',
54
- FullyOpen = 'fullyOpen',
47
+ /** Explicit rail width in CSS pixels; omitted to use the stylesheet's safe-area rules. */
48
+ inset?: number;
49
+ /** Native logical edge reported by the application's device plugin. Null or an unregistered edge uses a Web rail in verticalBarsOnly mode, or the ordinary Native UI Shell layout otherwise. Omission keeps the last supplied value. */
50
+ nativeEdge?: VerticalBarEdge;
55
51
  }
56
52
 
57
53
  export interface VerticalControlAreaHandle extends NativeUIShellHandle {
@@ -86,6 +82,9 @@ export interface ShellItem extends Frame {
86
82
  fontSize: number;
87
83
  fontWeight: number;
88
84
  color: string;
85
+ backgroundColor?: string;
86
+ borderColor?: string;
87
+ borderWidth?: number;
89
88
  badge?: ShellBadge;
90
89
  icon?: string;
91
90
  iconWidth?: number;
@@ -144,6 +143,8 @@ export interface ShellSnapshot {
144
143
  viewportWidth: number;
145
144
  /** Physical side the native rail is drawn on; the runtime resolves the logical edge through the document direction. */
146
145
  verticalBarEdge?: 'left' | 'right';
146
+ /** Visible foreground modal bounds; omitted for the full page. */
147
+ verticalBarFrame?: Frame;
147
148
  controls: ShellControl[];
148
149
  }
149
150
 
@@ -158,21 +159,12 @@ export interface WebViewMetrics {
158
159
  radius: number;
159
160
  }
160
161
 
161
- export interface DeviceLayout {
162
- placement: VerticalBarPlacement;
163
- /** Fold hinge posture, or `null` when the device reports no hinge. */
164
- hingeStatus: HingeStatus | null;
165
- webViewMetrics: WebViewMetrics;
166
- }
167
-
168
162
  export interface NativeUIShellPlugin {
169
163
  configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>;
170
- getDeviceLayout(): Promise<DeviceLayout>;
171
- startDeviceLayoutMonitoring(): Promise<void>;
172
- stopDeviceLayoutMonitoring(): Promise<void>;
164
+ getWebViewMetrics(): Promise<WebViewMetrics>;
173
165
  update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>;
174
166
  clear(options: { revision: number }): Promise<void>;
175
167
  addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise<PluginListenerHandle>;
176
168
  addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise<PluginListenerHandle>;
177
- addListener(name: 'deviceLayoutChange', listener: (event: DeviceLayout) => void): Promise<PluginListenerHandle>;
169
+ addListener(name: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise<PluginListenerHandle>;
178
170
  }