@rdlabo/ionic-theme-ios27 1.2.0 → 1.2.1-beta.pr273.shae866fb1dca9f

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 (121) hide show
  1. package/dist/css/components/ion-button.css +1 -1
  2. package/dist/css/components/ion-toolbar.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/native/components/index.js +2 -2
  6. package/dist/native/components/index.js.map +1 -1
  7. package/dist/native/definitions.d.ts +32 -0
  8. package/dist/native/definitions.d.ts.map +1 -1
  9. package/dist/native/index.d.ts.map +1 -1
  10. package/dist/native/index.js +23 -6
  11. package/dist/native/index.js.map +1 -1
  12. package/dist/native/overlays/content.d.ts +4 -0
  13. package/dist/native/overlays/content.d.ts.map +1 -0
  14. package/dist/native/overlays/content.js +51 -0
  15. package/dist/native/overlays/content.js.map +1 -0
  16. package/dist/native/overlays/controller.d.ts +8 -0
  17. package/dist/native/overlays/controller.d.ts.map +1 -0
  18. package/dist/native/overlays/controller.js +266 -0
  19. package/dist/native/overlays/controller.js.map +1 -0
  20. package/dist/native/overlays/dialog-animation.d.ts +8 -0
  21. package/dist/native/overlays/dialog-animation.d.ts.map +1 -0
  22. package/dist/native/overlays/dialog-animation.js +31 -0
  23. package/dist/native/overlays/dialog-animation.js.map +1 -0
  24. package/dist/native/overlays/dismiss-animation.d.ts +4 -0
  25. package/dist/native/overlays/dismiss-animation.d.ts.map +1 -0
  26. package/dist/native/overlays/dismiss-animation.js +18 -0
  27. package/dist/native/overlays/dismiss-animation.js.map +1 -0
  28. package/dist/native/overlays/focus.d.ts +5 -0
  29. package/dist/native/overlays/focus.d.ts.map +1 -0
  30. package/dist/native/overlays/focus.js +59 -0
  31. package/dist/native/overlays/focus.js.map +1 -0
  32. package/dist/native/overlays/popover.d.ts +11 -0
  33. package/dist/native/overlays/popover.d.ts.map +1 -0
  34. package/dist/native/overlays/popover.js +78 -0
  35. package/dist/native/overlays/popover.js.map +1 -0
  36. package/dist/native/overlays/styles.d.ts +9 -0
  37. package/dist/native/overlays/styles.d.ts.map +1 -0
  38. package/dist/native/overlays/styles.js +82 -0
  39. package/dist/native/overlays/styles.js.map +1 -0
  40. package/dist/native/prehide.d.ts.map +1 -1
  41. package/dist/native/prehide.js +6 -2
  42. package/dist/native/prehide.js.map +1 -1
  43. package/dist/native/runtime.d.ts +4 -1
  44. package/dist/native/runtime.d.ts.map +1 -1
  45. package/dist/native/runtime.js +29 -8
  46. package/dist/native/runtime.js.map +1 -1
  47. package/dist/native/shared/dom.d.ts +2 -0
  48. package/dist/native/shared/dom.d.ts.map +1 -1
  49. package/dist/native/shared/dom.js +5 -1
  50. package/dist/native/shared/dom.js.map +1 -1
  51. package/dist/native/shared/projection-id.d.ts +4 -0
  52. package/dist/native/shared/projection-id.d.ts.map +1 -0
  53. package/dist/native/shared/projection-id.js +7 -0
  54. package/dist/native/shared/projection-id.js.map +1 -0
  55. package/dist/native/vertical-bars-web.d.ts.map +1 -1
  56. package/dist/native/vertical-bars-web.js +4 -2
  57. package/dist/native/vertical-bars-web.js.map +1 -1
  58. package/dist/native-integration/index.d.ts +1 -1
  59. package/dist/native-integration/index.js.map +1 -1
  60. package/dist/native-integration/transition.d.ts +1 -1
  61. package/dist/native-integration/transition.js.map +1 -1
  62. package/dist/sheets-of-glass/animations.d.ts +1 -1
  63. package/dist/sheets-of-glass/animations.js +1 -1
  64. package/dist/sheets-of-glass/animations.js.map +1 -1
  65. package/dist/sheets-of-glass/index.js +1 -1
  66. package/dist/sheets-of-glass/index.js.map +1 -1
  67. package/dist/tab-bar-searchable/animations/enter.d.ts +1 -1
  68. package/dist/tab-bar-searchable/animations/enter.d.ts.map +1 -1
  69. package/dist/tab-bar-searchable/animations/enter.js +1 -1
  70. package/dist/tab-bar-searchable/animations/enter.js.map +1 -1
  71. package/dist/tab-bar-searchable/animations/leave.d.ts +1 -1
  72. package/dist/tab-bar-searchable/animations/leave.d.ts.map +1 -1
  73. package/dist/tab-bar-searchable/animations/leave.js +1 -1
  74. package/dist/tab-bar-searchable/animations/leave.js.map +1 -1
  75. package/dist/tab-bar-searchable/index.js +1 -1
  76. package/dist/tab-bar-searchable/index.js.map +1 -1
  77. package/dist/transition/index.d.ts +1 -1
  78. package/dist/transition/index.js +1 -1
  79. package/dist/transition/index.js.map +1 -1
  80. package/dist/transition/ios.transition.d.ts +1 -1
  81. package/dist/transition/ios.transition.js.map +1 -1
  82. package/dist/utils.d.ts +1 -1
  83. package/dist/utils.js.map +1 -1
  84. package/docs/native-ui-shell.md +20 -4
  85. package/docs/vertical-bars.md +3 -1
  86. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +65 -91
  87. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabBar.swift +9 -3
  88. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +79 -3
  89. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +57 -1
  90. package/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift +116 -0
  91. package/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift +36 -0
  92. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +27 -0
  93. package/package.json +2 -2
  94. package/src/native/components/index.ts +3 -2
  95. package/src/native/definitions.ts +27 -1
  96. package/src/native/index.ts +23 -6
  97. package/src/native/overlays/content.ts +51 -0
  98. package/src/native/overlays/controller.ts +322 -0
  99. package/src/native/overlays/dialog-animation.ts +30 -0
  100. package/src/native/overlays/dismiss-animation.ts +15 -0
  101. package/src/native/overlays/focus.ts +64 -0
  102. package/src/native/overlays/popover.ts +80 -0
  103. package/src/native/overlays/styles.ts +80 -0
  104. package/src/native/prehide.ts +6 -1
  105. package/src/native/runtime.ts +39 -15
  106. package/src/native/shared/dom.ts +14 -0
  107. package/src/native/shared/projection-id.ts +2 -0
  108. package/src/native/vertical-bars-web.ts +11 -1
  109. package/src/native-integration/index.ts +1 -1
  110. package/src/native-integration/transition.ts +1 -1
  111. package/src/sheets-of-glass/animations.ts +2 -2
  112. package/src/sheets-of-glass/index.ts +2 -2
  113. package/src/styles/components/ion-button.scss +47 -0
  114. package/src/styles/components/ion-toolbar.scss +62 -7
  115. package/src/styles/vertical-bars.scss +23 -0
  116. package/src/tab-bar-searchable/animations/enter.ts +2 -1
  117. package/src/tab-bar-searchable/animations/leave.ts +2 -1
  118. package/src/tab-bar-searchable/index.ts +1 -1
  119. package/src/transition/index.ts +3 -3
  120. package/src/transition/ios.transition.ts +1 -1
  121. package/src/utils.ts +1 -1
@@ -7,6 +7,7 @@ protocol ShellVerticalBarsControlling: AnyObject {
7
7
  func attach(to owner: UIViewController, in parent: UIView)
8
8
  @MainActor func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String)
9
9
  var ownsKeyboardChrome: Bool { get }
10
+ func presentPopover(anchor: String, host: ShellOverlayHost, completion: @escaping (Bool) -> Void) -> Bool
10
11
  func detach()
11
12
  }
12
13
 
@@ -32,6 +33,9 @@ final class ShellVerticalBarsModel: ObservableObject {
32
33
  let slot: ShellControl.ToolbarSlot?
33
34
  }
34
35
 
36
+ @Published var popoverAnchor: String?
37
+ var popoverHost: ShellOverlayHost?
38
+ var popoverPresented: ((Bool) -> Void)?
35
39
  @Published var search: ShellVerticalSearchModel?
36
40
  @Published var back: Item?
37
41
  @Published var groups: [Group] = []
@@ -45,9 +49,11 @@ final class ShellVerticalBarsModel: ObservableObject {
45
49
  func apply(_ controls: [ShellControl], rendering: ShellRendering,
46
50
  now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) {
47
51
  func item(_ source: ShellItem) -> Item {
48
- Item(id: source.id, label: source.content.label,
52
+ let image = rendering.image(source.content)
53
+ return Item(id: source.id, label: source.content.label,
49
54
  accessibilityLabel: source.content.accessibilityLabel,
50
- image: rendering.image(source.content),
55
+ // UIKit toolbar extraction uses the UIImage's rendering mode.
56
+ image: source.content.disabled ? image?.withRenderingMode(.alwaysTemplate) : image,
51
57
  clear: source.content.buttonFill == .clear,
52
58
  background: source.content.backgroundColor.map { Color(uiColor: rendering.color($0)) },
53
59
  borderColor: source.content.borderColor.map { Color(uiColor: rendering.color($0)) },
@@ -127,6 +133,13 @@ private func verticalBarsButton(_ item: ShellVerticalBarsModel.Item, model: Shel
127
133
  }
128
134
  }
129
135
 
136
+ @available(iOS 26.0, *)
137
+ private struct ShellVerticalPopoverContent: UIViewControllerRepresentable {
138
+ let host: ShellOverlayHost
139
+ func makeUIViewController(context: Context) -> ShellOverlayHost { host }
140
+ func updateUIViewController(_ controller: ShellOverlayHost, context: Context) {}
141
+ }
142
+
130
143
  @available(iOS 26.0, *)
131
144
  private struct ShellVerticalBarsButton: ToolbarContent {
132
145
  @ObservedObject var model: ShellVerticalBarsModel
@@ -136,7 +149,40 @@ private struct ShellVerticalBarsButton: ToolbarContent {
136
149
  // Observe here so same-ID updates also refresh the shared toolbar background.
137
150
  var body: some ToolbarContent {
138
151
  if let item = model.groups.lazy.flatMap(\.items).first(where: { $0.id == id }) {
139
- ToolbarItem(placement: placement) { verticalBarsButton(item, model: model) }
152
+ ToolbarItem(placement: placement) {
153
+ verticalBarsButton(item, model: model)
154
+ .popover(isPresented: Binding(
155
+ get: { model.popoverAnchor == item.id },
156
+ set: { shown in
157
+ if !shown && model.popoverAnchor == item.id {
158
+ model.popoverAnchor = nil
159
+ model.popoverHost?.event("dismiss")
160
+ }
161
+ }
162
+ )) {
163
+ if let host = model.popoverHost {
164
+ ShellVerticalPopoverContent(host: host)
165
+ .frame(width: host.preferredContentSize.width, height: host.preferredContentSize.height)
166
+ .preferredColorScheme(host.options?["dark"] as? Bool == true ? .dark : .light)
167
+ .presentationCompactAdaptation(.popover)
168
+ .interactiveDismissDisabled(host.options?["backdropDismiss"] as? Bool == false)
169
+ .onAppear {
170
+ guard model.popoverHost === host else { return }
171
+ host.anchoredVisible = true
172
+ let completion = model.popoverPresented
173
+ model.popoverPresented = nil
174
+ completion?(true)
175
+ }
176
+ .onDisappear {
177
+ host.anchoredVisible = false
178
+ let completion = host.anchoredDidDismiss
179
+ host.anchoredDidDismiss = nil
180
+ if model.popoverHost === host { model.popoverHost = nil }
181
+ completion?()
182
+ }
183
+ }
184
+ }
185
+ }
140
186
  .sharedBackgroundVisibility(item.clear ? .hidden : .automatic)
141
187
  }
142
188
  }
@@ -424,6 +470,36 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling {
424
470
  controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light
425
471
  }
426
472
 
473
+ func presentPopover(anchor: String, host: ShellOverlayHost, completion: @escaping (Bool) -> Void) -> Bool {
474
+ guard container.window != nil, model.popoverAnchor == nil,
475
+ model.groups.flatMap(\.items).contains(where: { $0.id == anchor && !$0.disabled }) else { return false }
476
+ model.popoverHost = host
477
+ model.popoverPresented = completion
478
+ host.anchoredVisible = false
479
+ host.dismissAnchored = { [weak self, weak host] in
480
+ guard let self, let host, self.model.popoverHost === host else { return }
481
+ // A backdrop tap clears the binding before SwiftUI finishes closing.
482
+ // Keep the live content and completion until onDisappear, including
483
+ // when dismissal has already started through the system binding.
484
+ if self.model.popoverAnchor != nil {
485
+ var transaction = Transaction()
486
+ transaction.disablesAnimations = host.options?["animated"] as? Bool == false
487
+ withTransaction(transaction) { self.model.popoverAnchor = nil }
488
+ }
489
+ // Before onAppear there will be no onDisappear to finish cleanup.
490
+ if !host.anchoredVisible {
491
+ let completion = self.model.popoverPresented
492
+ self.model.popoverPresented = nil
493
+ self.model.popoverHost = nil
494
+ completion?(false)
495
+ }
496
+ }
497
+ var transaction = Transaction()
498
+ transaction.disablesAnimations = host.options?["animated"] as? Bool == false
499
+ withTransaction(transaction) { model.popoverAnchor = anchor }
500
+ return true
501
+ }
502
+
427
503
  func detach() {
428
504
  guard controller.parent != nil else { return }
429
505
  controller.willMove(toParent: nil)
@@ -10,8 +10,14 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
10
10
  CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
11
11
  CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise),
12
12
  CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
13
- CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise)
13
+ CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise),
14
+ CAPPluginMethod(name: "prepareOverlay", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "presentOverlay", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "dismissOverlay", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "closeOverlay", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "stopOverlays", returnType: CAPPluginReturnPromise)
14
19
  ]
20
+ private var overlays: ShellOverlayController?
15
21
  private var host: ShellHost?
16
22
  private var verticalBars: ShellVerticalBarsControlling?
17
23
  private var controls: [String: UIView] = [:]
@@ -74,6 +80,56 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
74
80
  observers.forEach(NotificationCenter.default.removeObserver)
75
81
  }
76
82
 
83
+ @objc func prepareOverlay(_ call: CAPPluginCall) {
84
+ DispatchQueue.main.async { [weak self] in
85
+ guard let self, let id = call.getString("id"),
86
+ let source = self.bridge?.webView, let owner = self.bridge?.viewController else {
87
+ call.reject("Popover source unavailable"); return
88
+ }
89
+ if self.overlays == nil {
90
+ self.overlays = ShellOverlayController(source: source, owner: owner) { [weak self] id, action in
91
+ self?.notifyListeners("overlay", data: ["id": id, "action": action])
92
+ }
93
+ }
94
+ guard self.overlays?.prepare(id) == true else { call.reject("Popover already pending"); return }
95
+ call.resolve()
96
+ }
97
+ }
98
+
99
+ @objc func presentOverlay(_ call: CAPPluginCall) {
100
+ DispatchQueue.main.async { [weak self] in
101
+ guard let self, let overlays = self.overlays, let id = call.getString("id") else {
102
+ call.reject("Popover not prepared"); return
103
+ }
104
+ overlays.present(id, options: call.getObject("presentation"), anchored: { [weak self] anchor, host, completion in
105
+ self?.verticalBars?.presentPopover(anchor: anchor, host: host, completion: completion) ?? false
106
+ }) { presented in
107
+ if presented { call.resolve() } else { call.reject("Native Vertical Bars anchor unavailable") }
108
+ }
109
+ }
110
+ }
111
+
112
+ @objc func dismissOverlay(_ call: CAPPluginCall) {
113
+ DispatchQueue.main.async { [weak self] in
114
+ guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return }
115
+ overlays.dismiss(id, animated: call.getBool("animated") ?? false) { call.resolve() }
116
+ }
117
+ }
118
+
119
+ @objc func closeOverlay(_ call: CAPPluginCall) {
120
+ DispatchQueue.main.async { [weak self] in
121
+ guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return }
122
+ overlays.close(id) { call.resolve() }
123
+ }
124
+ }
125
+
126
+ @objc func stopOverlays(_ call: CAPPluginCall) {
127
+ DispatchQueue.main.async { [weak self] in
128
+ guard let self, let overlays = self.overlays else { call.resolve(); return }
129
+ overlays.stop { self.overlays = nil; call.resolve() }
130
+ }
131
+ }
132
+
77
133
  private func webViewMetrics() -> JSObject? {
78
134
  guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil }
79
135
  webView.layoutIfNeeded()
@@ -0,0 +1,116 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ /// Intercepts only a prepared blank window. Other requests keep Capacitor's behavior.
5
+ final class ShellOverlayController: NSObject, WKUIDelegate {
6
+ private weak var source: WKWebView?
7
+ private weak var owner: UIViewController?
8
+ private let original: WKUIDelegate?
9
+ private let automaticallyOpensWindows: Bool
10
+ private var prepared: String?
11
+ private let event: (String, String) -> Void
12
+ private var id: String?
13
+ private var host: ShellOverlayHost?
14
+
15
+ init(source: WKWebView, owner: UIViewController, event: @escaping (String, String) -> Void) {
16
+ self.source = source
17
+ self.owner = owner
18
+ self.event = event
19
+ original = source.uiDelegate
20
+ automaticallyOpensWindows = source.configuration.preferences.javaScriptCanOpenWindowsAutomatically
21
+ super.init()
22
+ source.uiDelegate = self
23
+ }
24
+
25
+ override func responds(to selector: Selector!) -> Bool {
26
+ super.responds(to: selector) || original?.responds(to: selector) == true
27
+ }
28
+
29
+ override func forwardingTarget(for selector: Selector!) -> Any? {
30
+ original?.responds(to: selector) == true ? original : super.forwardingTarget(for: selector)
31
+ }
32
+
33
+ func prepare(_ id: String) -> Bool {
34
+ guard prepared == nil, host == nil else { return false }
35
+ prepared = id
36
+ source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
37
+ return true
38
+ }
39
+
40
+ func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
41
+ for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
42
+ // WebKit percent-encodes '#' inside about: URLs; compare the decoded form.
43
+ guard webView === source, navigationAction.sourceFrame.isMainFrame,
44
+ let prepared, navigationAction.targetFrame == nil,
45
+ navigationAction.request.url?.absoluteString.removingPercentEncoding == "about:blank#\(prepared)" else {
46
+ return original?.webView?(webView, createWebViewWith: configuration,
47
+ for: navigationAction, windowFeatures: windowFeatures)
48
+ }
49
+ self.prepared = nil
50
+ source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows
51
+ id = prepared
52
+ let host = ShellOverlayHost(configuration: configuration, options: nil) { [weak self] action in
53
+ self?.event(prepared, action)
54
+ }
55
+ host.webView.uiDelegate = self
56
+ host.webView.frame = source?.bounds ?? .zero
57
+ self.host = host
58
+ return host.webView
59
+ }
60
+
61
+ func present(_ id: String, options: [String: Any]?, anchored: (String, ShellOverlayHost, @escaping (Bool) -> Void) -> Bool,
62
+ completion: @escaping (Bool) -> Void) {
63
+ guard self.id == id, let host, let owner,
64
+ owner.presentedViewController == nil else { completion(false); return }
65
+ host.apply(options)
66
+ if host.options?["kind"] as? String == "popover",
67
+ let anchor = host.options?["anchorId"] as? String,
68
+ anchored(anchor, host, completion) { return }
69
+ completion(false)
70
+ }
71
+
72
+ // Keep the WebView alive until JavaScript has restored the adopted nodes and listeners.
73
+ func dismiss(_ id: String, animated: Bool, completion: @escaping () -> Void) {
74
+ guard self.id == id, let host else { completion(); return }
75
+ if let dismiss = host.dismissAnchored {
76
+ if host.anchoredVisible { host.anchoredDidDismiss = completion; dismiss() }
77
+ else { dismiss(); completion() }
78
+ return
79
+ }
80
+ guard host.presentingViewController != nil else { completion(); return }
81
+ host.dismiss(animated: animated, completion: completion)
82
+ }
83
+
84
+ func close(_ id: String, completion: @escaping () -> Void) {
85
+ if prepared == id {
86
+ prepared = nil
87
+ source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows
88
+ }
89
+ guard self.id == id, let host else { completion(); return }
90
+ let finish = { [self] in
91
+ host.webView.uiDelegate = nil
92
+ self.host = nil
93
+ self.id = nil
94
+ completion()
95
+ }
96
+ if let dismiss = host.dismissAnchored {
97
+ if host.anchoredVisible { host.anchoredDidDismiss = finish; dismiss() }
98
+ else { dismiss(); finish() }
99
+ }
100
+ else if host.presentingViewController == nil { finish() }
101
+ else { host.dismiss(animated: false, completion: finish) }
102
+ }
103
+
104
+ func stop(completion: @escaping () -> Void) {
105
+ let finish = { [self] in
106
+ prepared = nil
107
+ if let source, source.uiDelegate === self {
108
+ source.uiDelegate = original
109
+ source.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows
110
+ }
111
+ completion()
112
+ }
113
+ if let id { close(id, completion: finish) }
114
+ else { finish() }
115
+ }
116
+ }
@@ -0,0 +1,36 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ /// Hosts only the live content of a Vertical Bars popover.
5
+ final class ShellOverlayHost: UIViewController {
6
+ let webView: WKWebView
7
+ private(set) var options: [String: Any]?
8
+ let event: (String) -> Void
9
+ var dismissAnchored: (() -> Void)?
10
+ var anchoredVisible = false
11
+ var anchoredDidDismiss: (() -> Void)?
12
+
13
+ init(configuration: WKWebViewConfiguration, options: [String: Any]?, event: @escaping (String) -> Void) {
14
+ webView = WKWebView(frame: .zero, configuration: configuration)
15
+ self.event = event
16
+ super.init(nibName: nil, bundle: nil)
17
+ apply(options)
18
+ }
19
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
20
+
21
+ func apply(_ options: [String: Any]?) {
22
+ guard let options else { return }
23
+ self.options = options
24
+ preferredContentSize = CGSize(width: options["width"] as? Double ?? 280, height: options["height"] as? Double ?? 200)
25
+ overrideUserInterfaceStyle = options["dark"] as? Bool == true ? .dark : .light
26
+ }
27
+
28
+ override func loadView() {
29
+ webView.isOpaque = false
30
+ webView.backgroundColor = .clear
31
+ webView.scrollView.backgroundColor = .clear
32
+ webView.scrollView.contentInsetAdjustmentBehavior = .never
33
+ view = webView
34
+ view.accessibilityViewIsModal = true
35
+ }
36
+ }
@@ -1,8 +1,35 @@
1
1
  import XCTest
2
+ import WebKit
2
3
  import Capacitor
3
4
  @testable import IonicNativeUIShellPlugin
4
5
 
5
6
  final class ShellSnapshotTests: XCTestCase {
7
+ @MainActor func testVerticalPopoverCanCancelBeforeSwiftUIAppearsAndReopen() throws {
8
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native Vertical Bars") }
9
+ let owner = UIViewController()
10
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 700, height: 900))
11
+ window.rootViewController = owner
12
+ window.makeKeyAndVisible()
13
+ defer { window.isHidden = true }
14
+ let rail = ShellVerticalBarsController(activate: { _ in }, changed: { _, _, _, _, _ in 0 })
15
+ rail.attach(to: owner, in: owner.view)
16
+ defer { rail.detach() }
17
+ rail.apply(try decode([control(["placement": "vertical-bars"])]).controls,
18
+ rendering: ShellRendering(), edge: "right")
19
+ for _ in 0..<3 {
20
+ let host = ShellOverlayHost(configuration: WKWebViewConfiguration(), options: nil, event: { _ in })
21
+ var results: [Bool] = []
22
+ XCTAssertTrue(rail.presentPopover(anchor: "action", host: host, completion: { results.append($0) }))
23
+ // Cancel in the same runloop turn, before SwiftUI can call onAppear.
24
+ XCTAssertFalse(host.anchoredVisible)
25
+ host.dismissAnchored?()
26
+ XCTAssertEqual(results, [false])
27
+ XCTAssertFalse(host.anchoredVisible)
28
+ host.dismissAnchored?()
29
+ XCTAssertEqual(results, [false], "Cancellation must settle presentation only once")
30
+ }
31
+ }
32
+
6
33
  private func item(_ changes: JSObject = [:]) -> JSObject {
7
34
  var value: JSObject = ["id": "action", "x": 4.0, "y": 8.0, "width": 44.0, "height": 44.0,
8
35
  "label": "Send", "accessibilityLabel": "Send message", "disabled": false, "selected": false,
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",
4
+ "version": "1.2.1-beta.pr273.shae866fb1dca9f",
5
5
  "description": "iOS27 Theme for Ionic Framework",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "homepage": "https://docs.rdlabo.dev/projects/ionic-theme-ios27",
63
63
  "dependencies": {
64
- "@rdlabo/ionic-theme-utils": "0.1.2"
64
+ "@rdlabo/ionic-theme-utils": "0.1.4"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@capacitor/core": "^8.5.2",
@@ -6,7 +6,7 @@ import * as menuButton from './ion-menu-button';
6
6
  import * as tabBar from './ion-tab-bar';
7
7
  import * as segment from './ion-segment';
8
8
  import * as fab from './ion-fab';
9
- import { isDisabledButtonGroupChild, isVerticalBarsSource, visible } from '../shared/dom';
9
+ import { isDisabledButtonGroupChild, isVerticalBarsSource, visible, withoutPrehide } from '../shared/dom';
10
10
  import type { Candidate, Identify } from '../shared/candidate';
11
11
 
12
12
  // Static composition only. Each component declares its own tag, discovery and reader.
@@ -48,5 +48,6 @@ export const readCandidate = (element: HTMLElement, id: Identify, options: Verti
48
48
  )
49
49
  return;
50
50
  if (element.contains(element.ownerDocument.activeElement)) return;
51
- return components.find((component) => component.tag === element.localName)?.read(element, id, options);
51
+ // Read icon and control geometry before restoring prehide, which can collapse the toolbar.
52
+ return withoutPrehide(element, () => components.find((component) => component.tag === element.localName)?.read(element, id, options));
52
53
  };
@@ -11,6 +11,8 @@ export interface NativeUIShellStatus {
11
11
  }
12
12
 
13
13
  export interface VerticalControlAreaOptions {
14
+ /** Opt-in popovers anchored to native Vertical Bars buttons. */
15
+ controls?: Pick<NativeUIShellControls, 'popover'>;
14
16
  /** Appearance of native vertical ion-button and ion-menu-button actions.
15
17
  * `system` (default) uses SwiftUI styling and template icons; `source` projects supported Ionic fill and colors.
16
18
  * Local data-projection="source|system" or ios-theme-projection-source / ios-theme-projection-system take precedence:
@@ -32,7 +34,7 @@ export interface VerticalControlAreaOptions {
32
34
  export interface NativeUIShellOptions extends VerticalControlAreaOptions {
33
35
  /** Enables Native UI Shell globally. Defaults to `true`. */
34
36
  enabled?: boolean;
35
- /** Controls eligible for native projection. Omit to enable every control; when present, only `true` controls are enabled. */
37
+ /** Controls eligible for native projection. Omit to enable tabs, toolbar, segment and fab; popover always requires explicit opt-in. When present, only `true` controls are enabled. */
36
38
  controls?: NativeUIShellControls;
37
39
  /** Internal: limit native projection to the Vertical Control Area. */
38
40
  verticalBarsOnly?: boolean;
@@ -47,6 +49,8 @@ export interface NativeUIShellControls {
47
49
  segment?: boolean;
48
50
  /** Projects floating action buttons. */
49
51
  fab?: boolean;
52
+ /** Relays popovers anchored to native Vertical Bars buttons. Defaults to false. */
53
+ popover?: boolean;
50
54
  }
51
55
 
52
56
  export interface NativeUIShellHandle {
@@ -183,9 +187,31 @@ export interface WebViewMetrics {
183
187
  export interface NativeUIShellPlugin {
184
188
  configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>;
185
189
  getWebViewMetrics(): Promise<WebViewMetrics>;
190
+ prepareOverlay(options: { id: string }): Promise<void>;
191
+ presentOverlay(options: { id: string; presentation?: ShellPopoverPresentation }): Promise<void>;
192
+ dismissOverlay(options: { id: string; animated: boolean }): Promise<void>;
193
+ closeOverlay(options: { id: string }): Promise<void>;
194
+ stopOverlays(): Promise<void>;
186
195
  update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>;
187
196
  clear(options: { revision: number }): Promise<void>;
188
197
  addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise<PluginListenerHandle>;
189
198
  addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise<PluginListenerHandle>;
190
199
  addListener(name: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise<PluginListenerHandle>;
200
+ addListener(name: 'overlay', listener: (event: ShellOverlayEvent) => void): Promise<PluginListenerHandle>;
201
+ }
202
+
203
+ export interface ShellPopoverPresentation {
204
+ kind: 'popover';
205
+ animated: boolean;
206
+ dark: boolean;
207
+ anchorId: string;
208
+ width: number;
209
+ height: number;
210
+ backgroundColor: string;
211
+ backdropDismiss: boolean;
212
+ }
213
+
214
+ export interface ShellOverlayEvent {
215
+ id: string;
216
+ action: 'dismiss';
191
217
  }
@@ -11,6 +11,7 @@ import type {
11
11
  WebViewMetrics,
12
12
  } from './definitions';
13
13
  import { createRuntime } from './runtime';
14
+ import { createOverlayController } from './overlays/controller';
14
15
  import { createVerticalBarsWebProjection } from './vertical-bars-web';
15
16
  import { prehideVerticalBarsToolbarSources } from './prehide';
16
17
  import { observeVerticalBarsModals } from './shared/modal';
@@ -64,7 +65,7 @@ const manage = (
64
65
  handle: NativeUIShellHandle,
65
66
  lifecycle: {
66
67
  reason?: string;
67
- suspend?: () => (() => void) | undefined;
68
+ suspend?: () => (() => void) | undefined | Promise<(() => void) | undefined>;
68
69
  /** Runs after teardown; clears the shared slot only while this activation owns it. */
69
70
  release?: () => void;
70
71
  destroy?: () => void | Promise<void>;
@@ -75,7 +76,7 @@ const manage = (
75
76
  getStatus: () => (lifecycle.reason ? { ...handle.getStatus(), reason: lifecycle.reason } : handle.getStatus()),
76
77
  async suspend() {
77
78
  const lease = await handle.suspend();
78
- const resume = lifecycle.suspend?.();
79
+ const resume = await lifecycle.suspend?.();
79
80
  return {
80
81
  async resume() {
81
82
  await lease.resume();
@@ -89,8 +90,11 @@ const manage = (
89
90
  try {
90
91
  await handle.destroy();
91
92
  } finally {
92
- await lifecycle.destroy?.();
93
- lifecycle.release?.();
93
+ try {
94
+ await lifecycle.destroy?.();
95
+ } finally {
96
+ lifecycle.release?.();
97
+ }
94
98
  }
95
99
  },
96
100
  };
@@ -171,7 +175,7 @@ export const enableVerticalControlArea = async (options: VerticalControlAreaOpti
171
175
  const handle = await enableNativeUIShell({
172
176
  buttonProjection: options.buttonProjection,
173
177
  buttonDefaultFill: options.buttonDefaultFill,
174
- controls: { tabs: true, toolbar: true },
178
+ controls: { tabs: true, toolbar: true, popover: options.controls?.popover === true },
175
179
  verticalBarsOnly: true,
176
180
  });
177
181
  return {
@@ -203,6 +207,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
203
207
  // Local source overrides can use this even when the startup projection is system.
204
208
  options.buttonDefaultFill ?? null,
205
209
  ...(['tabs', 'toolbar', 'segment', 'fab'] as const).map((component) => !controls || controls[component] === true),
210
+ controls?.popover === true,
206
211
  ]);
207
212
  if (active && activeConfiguration !== configuration)
208
213
  return Promise.reject(
@@ -235,6 +240,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
235
240
  (async () => {
236
241
  if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS');
237
242
  let runtime: NativeUIShellHandle | undefined;
243
+ let overlays: Awaited<ReturnType<typeof createOverlayController>> | undefined;
238
244
  let metricsListener: Awaited<ReturnType<typeof plugin.addListener>> | undefined;
239
245
  let stopVerticalBarsLayout: (() => void) | undefined;
240
246
  try {
@@ -261,10 +267,20 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
261
267
  native,
262
268
  createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars() || native.getStatus().state === 'stopped'),
263
269
  );
270
+ if (controls?.popover === true)
271
+ overlays = await createOverlayController(document, plugin, options, native.retain, nativeVerticalBars);
264
272
  return manage(runtime, {
265
- suspend: () => prehide?.suspend(),
273
+ suspend: async () => {
274
+ const restorePrehide = prehide?.suspend();
275
+ const resumeOverlays = await overlays?.suspend();
276
+ return () => {
277
+ restorePrehide?.();
278
+ resumeOverlays?.();
279
+ };
280
+ },
266
281
  release,
267
282
  destroy: async () => {
283
+ await overlays?.destroy();
268
284
  await metricsListener?.remove().catch(() => {});
269
285
  stopVerticalBarsLayout?.();
270
286
  prehide?.stop();
@@ -272,6 +288,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
272
288
  },
273
289
  });
274
290
  } catch (error) {
291
+ await overlays?.destroy();
275
292
  await runtime?.destroy();
276
293
  await metricsListener?.remove().catch(() => {});
277
294
  stopVerticalBarsLayout?.();
@@ -0,0 +1,51 @@
1
+ import { sheetText } from './styles';
2
+
3
+ /** Constructed stylesheets cannot cross documents with their nodes. */
4
+ export const moveContent = (content: HTMLElement[], destination: HTMLElement): (() => void) => {
5
+ const home = content.map((element) => ({ element, parent: element.parentNode!, next: element.nextSibling }));
6
+ const roots: { root: ShadowRoot; css: string; sheets: CSSStyleSheet[] }[] = [];
7
+ const collect = (node: Element) => {
8
+ if (node.shadowRoot) {
9
+ roots.push({
10
+ root: node.shadowRoot,
11
+ css: sheetText(node.shadowRoot.adoptedStyleSheets),
12
+ sheets: [...node.shadowRoot.adoptedStyleSheets],
13
+ });
14
+ for (const child of Array.from(node.shadowRoot.children)) collect(child);
15
+ }
16
+ for (const child of Array.from(node.children)) collect(child);
17
+ };
18
+ content.forEach(collect);
19
+ for (const element of content) destination.append(destination.ownerDocument.adoptNode(element));
20
+ const styles = roots
21
+ .filter(({ css }) => css)
22
+ .map(({ root, css }) => {
23
+ const style = destination.ownerDocument.createElement('style');
24
+ style.textContent = css;
25
+ root.append(style);
26
+ return style;
27
+ });
28
+ return () => {
29
+ styles.forEach((style) => style.remove());
30
+ for (const { element, parent, next } of home) {
31
+ const connected = element.isConnected;
32
+ parent.ownerDocument!.adoptNode(element);
33
+ // Ionic may already have destroyed the Angular component on dismiss.
34
+ if (connected) parent.insertBefore(element, next?.parentNode === parent ? next : null);
35
+ }
36
+ for (const { root, css, sheets } of roots) {
37
+ // WebKit re-associates adopted sheets with the new document across
38
+ // adoptNode, so they may no longer match this root; fall back to the
39
+ // inline copy instead of aborting the remaining restore.
40
+ try {
41
+ root.adoptedStyleSheets = sheets;
42
+ } catch {
43
+ if (css) {
44
+ const style = root.ownerDocument.createElement('style');
45
+ style.textContent = css;
46
+ root.append(style);
47
+ }
48
+ }
49
+ }
50
+ };
51
+ };