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