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