@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.
- package/dist/css/components/ion-button.css +1 -1
- package/dist/css/components/ion-toolbar.css +1 -1
- package/dist/css/ionic-theme-ios27.css +1 -1
- package/dist/css/vertical-bars.css +1 -1
- package/dist/native/components/index.js +2 -2
- package/dist/native/components/index.js.map +1 -1
- package/dist/native/definitions.d.ts +32 -0
- package/dist/native/definitions.d.ts.map +1 -1
- package/dist/native/index.d.ts.map +1 -1
- package/dist/native/index.js +23 -6
- package/dist/native/index.js.map +1 -1
- package/dist/native/overlays/content.d.ts +4 -0
- package/dist/native/overlays/content.d.ts.map +1 -0
- package/dist/native/overlays/content.js +51 -0
- package/dist/native/overlays/content.js.map +1 -0
- package/dist/native/overlays/controller.d.ts +8 -0
- package/dist/native/overlays/controller.d.ts.map +1 -0
- package/dist/native/overlays/controller.js +266 -0
- package/dist/native/overlays/controller.js.map +1 -0
- package/dist/native/overlays/dialog-animation.d.ts +8 -0
- package/dist/native/overlays/dialog-animation.d.ts.map +1 -0
- package/dist/native/overlays/dialog-animation.js +31 -0
- package/dist/native/overlays/dialog-animation.js.map +1 -0
- package/dist/native/overlays/dismiss-animation.d.ts +4 -0
- package/dist/native/overlays/dismiss-animation.d.ts.map +1 -0
- package/dist/native/overlays/dismiss-animation.js +18 -0
- package/dist/native/overlays/dismiss-animation.js.map +1 -0
- package/dist/native/overlays/focus.d.ts +5 -0
- package/dist/native/overlays/focus.d.ts.map +1 -0
- package/dist/native/overlays/focus.js +59 -0
- package/dist/native/overlays/focus.js.map +1 -0
- package/dist/native/overlays/popover.d.ts +11 -0
- package/dist/native/overlays/popover.d.ts.map +1 -0
- package/dist/native/overlays/popover.js +78 -0
- package/dist/native/overlays/popover.js.map +1 -0
- package/dist/native/overlays/styles.d.ts +9 -0
- package/dist/native/overlays/styles.d.ts.map +1 -0
- package/dist/native/overlays/styles.js +82 -0
- package/dist/native/overlays/styles.js.map +1 -0
- package/dist/native/prehide.d.ts.map +1 -1
- package/dist/native/prehide.js +6 -2
- package/dist/native/prehide.js.map +1 -1
- package/dist/native/runtime.d.ts +4 -1
- package/dist/native/runtime.d.ts.map +1 -1
- package/dist/native/runtime.js +29 -8
- package/dist/native/runtime.js.map +1 -1
- package/dist/native/shared/dom.d.ts +2 -0
- package/dist/native/shared/dom.d.ts.map +1 -1
- package/dist/native/shared/dom.js +5 -1
- package/dist/native/shared/dom.js.map +1 -1
- package/dist/native/shared/projection-id.d.ts +4 -0
- package/dist/native/shared/projection-id.d.ts.map +1 -0
- package/dist/native/shared/projection-id.js +7 -0
- package/dist/native/shared/projection-id.js.map +1 -0
- package/dist/native/vertical-bars-web.d.ts.map +1 -1
- package/dist/native/vertical-bars-web.js +4 -2
- package/dist/native/vertical-bars-web.js.map +1 -1
- package/dist/native-integration/index.d.ts +1 -1
- package/dist/native-integration/index.js.map +1 -1
- package/dist/native-integration/transition.d.ts +1 -1
- package/dist/native-integration/transition.js.map +1 -1
- package/dist/sheets-of-glass/animations.d.ts +1 -1
- package/dist/sheets-of-glass/animations.js +1 -1
- package/dist/sheets-of-glass/animations.js.map +1 -1
- package/dist/sheets-of-glass/index.js +1 -1
- package/dist/sheets-of-glass/index.js.map +1 -1
- package/dist/tab-bar-searchable/animations/enter.d.ts +1 -1
- package/dist/tab-bar-searchable/animations/enter.d.ts.map +1 -1
- package/dist/tab-bar-searchable/animations/enter.js +1 -1
- package/dist/tab-bar-searchable/animations/enter.js.map +1 -1
- package/dist/tab-bar-searchable/animations/leave.d.ts +1 -1
- package/dist/tab-bar-searchable/animations/leave.d.ts.map +1 -1
- package/dist/tab-bar-searchable/animations/leave.js +1 -1
- package/dist/tab-bar-searchable/animations/leave.js.map +1 -1
- package/dist/tab-bar-searchable/index.js +1 -1
- package/dist/tab-bar-searchable/index.js.map +1 -1
- package/dist/transition/index.d.ts +1 -1
- package/dist/transition/index.js +1 -1
- package/dist/transition/index.js.map +1 -1
- package/dist/transition/ios.transition.d.ts +1 -1
- package/dist/transition/ios.transition.js.map +1 -1
- package/dist/utils.d.ts +1 -1
- package/dist/utils.js.map +1 -1
- package/docs/native-ui-shell.md +20 -4
- package/docs/vertical-bars.md +3 -1
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +65 -91
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabBar.swift +9 -3
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +79 -3
- package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +57 -1
- package/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift +116 -0
- package/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift +36 -0
- package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +27 -0
- package/package.json +2 -2
- package/src/native/components/index.ts +3 -2
- package/src/native/definitions.ts +27 -1
- package/src/native/index.ts +23 -6
- package/src/native/overlays/content.ts +51 -0
- package/src/native/overlays/controller.ts +322 -0
- package/src/native/overlays/dialog-animation.ts +30 -0
- package/src/native/overlays/dismiss-animation.ts +15 -0
- package/src/native/overlays/focus.ts +64 -0
- package/src/native/overlays/popover.ts +80 -0
- package/src/native/overlays/styles.ts +80 -0
- package/src/native/prehide.ts +6 -1
- package/src/native/runtime.ts +39 -15
- package/src/native/shared/dom.ts +14 -0
- package/src/native/shared/projection-id.ts +2 -0
- package/src/native/vertical-bars-web.ts +11 -1
- package/src/native-integration/index.ts +1 -1
- package/src/native-integration/transition.ts +1 -1
- package/src/sheets-of-glass/animations.ts +2 -2
- package/src/sheets-of-glass/index.ts +2 -2
- package/src/styles/components/ion-button.scss +47 -0
- package/src/styles/components/ion-toolbar.scss +62 -7
- package/src/styles/vertical-bars.scss +23 -0
- package/src/tab-bar-searchable/animations/enter.ts +2 -1
- package/src/tab-bar-searchable/animations/leave.ts +2 -1
- package/src/tab-bar-searchable/index.ts +1 -1
- package/src/transition/index.ts +3 -3
- package/src/transition/ios.transition.ts +1 -1
- 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
|
-
|
|
52
|
+
let image = rendering.image(source.content)
|
|
53
|
+
return Item(id: source.id, label: source.content.label,
|
|
49
54
|
accessibilityLabel: source.content.accessibilityLabel,
|
|
50
|
-
|
|
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) {
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/native/index.ts
CHANGED
|
@@ -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
|
-
|
|
93
|
-
|
|
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: () =>
|
|
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
|
+
};
|