@rdlabo/ionic-theme-ios27 1.2.0-beta.pr231.sha22a35d481cf3 → 1.2.0-beta.pr233.sha647eef34274b

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 +9 -0
  2. package/dist/native/components/searchable-tabs.d.ts.map +1 -1
  3. package/dist/native/components/searchable-tabs.js +6 -7
  4. package/dist/native/definitions.d.ts +4 -15
  5. package/dist/native/definitions.d.ts.map +1 -1
  6. package/dist/native/definitions.js +1 -6
  7. package/dist/native/index.d.ts +1 -2
  8. package/dist/native/index.d.ts.map +1 -1
  9. package/dist/native/index.js +57 -27
  10. package/dist/native/runtime.d.ts.map +1 -1
  11. package/dist/native/runtime.js +18 -10
  12. package/dist/native/vertical-bars-web.d.ts.map +1 -1
  13. package/dist/native/vertical-bars-web.js +2 -0
  14. package/dist/tab-bar-searchable/index.d.ts.map +1 -1
  15. package/dist/tab-bar-searchable/index.js +39 -11
  16. package/dist/vertical-bars.d.ts +1 -2
  17. package/dist/vertical-bars.d.ts.map +1 -1
  18. package/dist/vertical-bars.js +0 -1
  19. package/docs/iphone-duo-with-original-theme.md +16 -8
  20. package/docs/iphone-duo.md +57 -44
  21. package/docs/native-ui-shell.md +5 -3
  22. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +1 -1
  23. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +27 -2
  24. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalSearch.swift +118 -0
  25. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +27 -179
  26. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +37 -0
  27. package/package.json +1 -1
  28. package/src/native/components/searchable-tabs.ts +8 -11
  29. package/src/native/definitions.ts +6 -19
  30. package/src/native/index.ts +60 -29
  31. package/src/native/runtime.ts +16 -10
  32. package/src/native/vertical-bars-web.ts +2 -0
  33. package/src/tab-bar-searchable/index.ts +40 -9
  34. package/src/vertical-bars.ts +1 -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,
@@ -57,7 +46,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
57
46
  self.host?.isHidden = true
58
47
  self.verticalBars?.view.isHidden = true
59
48
  }
60
- var searchOwnsKeyboard = false
49
+ var searchOwnsKeyboard = self.verticalBars?.ownsKeyboardChrome == true
61
50
  self.searchControllers.values.forEach { controller in
62
51
  if controller.ownsKeyboardChrome { searchOwnsKeyboard = true }
63
52
  if !keyboard { controller.surface.isHidden = true }
@@ -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()
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
172
- }
93
+ call.resolve(self?.webViewMetrics() ?? ["radius": 0])
173
94
  }
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
95
  }
257
96
 
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,10 +190,12 @@ 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 }
358
197
  let snapshots = snapshot.controls.filter { $0.placement != .verticalBars }
198
+ let verticalSearchActive = verticalBars.contains { $0.search.map { $0.available && $0.active } ?? false }
359
199
  let width = snapshot.viewportWidth
360
200
  self.revision = next
361
201
  if snapshots.isEmpty && verticalBars.isEmpty {
@@ -371,12 +211,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
371
211
  var fabs: [(ShellFab, ShellControl)] = []
372
212
  var searches: [(ShellSearchControlling, ShellControl, CGRect, CGRect, UIView?, Bool)] = []
373
213
  var rejectedSearches: [String] = []
374
- if verticalBars.isEmpty || self.keyboardVisible {
214
+ if verticalBars.isEmpty || (self.keyboardVisible && !verticalSearchActive) {
375
215
  self.verticalBars?.detach()
376
216
  self.verticalBars = nil
377
217
  if self.keyboardVisible { rejectedControls.append(contentsOf: verticalBars.map(\.id)) }
378
218
  } else if let owner = self.bridge?.viewController {
379
- let rail = self.verticalBars ?? ShellVerticalBarsController(activate: { [weak self] id in self?.activate(id) })
219
+ let rail = self.verticalBars ?? ShellVerticalBarsController(
220
+ activate: { [weak self] id in self?.activate(id) },
221
+ changed: { [weak self] id, phase, value, composing, valueVersion in
222
+ self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0
223
+ })
380
224
  self.verticalBars = rail
381
225
  rail.attach(to: owner, in: owner.view)
382
226
  if let frame = snapshot.verticalBarFrame {
@@ -435,11 +279,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
435
279
  controller = ShellSearchController()
436
280
  controller.activate = { [weak self] id in self?.activate(id) }
437
281
  controller.changed = { [weak self] id, phase, value, composing, valueVersion in
438
- guard let self else { return 0 }
439
- self.sequence += 1
440
- self.notifyListeners("search", data: ["id": id, "phase": phase.rawValue, "value": value,
441
- "composing": composing, "valueVersion": valueVersion, "sequence": self.sequence, "revision": self.revision])
442
- return self.sequence
282
+ self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0
443
283
  }
444
284
  controller.attach(to: owner, in: owner.view)
445
285
  self.searchControllers[id] = controller
@@ -540,6 +380,14 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
540
380
  activate(itemId)
541
381
  }
542
382
 
383
+ private func searchChanged(_ id: String, phase: ShellSearchPhase, value: String,
384
+ composing: Bool, valueVersion: Int) -> Int {
385
+ sequence += 1
386
+ notifyListeners("search", data: ["id": id, "phase": phase.rawValue, "value": value,
387
+ "composing": composing, "valueVersion": valueVersion, "sequence": sequence, "revision": revision])
388
+ return sequence
389
+ }
390
+
543
391
  private func activate(_ id: String) {
544
392
  sequence += 1
545
393
  notifyListeners("activate", data: ["id": id, "revision": revision, "sequence": sequence])
@@ -607,4 +607,41 @@ final class ShellSnapshotTests: XCTestCase {
607
607
  XCTAssertEqual(phases, ["input"])
608
608
  }
609
609
 
610
+ func testVerticalSearchBridgesInputFocusAndDismissalWithoutStaleWebEchoes() throws {
611
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native glass search") }
612
+ func node(active: Bool, value: String = "", version: Int = 0) throws -> ShellControl {
613
+ let search: JSObject = ["id": "search", "field": item(),
614
+ "trigger": item(["id": "trigger"]), "closeId": "close", "active": active,
615
+ "available": true, "focused": false, "value": value, "placeholder": "Search",
616
+ "disabled": false, "editSequence": 0, "valueVersion": version]
617
+ return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "placement": "vertical-bars",
618
+ "search": search, "items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
619
+ }
620
+ var actions: [String] = []
621
+ var values: [String] = []
622
+ let rail = UIView()
623
+ let model = ShellVerticalSearchModel(try XCTUnwrap(node(active: false).search), in: rail,
624
+ activate: { actions.append($0) },
625
+ changed: { _, _, value, _, _ in values.append(value); return values.count })
626
+ model.present(true)
627
+ XCTAssertEqual(actions, ["trigger"])
628
+ model.apply(try XCTUnwrap(node(active: true).search))
629
+ XCTAssertTrue(model.presented)
630
+ model.input("native query")
631
+ XCTAssertEqual(values, ["native query"])
632
+ model.apply(try XCTUnwrap(node(active: true).search))
633
+ XCTAssertEqual(model.text, "native query")
634
+ // The first presentation acknowledgement must not undo native focus.
635
+ model.focus(true)
636
+ model.apply(try XCTUnwrap(node(active: true).search))
637
+ XCTAssertTrue(model.focused)
638
+ model.apply(try XCTUnwrap(node(active: true, value: "application", version: 1).search))
639
+ XCTAssertEqual(model.text, "application")
640
+ model.present(false)
641
+ XCTAssertEqual(actions, ["trigger", "close"])
642
+ model.apply(try XCTUnwrap(node(active: false).search))
643
+ XCTAssertFalse(model.presented)
644
+ XCTAssertFalse(model.focused)
645
+ }
646
+
610
647
  }
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-beta.pr231.sha22a35d481cf3",
4
+ "version": "1.2.0-beta.pr233.sha647eef34274b",
5
5
  "description": "iOS27 Theme for Ionic Framework",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
@@ -167,7 +167,12 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
167
167
  keyboard(visible: boolean) {
168
168
  keyboardVisible = visible;
169
169
  if (!visible) {
170
- states.forEach((state) => (state.keyboardHidden = false));
170
+ for (const [binding, state] of states) {
171
+ // Ionic can wait for the pre-keyboard height indefinitely after rotation.
172
+ // Release the visibility override we own when native search's keyboard closes.
173
+ if (state.keyboardHidden) binding.tabBar.classList.remove('tab-bar-hidden');
174
+ state.keyboardHidden = false;
175
+ }
171
176
  releaseKeyboardResize();
172
177
  }
173
178
  schedule();
@@ -202,15 +207,7 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
202
207
  };
203
208
  for (const binding of bindings) {
204
209
  const candidate = candidates.find((c) => c.element === binding.tabBar);
205
- // The vertical rail has no search surface; hiding the FAB/footer there
206
- // would leave searchable tabs without any search control.
207
- if (
208
- !candidate ||
209
- candidate.control.search ||
210
- candidate.control.placement === 'vertical-bars' ||
211
- binding.tabBar.getAttribute('slot') !== 'bottom'
212
- )
213
- continue;
210
+ if (!candidate || candidate.control.search || binding.tabBar.getAttribute('slot') !== 'bottom') continue;
214
211
  const fab = binding.trigger.parentElement;
215
212
  // Search also supports the existing page-level fixed FAB, outside scrolling content.
216
213
  if (
@@ -441,8 +438,8 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
441
438
  },
442
439
  retire(binding: NativeSearchBinding) {
443
440
  const state = states.get(binding);
441
+ // Keep ownership until keyboardWillHide can release this class.
444
442
  if (state?.keyboardHidden && keyboardVisible) binding.tabBar.classList.add('tab-bar-hidden');
445
- if (state) state.keyboardHidden = false;
446
443
  if (state?.focused) {
447
444
  state.focused = false;
448
445
  state.input.dispatchEvent(new FocusEvent('blur'));
@@ -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 {
@@ -163,21 +159,12 @@ export interface WebViewMetrics {
163
159
  radius: number;
164
160
  }
165
161
 
166
- export interface DeviceLayout {
167
- placement: VerticalBarPlacement;
168
- /** Fold hinge posture, or `null` when the device reports no hinge. */
169
- hingeStatus: HingeStatus | null;
170
- webViewMetrics: WebViewMetrics;
171
- }
172
-
173
162
  export interface NativeUIShellPlugin {
174
163
  configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>;
175
- getDeviceLayout(): Promise<DeviceLayout>;
176
- startDeviceLayoutMonitoring(): Promise<void>;
177
- stopDeviceLayoutMonitoring(): Promise<void>;
164
+ getWebViewMetrics(): Promise<WebViewMetrics>;
178
165
  update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>;
179
166
  clear(options: { revision: number }): Promise<void>;
180
167
  addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise<PluginListenerHandle>;
181
168
  addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise<PluginListenerHandle>;
182
- addListener(name: 'deviceLayoutChange', listener: (event: DeviceLayout) => void): Promise<PluginListenerHandle>;
169
+ addListener(name: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise<PluginListenerHandle>;
183
170
  }
@@ -20,13 +20,11 @@ export type {
20
20
  NativeUIShellOptions,
21
21
  NativeUIShellStatus,
22
22
  NativeUIShellSuspension,
23
- DeviceLayout,
24
23
  VerticalBarEdge,
25
24
  VerticalBarPlacement,
26
25
  VerticalControlAreaHandle,
27
26
  WebViewMetrics,
28
27
  } from './definitions';
29
- export { HingeStatus } from './definitions';
30
28
 
31
29
  const plugin = registerPlugin<NativeUIShellPlugin>('IonicNativeUIShell');
32
30
  export const IonicNativeUIShell = plugin;
@@ -98,8 +96,7 @@ const manage = (
98
96
 
99
97
  /** Reads the current native WebView geometry and applies it to page transitions. */
100
98
  export const configureNativeTransition = async (): Promise<WebViewMetrics> => {
101
- const metrics =
102
- typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? (await plugin.getDeviceLayout()).webViewMetrics : { radius: 0 };
99
+ const metrics = typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? await plugin.getWebViewMetrics() : { radius: 0 };
103
100
  setConfig({ radius: metrics.radius });
104
101
  return metrics;
105
102
  };
@@ -110,6 +107,39 @@ const physicalVerticalBarEdge = (edge: Exclude<VerticalBarEdge, null>, rtl: bool
110
107
 
111
108
  const elementRtl = (element: Element): boolean => element.closest('[dir]')?.getAttribute('dir') === 'rtl';
112
109
 
110
+ // Device facts are supplied by the application; the theme only compares placement.
111
+ const nativePlacements = new WeakMap<HTMLElement, { edge: VerticalBarEdge; rtl?: boolean }>();
112
+ const horizontalFallbackAttribute = 'data-native-ui-shell-vertical-bars-suspended';
113
+
114
+ // Keep the requested rail while ordinary Native UI Shell temporarily uses its
115
+ // horizontal layout. Removing the effective class restores normal measurements,
116
+ // toolbar ownership and safe areas throughout the existing rendering pipeline.
117
+ const observeNativeVerticalBarsLayout = (doc: Document): (() => void) => {
118
+ const reconcile = () => {
119
+ const app = doc.querySelector<HTMLElement>('ion-app');
120
+ if (!app) return;
121
+ const requested = app.classList.contains('ios-theme-vertical-bars') || app.hasAttribute(horizontalFallbackAttribute);
122
+ const suspended = requested && nativePlacements.get(app)?.edge == null;
123
+ if (app.hasAttribute(horizontalFallbackAttribute) !== suspended) app.toggleAttribute(horizontalFallbackAttribute, suspended);
124
+ if (app.classList.contains('ios-theme-vertical-bars') !== (requested && !suspended)) {
125
+ app.classList.toggle('ios-theme-vertical-bars', requested && !suspended);
126
+ }
127
+ };
128
+ const observer = new MutationObserver(reconcile);
129
+ observer.observe(doc.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
130
+ doc.defaultView?.addEventListener('nativeUIShellRefresh', reconcile);
131
+ reconcile();
132
+ return () => {
133
+ observer.disconnect();
134
+ doc.defaultView?.removeEventListener('nativeUIShellRefresh', reconcile);
135
+ const app = doc.querySelector<HTMLElement>(`ion-app[${horizontalFallbackAttribute}]`);
136
+ if (app) {
137
+ app.removeAttribute(horizontalFallbackAttribute);
138
+ app.classList.add('ios-theme-vertical-bars');
139
+ }
140
+ };
141
+ };
142
+
113
143
  /**
114
144
  * Applies one placement to the CSS layout and both Web/native projections.
115
145
  * Pass `rtl` when the document direction is known; otherwise the nearest `dir` attribute is used.
@@ -118,11 +148,16 @@ export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | Ver
118
148
  if (typeof document === 'undefined') return;
119
149
  const app = document.querySelector<HTMLElement>('ion-app');
120
150
  if (!app) throw new Error('Vertical Control Area requires ion-app');
121
- const { edge, inset } = placement && typeof placement === 'object' ? placement : { edge: placement, inset: 0 };
151
+ const { edge, inset = 0 } = placement && typeof placement === 'object' ? placement : { edge: placement, inset: 0 };
152
+ if (placement && typeof placement === 'object' && placement.nativeEdge !== undefined) {
153
+ nativePlacements.set(app, { edge: placement.nativeEdge, rtl });
154
+ }
155
+ app.removeAttribute(horizontalFallbackAttribute);
122
156
  app.classList.toggle('ios-theme-vertical-bars', edge !== null);
123
157
  app.classList.toggle('ios-theme-vertical-bars-left', edge !== null && physicalVerticalBarEdge(edge, rtl ?? elementRtl(app)) === 'left');
124
158
  if (edge && Number.isFinite(inset) && inset > 0) app.style.setProperty('--ios-theme-vertical-bars-native-inset', `${inset}px`);
125
159
  else app.style.removeProperty('--ios-theme-vertical-bars-native-inset');
160
+ document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh'));
126
161
  };
127
162
 
128
163
  /** Call once at application startup. Ionic markup remains the source of truth. */
@@ -186,31 +221,27 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
186
221
  (async () => {
187
222
  if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS');
188
223
  let runtime: NativeUIShellHandle | undefined;
189
- let placementListener: Awaited<ReturnType<typeof plugin.addListener>> | undefined;
190
- let monitoring = false;
224
+ let metricsListener: Awaited<ReturnType<typeof plugin.addListener>> | undefined;
225
+ let stopVerticalBarsLayout: (() => void) | undefined;
191
226
  try {
192
- if (!options.verticalBarsOnly) await configureNativeTransition().catch(() => undefined);
193
227
  const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true });
194
228
  if (!capabilities.supported) return fallback('Requires iOS 26 or later');
195
- let nativeEdge: VerticalBarEdge = null;
229
+ if (!options.verticalBarsOnly) stopVerticalBarsLayout = observeNativeVerticalBarsLayout(document);
230
+ // The application owns device state and selects the rail through placement classes.
196
231
  const nativeVerticalBars = () => {
197
- const root = document.querySelector('ion-app.ios-theme-vertical-bars');
198
- if (!root) return false;
199
- // The trait stays unspecified when the OS cannot report a rail — for
200
- // example an app linked against an SDK older than 27.1 — so the DOM
201
- // class is trusted there. When the OS does report an edge, the native
202
- // rail only takes over once the app has applied the matching class.
203
- const domEdge = root.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right';
204
- return nativeEdge === null || physicalVerticalBarEdge(nativeEdge, elementRtl(root)) === domEdge;
232
+ const app = document.querySelector<HTMLElement>('ion-app.ios-theme-vertical-bars');
233
+ if (!app) return false;
234
+ const reported = nativePlacements.get(app);
235
+ if (!reported?.edge) return false;
236
+ const physicalEdge = app.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right';
237
+ return physicalEdge === physicalVerticalBarEdge(reported.edge, reported.rtl ?? elementRtl(app));
205
238
  };
206
- await plugin.startDeviceLayoutMonitoring();
207
- monitoring = true;
208
- placementListener = await plugin.addListener('deviceLayoutChange', ({ placement, webViewMetrics }) => {
209
- nativeEdge = placement.edge;
210
- if (!options.verticalBarsOnly) setConfig({ radius: webViewMetrics.radius });
211
- document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh'));
212
- });
213
- nativeEdge = (await plugin.getDeviceLayout()).placement.edge;
239
+ if (!options.verticalBarsOnly) {
240
+ metricsListener = await plugin.addListener('webViewMetricsChange', (metrics) => {
241
+ setConfig({ radius: metrics.radius });
242
+ });
243
+ await configureNativeTransition().catch(() => undefined);
244
+ }
214
245
  const native = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true);
215
246
  runtime = combine(
216
247
  native,
@@ -220,16 +251,16 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
220
251
  suspend: () => prehide?.suspend(),
221
252
  release,
222
253
  destroy: async () => {
223
- await placementListener?.remove().catch(() => {});
224
- if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {});
254
+ await metricsListener?.remove().catch(() => {});
255
+ stopVerticalBarsLayout?.();
225
256
  prehide?.stop();
226
257
  stopModals();
227
258
  },
228
259
  });
229
260
  } catch (error) {
230
261
  await runtime?.destroy();
231
- await placementListener?.remove().catch(() => {});
232
- if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {});
262
+ await metricsListener?.remove().catch(() => {});
263
+ stopVerticalBarsLayout?.();
233
264
  return fallback(error instanceof Error ? error.message : String(error));
234
265
  }
235
266
  })());
@@ -419,13 +419,14 @@ export const createRuntime = async (
419
419
  verticalBarsMembers.add(element);
420
420
  }
421
421
  for (const element of accepted.flatMap(candidateSources)) {
422
+ const newlyProjected = !sources.has(element) || !element.hasAttribute(marker);
422
423
  if (!sources.has(element)) {
423
424
  sources.set(element, element.getAttribute('aria-hidden'));
424
425
  crossfade.play(element, true, handoffInstant || isVerticalBarsSource(element));
425
- element.setAttribute(marker, '');
426
- element.setAttribute('aria-hidden', 'true');
427
- element.dispatchEvent(new CustomEvent('nativeUIShellChange'));
428
426
  }
427
+ element.setAttribute(marker, '');
428
+ element.setAttribute('aria-hidden', 'true');
429
+ if (newlyProjected) element.dispatchEvent(new CustomEvent('nativeUIShellChange'));
429
430
  }
430
431
  const received = pendingActivations;
431
432
  pendingActivations = [];
@@ -607,24 +608,29 @@ export const createRuntime = async (
607
608
  'focusout',
608
609
  ])
609
610
  on(doc, name);
610
- on(doc, 'visibilitychange', () => {
611
- if (doc.hidden) getNativeSearchBindings(doc).forEach((binding) => search.retire(binding));
611
+ const refreshLayout = () => {
612
+ rejected = new WeakMap();
613
+ forceRefresh = true;
612
614
  schedule();
615
+ };
616
+ on(doc, 'visibilitychange', () => {
617
+ if (doc.hidden) {
618
+ getNativeSearchBindings(doc).forEach((binding) => search.retire(binding));
619
+ schedule();
620
+ } else refreshLayout();
613
621
  });
614
- on(win, 'resize');
622
+ on(win, 'resize', refreshLayout);
615
623
  on(win, 'keyboardWillShow', () => search.keyboard(true));
616
624
  on(win, 'keyboardWillHide', () => search.keyboard(false));
617
625
  on(win, 'scroll');
618
626
  on(win.matchMedia('(prefers-color-scheme: dark)'), 'change');
619
627
  on(win, 'nativeUIShellRefresh', (event) => {
620
- rejected = new WeakMap();
621
628
  if ((event as Event & { retireSearch?: boolean }).retireSearch)
622
629
  getNativeSearchBindings(doc).forEach((binding) => search.retire(binding));
623
- forceRefresh = true;
624
- schedule();
630
+ refreshLayout();
625
631
  });
626
632
  if (win.visualViewport) {
627
- on(win.visualViewport, 'resize');
633
+ on(win.visualViewport, 'resize', refreshLayout);
628
634
  on(win.visualViewport, 'scroll');
629
635
  }
630
636
  const handle: NativeUIShellHandle = {
@@ -293,6 +293,8 @@ export const createVerticalBarsWebProjection = (
293
293
  };
294
294
  const schedule = (): Promise<void> => {
295
295
  if (stopped) return Promise.resolve();
296
+ // Release Web ownership before the native runtime can acknowledge its next frame.
297
+ if (!enabled()) restore();
296
298
  const done = new Promise<void>((resolve) => waiters.push(resolve));
297
299
  if (!frame) frame = win.requestAnimationFrame(update);
298
300
  return done;