@rdlabo/ionic-theme-ios27 0.1.0 → 0.2.1-beta.pr185.sha0793981f6cf4
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 +34 -8
- package/dist/css/components/ion-button.css +1 -1
- package/dist/css/components/ion-fab.css +1 -1
- package/dist/css/components/ion-list.css +1 -1
- package/dist/css/components/ion-menu.css +1 -1
- package/dist/css/components/ion-searchbar.css +1 -1
- package/dist/css/components/ion-tabs.css +1 -1
- package/dist/css/components/ion-toolbar.css +1 -1
- package/dist/css/ionic-theme-ios27.css +1 -1
- package/dist/css/md-ion-list-inset.css +1 -1
- package/dist/css/utils/translucent.css +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/native/components/searchable-tabs.d.ts +2 -0
- package/dist/native/components/searchable-tabs.d.ts.map +1 -1
- package/dist/native/components/searchable-tabs.js +131 -21
- package/dist/native/definitions.d.ts +19 -0
- package/dist/native/definitions.d.ts.map +1 -1
- package/dist/native/index.d.ts +4 -3
- package/dist/native/index.d.ts.map +1 -1
- package/dist/native/index.js +22 -9
- package/dist/native/lifecycle.d.ts +4 -0
- package/dist/native/lifecycle.d.ts.map +1 -0
- package/dist/native/lifecycle.js +34 -0
- package/dist/native/runtime.d.ts +2 -2
- package/dist/native/runtime.d.ts.map +1 -1
- package/dist/native/runtime.js +57 -11
- 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/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/index.d.ts +2 -2
- package/dist/tab-bar-searchable/index.d.ts.map +1 -1
- package/dist/tab-bar-searchable/index.js +2 -3
- package/dist/transition/ios.transition.d.ts +6 -1
- package/dist/transition/ios.transition.d.ts.map +1 -1
- package/dist/transition/ios.transition.js +10 -385
- package/docs/features.md +3 -1
- package/docs/ios-adaptive.md +20 -21
- package/docs/native-ui-shell.md +117 -3
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +243 -64
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabBar.swift +77 -6
- package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +106 -11
- package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +258 -2
- package/node_modules/@rdlabo/ionic-theme-utils/LICENSE +21 -0
- package/node_modules/@rdlabo/ionic-theme-utils/README.md +28 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/dom.d.ts +3 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/dom.d.ts.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/dom.js +13 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/dom.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/focus-controller.d.ts +6 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/focus-controller.d.ts.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/focus-controller.js +37 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/focus-controller.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/index.d.ts +13 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/index.d.ts.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/index.js +11 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/index.js.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.enter.d.ts +0 -1
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/animations/ios.enter.d.ts.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.enter.js +8 -5
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/animations/ios.enter.js.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.leave.d.ts.map +1 -1
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.leave.js +11 -5
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/animations/ios.leave.js.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/callout-surface.js +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/callout-surface.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/popover-interface.js +2 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/popover-interface.js.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/utils.d.ts +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/utils.d.ts.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/utils.js +3 -2
- package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/utils.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/sheets-of-glass.d.ts +16 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/sheets-of-glass.d.ts.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/sheets-of-glass.js +2 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/sheets-of-glass.js.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/interfaces.js +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/tab-bar-searchable/interfaces.js.map +1 -0
- package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/utils.js +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/tab-bar-searchable/utils.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/theme.d.ts +15 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/theme.d.ts.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/theme.js +49 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/theme.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/transition/ios.transition.d.ts +18 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/transition/ios.transition.d.ts.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/transition/ios.transition.js +412 -0
- package/node_modules/@rdlabo/ionic-theme-utils/dist/transition/ios.transition.js.map +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/package.json +64 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/dom.spec.ts +19 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/dom.ts +16 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/focus-controller.ts +38 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/index.ts +59 -0
- package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/animations/ios.enter.ts +7 -5
- package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/animations/ios.leave.ts +14 -5
- package/node_modules/@rdlabo/ionic-theme-utils/src/popover/utils.spec.ts +58 -0
- package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/utils.ts +4 -2
- package/node_modules/@rdlabo/ionic-theme-utils/src/sheets-of-glass.ts +17 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/styles/index.scss +1 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/styles/md-ion-list-inset.scss +20 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/styles/structured-list.scss +310 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/tab-bar-searchable/utils.spec.ts +56 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/theme.ts +51 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/transition/ios.transition.spec.ts +52 -0
- package/node_modules/@rdlabo/ionic-theme-utils/src/transition/ios.transition.ts +542 -0
- package/package.json +13 -5
- package/src/index.ts +4 -2
- package/src/native/components/searchable-tabs.ts +142 -22
- package/src/native/definitions.ts +34 -0
- package/src/native/index.ts +38 -11
- package/src/native/lifecycle.ts +38 -0
- package/src/native/runtime.ts +60 -10
- package/src/styles/components/ion-button.scss +21 -10
- package/src/styles/components/ion-fab.scss +10 -4
- package/src/styles/components/ion-list.scss +12 -3
- package/src/styles/components/ion-menu.scss +17 -10
- package/src/styles/components/ion-searchbar.scss +1 -1
- package/src/styles/components/ion-tabs.scss +14 -10
- package/src/styles/components/ion-toolbar.scss +9 -10
- package/src/styles/md-ion-list-inset.scss +2 -2
- package/src/styles/utils/dark/ion-list.scss +1 -1
- package/src/styles/utils/translucent.scss +44 -0
- package/src/tab-bar-searchable/animations/enter.ts +1 -2
- package/src/tab-bar-searchable/animations/leave.ts +1 -2
- package/src/tab-bar-searchable/index.ts +12 -4
- package/src/transition/ios.transition.ts +14 -473
- package/dist/css/utils/structured-list.css +0 -1
- package/dist/popover/animations/ios.enter.d.ts.map +0 -1
- package/dist/popover/popover-interface.js +0 -1
- package/dist/popover/utils.d.ts.map +0 -1
- package/src/styles/utils/structured-list.scss +0 -223
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.leave.d.ts +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/callout-surface.d.ts +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/callout-surface.d.ts.map +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/popover-interface.d.ts +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/popover-interface.d.ts.map +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/interfaces.d.ts +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/interfaces.d.ts.map +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/utils.d.ts +0 -0
- /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/utils.d.ts.map +0 -0
- /package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/callout-surface.ts +0 -0
- /package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/popover-interface.ts +0 -0
- /package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/tab-bar-searchable/interfaces.ts +0 -0
- /package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/tab-bar-searchable/utils.ts +0 -0
|
@@ -8,6 +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: "getWebViewMetrics", returnType: CAPPluginReturnPromise),
|
|
11
12
|
CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
|
|
12
13
|
CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise)
|
|
13
14
|
]
|
|
@@ -19,6 +20,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
19
20
|
private var revision = 0
|
|
20
21
|
private var sequence = 0
|
|
21
22
|
private var keyboardVisible = false
|
|
23
|
+
private var pendingTabSelections: [String: ShellTabBar.PendingSelection] = [:]
|
|
24
|
+
private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:]
|
|
22
25
|
private var restoreTopEdge: (() -> Void)?
|
|
23
26
|
private var observers: [NSObjectProtocol] = []
|
|
24
27
|
|
|
@@ -38,13 +41,21 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
if !keyboard { self.host?.isHidden = true }
|
|
44
|
+
var searchOwnsKeyboard = false
|
|
41
45
|
if #available(iOS 26.0, *) {
|
|
42
46
|
self.searchControllers.values.forEach {
|
|
43
47
|
guard let controller = $0 as? ShellSearchController else { return }
|
|
48
|
+
if controller.ownsKeyboardChrome { searchOwnsKeyboard = true }
|
|
44
49
|
if !keyboard { controller.surface.isHidden = true }
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
|
-
if keyboard {
|
|
52
|
+
if keyboard {
|
|
53
|
+
if !searchOwnsKeyboard {
|
|
54
|
+
self.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh")
|
|
55
|
+
}
|
|
56
|
+
} else if name == UIDevice.orientationDidChangeNotification {
|
|
57
|
+
self.notifyWebViewMetricsChange()
|
|
58
|
+
}
|
|
48
59
|
})
|
|
49
60
|
}
|
|
50
61
|
for name in [UIApplication.didBecomeActiveNotification, UIResponder.keyboardDidHideNotification,
|
|
@@ -52,6 +63,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
52
63
|
observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
|
53
64
|
if name == UIResponder.keyboardDidHideNotification { self?.keyboardVisible = false }
|
|
54
65
|
self?.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh", data: name == UIApplication.didBecomeActiveNotification ? "{\"retireSearch\":true}" : "{}")
|
|
66
|
+
if name == UIApplication.didBecomeActiveNotification { self?.notifyWebViewMetricsChange() }
|
|
55
67
|
})
|
|
56
68
|
}
|
|
57
69
|
}
|
|
@@ -60,6 +72,31 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
60
72
|
observers.forEach(NotificationCenter.default.removeObserver)
|
|
61
73
|
}
|
|
62
74
|
|
|
75
|
+
private func webViewMetrics() -> JSObject? {
|
|
76
|
+
guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil }
|
|
77
|
+
webView.layoutIfNeeded()
|
|
78
|
+
return ["radius": Double(webView.effectiveRadius(corner: .topLeft))]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private func notifyWebViewMetricsChange() {
|
|
82
|
+
guard let metrics = webViewMetrics() else { return }
|
|
83
|
+
notifyListeners("webViewMetricsChange", data: metrics)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@objc func getWebViewMetrics(_ call: CAPPluginCall) {
|
|
87
|
+
DispatchQueue.main.async { [weak self] in
|
|
88
|
+
guard #available(iOS 26.0, *) else {
|
|
89
|
+
call.resolve(["radius": 0])
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
guard let metrics = self?.webViewMetrics() else {
|
|
93
|
+
call.reject("WebView unavailable")
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
call.resolve(metrics)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
63
100
|
@objc func configure(_ call: CAPPluginCall) {
|
|
64
101
|
DispatchQueue.main.async { [weak self] in
|
|
65
102
|
// A new JS context starts revision numbering again (live reload / navigation).
|
|
@@ -101,6 +138,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
101
138
|
}
|
|
102
139
|
if let control = controls.removeValue(forKey: id) { ShellCrossfade.retire(control, duration: duration) }
|
|
103
140
|
fingerprints.removeValue(forKey: id)
|
|
141
|
+
pendingTabSelections.removeValue(forKey: id)
|
|
142
|
+
pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
|
|
104
143
|
}
|
|
105
144
|
|
|
106
145
|
private func removeControls(duration: TimeInterval = 0) {
|
|
@@ -108,6 +147,36 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
108
147
|
host?.removeFromSuperview()
|
|
109
148
|
host = nil
|
|
110
149
|
rendering.clear()
|
|
150
|
+
pendingTabSelections.removeAll()
|
|
151
|
+
pendingTabExpiryWorks.values.forEach { $0.cancel() }
|
|
152
|
+
pendingTabExpiryWorks.removeAll()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private func syncTabBar(_ tabBar: UITabBar, id: String, node: ShellControl) {
|
|
156
|
+
var pending = pendingTabSelections[id]
|
|
157
|
+
ShellTabBar.update(tabBar, node: node, rendering: rendering, pendingSelection: &pending)
|
|
158
|
+
if let pending {
|
|
159
|
+
pendingTabSelections[id] = pending
|
|
160
|
+
} else {
|
|
161
|
+
pendingTabSelections.removeValue(forKey: id)
|
|
162
|
+
pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private func schedulePendingTabExpiry(_ id: String, until: CFAbsoluteTime) {
|
|
167
|
+
pendingTabExpiryWorks[id]?.cancel()
|
|
168
|
+
let delay = max(0, until - CFAbsoluteTimeGetCurrent()) + 0.02
|
|
169
|
+
let work = DispatchWorkItem { [weak self] in
|
|
170
|
+
guard let self else { return }
|
|
171
|
+
self.pendingTabExpiryWorks.removeValue(forKey: id)
|
|
172
|
+
guard let pending = self.pendingTabSelections[id],
|
|
173
|
+
CFAbsoluteTimeGetCurrent() >= pending.until,
|
|
174
|
+
let tabBar = self.controls[id] as? UITabBar,
|
|
175
|
+
let node = self.fingerprints[id] else { return }
|
|
176
|
+
self.syncTabBar(tabBar, id: id, node: node)
|
|
177
|
+
}
|
|
178
|
+
pendingTabExpiryWorks[id] = work
|
|
179
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
|
111
180
|
}
|
|
112
181
|
|
|
113
182
|
@objc func update(_ call: CAPPluginCall) {
|
|
@@ -145,7 +214,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
145
214
|
}
|
|
146
215
|
var rejectedControls: [String] = []
|
|
147
216
|
var fabs: [(ShellFab, ShellControl)] = []
|
|
148
|
-
var searches: [(ShellSearchController, ShellControl, CGRect, CGRect)] = []
|
|
217
|
+
var searches: [(ShellSearchController, ShellControl, CGRect, CGRect, UIView?, Bool)] = []
|
|
149
218
|
var rejectedSearches: [String] = []
|
|
150
219
|
UIView.performWithoutAnimation {
|
|
151
220
|
if host.superview !== parent { parent.addSubview(host) }
|
|
@@ -173,9 +242,11 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
173
242
|
if let search = node.search {
|
|
174
243
|
guard let owner = self.bridge?.viewController else { rejectedSearches.append(id); continue }
|
|
175
244
|
let controller: ShellSearchController
|
|
176
|
-
|
|
245
|
+
let previousCover = self.controls[id]
|
|
246
|
+
let replacing = self.searchControllers[id] as? ShellSearchController
|
|
247
|
+
if let existing = replacing { controller = existing }
|
|
177
248
|
else {
|
|
178
|
-
|
|
249
|
+
// Keep the ordinary UITabBar cover until the search controller applies.
|
|
179
250
|
controller = ShellSearchController()
|
|
180
251
|
controller.activate = { [weak self] id in self?.activate(id) }
|
|
181
252
|
controller.changed = { [weak self] id, phase, value, composing, valueVersion in
|
|
@@ -194,7 +265,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
194
265
|
y: webView.bounds.minY + local.minY * scale, width: local.width * scale, height: local.height * scale), to: owner.view)
|
|
195
266
|
let triggerFrame = webView.convert(CGRect(x: webView.bounds.minX + trigger.minX * scale,
|
|
196
267
|
y: webView.bounds.minY + trigger.minY * scale, width: trigger.width * scale, height: trigger.height * scale), to: owner.view)
|
|
197
|
-
searches.append((controller, node, searchBarFrame, triggerFrame))
|
|
268
|
+
searches.append((controller, node, searchBarFrame, triggerFrame, previousCover, replacing == nil))
|
|
198
269
|
continue
|
|
199
270
|
} else if self.searchControllers[id] != nil {
|
|
200
271
|
self.removeControl(id)
|
|
@@ -208,7 +279,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
208
279
|
} else if let segment = self.controls[id] as? ShellSegment, node.kind == ShellSegment.kind {
|
|
209
280
|
segment.update(node, scale: scale, rendering: self.rendering)
|
|
210
281
|
} else if let tabBar = self.controls[id] as? UITabBar, node.kind == ShellTabBar.kind {
|
|
211
|
-
|
|
282
|
+
self.syncTabBar(tabBar, id: id, node: node)
|
|
212
283
|
} else {
|
|
213
284
|
guard let control = ShellComponents.make(node, scale: scale, rendering: self.rendering, tabDelegate: self,
|
|
214
285
|
activate: { [weak self] id in self?.activate(id) }) else { reject(); continue }
|
|
@@ -217,6 +288,9 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
217
288
|
host.addSubview(control)
|
|
218
289
|
}
|
|
219
290
|
self.fingerprints[id] = node
|
|
291
|
+
} else if let tabBar = self.controls[id] as? UITabBar, self.pendingTabSelections[id] != nil {
|
|
292
|
+
// Resolve an in-flight native tap even when other fingerprint fields are unchanged.
|
|
293
|
+
self.syncTabBar(tabBar, id: id, node: node)
|
|
220
294
|
}
|
|
221
295
|
guard let control = self.controls[id] else { reject(); continue }
|
|
222
296
|
if let tabBar = control as? UITabBar {
|
|
@@ -234,13 +308,29 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
234
308
|
for (fab, node) in fabs {
|
|
235
309
|
fab.apply(node, scale: scale, rendering: self.rendering, activate: { [weak self] id in self?.activate(id) })
|
|
236
310
|
}
|
|
237
|
-
for (controller, node, frame, triggerFrame) in searches {
|
|
311
|
+
for (controller, node, frame, triggerFrame, previousCover, created) in searches {
|
|
312
|
+
let id = node.id
|
|
238
313
|
let webFrame = webView.convert(webView.bounds, to: controller.surface.superview)
|
|
239
314
|
if !controller.apply(node, webFrame: webFrame, barFrame: frame, triggerFrame: triggerFrame, rendering: self.rendering) {
|
|
240
|
-
|
|
241
|
-
|
|
315
|
+
if created {
|
|
316
|
+
self.searchControllers.removeValue(forKey: id)
|
|
317
|
+
controller.detach()
|
|
318
|
+
if let previousCover, previousCover.superview != nil {
|
|
319
|
+
self.controls[id] = previousCover
|
|
320
|
+
} else {
|
|
321
|
+
self.controls.removeValue(forKey: id)?.removeFromSuperview()
|
|
322
|
+
self.fingerprints.removeValue(forKey: id)
|
|
323
|
+
self.pendingTabSelections[id] = nil
|
|
324
|
+
self.pendingTabExpiryWorks.removeValue(forKey: id)?.cancel()
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
self.removeControl(id)
|
|
328
|
+
}
|
|
242
329
|
rejectedSearches.append(id)
|
|
243
330
|
} else {
|
|
331
|
+
if created, let previousCover, previousCover !== controller.surface {
|
|
332
|
+
previousCover.removeFromSuperview()
|
|
333
|
+
}
|
|
244
334
|
controller.surface.isHidden = self.keyboardVisible && !controller.ownsKeyboard
|
|
245
335
|
}
|
|
246
336
|
}
|
|
@@ -256,8 +346,13 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
256
346
|
}
|
|
257
347
|
|
|
258
348
|
public func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
|
|
259
|
-
guard item.isEnabled, let
|
|
260
|
-
|
|
349
|
+
guard item.isEnabled, let itemId = item.accessibilityIdentifier else { return }
|
|
350
|
+
if let controlId = controls.first(where: { $0.value === tabBar })?.key {
|
|
351
|
+
let pending = ShellTabBar.PendingSelection.start(itemId)
|
|
352
|
+
pendingTabSelections[controlId] = pending
|
|
353
|
+
schedulePendingTabExpiry(controlId, until: pending.until)
|
|
354
|
+
}
|
|
355
|
+
activate(itemId)
|
|
261
356
|
}
|
|
262
357
|
|
|
263
358
|
private func activate(_ id: String) {
|
|
@@ -237,8 +237,8 @@ final class ShellSnapshotTests: XCTestCase {
|
|
|
237
237
|
let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
238
238
|
"items": [item(["id": "first", "selected": true]), item(["id": "second"]) ]])]).controls.first)
|
|
239
239
|
XCTAssertTrue(controller.apply(node, webFrame: CGRect(x: 0, y: 0, width: 390, height: 844),
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
241
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
242
242
|
}
|
|
243
243
|
try apply("old-search", value: "initial")
|
|
244
244
|
let navigation = try XCTUnwrap(controller.tabs.last?.viewController as? UINavigationController)
|
|
@@ -292,4 +292,260 @@ final class ShellSnapshotTests: XCTestCase {
|
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
@MainActor
|
|
296
|
+
func testTabTitleLayoutWarmupRestoresSelection() throws {
|
|
297
|
+
let image = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { context in
|
|
298
|
+
UIColor.black.setFill()
|
|
299
|
+
context.fill(CGRect(x: 0, y: 0, width: 24, height: 24))
|
|
300
|
+
}.pngData()!.base64EncodedString()
|
|
301
|
+
let items = [
|
|
302
|
+
item(["id": "first", "label": "Index", "selected": true, "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
|
|
303
|
+
item(["id": "second", "label": "Docs", "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
|
|
304
|
+
item(["id": "third", "label": "Library", "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
|
|
305
|
+
]
|
|
306
|
+
let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "width": 280.0, "height": 62.0, "items": items])]).controls.first)
|
|
307
|
+
let bar = UITabBar()
|
|
308
|
+
ShellTabBar.update(bar, node: node, rendering: ShellRendering())
|
|
309
|
+
XCTAssertTrue(ShellTabBar.fit(bar, node: node, bounds: CGRect(x: 20, y: 700, width: 280, height: 62)))
|
|
310
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
311
|
+
// A second fit without content changes must not leave a pending warmup.
|
|
312
|
+
XCTAssertTrue(ShellTabBar.fit(bar, node: node, bounds: CGRect(x: 20, y: 700, width: 280, height: 62)))
|
|
313
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
@MainActor
|
|
317
|
+
func testTabPendingSelectionIgnoresStaleDomEcho() throws {
|
|
318
|
+
let first = item(["id": "first", "selected": true])
|
|
319
|
+
let second = item(["id": "second"])
|
|
320
|
+
let bar = UITabBar()
|
|
321
|
+
let initial = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [first, second]])]).controls.first)
|
|
322
|
+
ShellTabBar.update(bar, node: initial, rendering: ShellRendering())
|
|
323
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
324
|
+
|
|
325
|
+
var pending: ShellTabBar.PendingSelection? = .start("second")
|
|
326
|
+
// Stale Web echo still reports the previous tab.
|
|
327
|
+
ShellTabBar.update(bar, node: initial, rendering: ShellRendering(), pendingSelection: &pending)
|
|
328
|
+
XCTAssertEqual(pending?.id, "second")
|
|
329
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "second")
|
|
330
|
+
|
|
331
|
+
let echoed = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [
|
|
332
|
+
item(["id": "first"]), item(["id": "second", "selected": true]),
|
|
333
|
+
]])]).controls.first)
|
|
334
|
+
ShellTabBar.update(bar, node: echoed, rendering: ShellRendering(), pendingSelection: &pending)
|
|
335
|
+
XCTAssertNil(pending)
|
|
336
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "second")
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
@MainActor
|
|
340
|
+
func testTabPendingSelectionFallsBackWhenDomRejects() throws {
|
|
341
|
+
let first = item(["id": "first", "selected": true])
|
|
342
|
+
let second = item(["id": "second"])
|
|
343
|
+
let bar = UITabBar()
|
|
344
|
+
let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [first, second]])]).controls.first)
|
|
345
|
+
ShellTabBar.update(bar, node: node, rendering: ShellRendering())
|
|
346
|
+
bar.selectedItem = bar.items?.first { $0.accessibilityIdentifier == "second" }
|
|
347
|
+
|
|
348
|
+
var pending: ShellTabBar.PendingSelection? = ShellTabBar.PendingSelection(id: "second", until: CFAbsoluteTimeGetCurrent() - 1)
|
|
349
|
+
ShellTabBar.update(bar, node: node, rendering: ShellRendering(), pendingSelection: &pending)
|
|
350
|
+
XCTAssertNil(pending)
|
|
351
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
352
|
+
|
|
353
|
+
pending = .start("second")
|
|
354
|
+
let disabled = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [
|
|
355
|
+
item(["id": "first", "selected": true]), item(["id": "second", "disabled": true]),
|
|
356
|
+
]])]).controls.first)
|
|
357
|
+
bar.selectedItem = bar.items?.first { $0.accessibilityIdentifier == "second" }
|
|
358
|
+
ShellTabBar.update(bar, node: disabled, rendering: ShellRendering(), pendingSelection: &pending)
|
|
359
|
+
XCTAssertNil(pending)
|
|
360
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
@MainActor
|
|
364
|
+
func testSearchOptimisticTabSelectionIgnoresStaleDomEcho() throws {
|
|
365
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
366
|
+
let controller = ShellSearchController()
|
|
367
|
+
let rendering = ShellRendering()
|
|
368
|
+
let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
369
|
+
let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
|
|
370
|
+
let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
|
|
371
|
+
func node(selected: String) throws -> ShellControl {
|
|
372
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
373
|
+
"closeId": "close", "active": false, "available": true, "focused": false,
|
|
374
|
+
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
375
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
376
|
+
"items": [item(["id": "first", "selected": selected == "first"]),
|
|
377
|
+
item(["id": "second", "selected": selected == "second"])]])]).controls.first)
|
|
378
|
+
}
|
|
379
|
+
_ = controller.apply(try node(selected: "first"), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
380
|
+
let second = try XCTUnwrap(controller.tabs.first { $0.identifier == "second" })
|
|
381
|
+
var activated: [String] = []
|
|
382
|
+
controller.activate = { activated.append($0) }
|
|
383
|
+
XCTAssertTrue(controller.tabBarController(controller, shouldSelectTab: second))
|
|
384
|
+
XCTAssertEqual(activated, ["second"])
|
|
385
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
386
|
+
// Stale Web echo still reports the previous tab.
|
|
387
|
+
_ = controller.apply(try node(selected: "first"), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
388
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
389
|
+
// Web catches up — clear pending without bouncing selection.
|
|
390
|
+
_ = controller.apply(try node(selected: "second"), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
391
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
@MainActor
|
|
395
|
+
func testSearchLocksProjectionWhileActiveAndKeepsApplicationValue() throws {
|
|
396
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
397
|
+
let controller = ShellSearchController()
|
|
398
|
+
let rendering = ShellRendering()
|
|
399
|
+
let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
400
|
+
let shrunk = CGRect(x: 0, y: 0, width: 390, height: 500)
|
|
401
|
+
func node(active: Bool, focused: Bool, value: String = "", valueVersion: Int = 0) throws -> ShellControl {
|
|
402
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
403
|
+
"closeId": "close", "active": active, "available": true, "focused": focused,
|
|
404
|
+
"value": value, "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": valueVersion]
|
|
405
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
406
|
+
"items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
|
|
407
|
+
}
|
|
408
|
+
// Resting layout may reject without a full window hierarchy; surface is still assigned.
|
|
409
|
+
_ = controller.apply(try node(active: false, focused: false), webFrame: resting,
|
|
410
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
411
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
|
|
412
|
+
controller.surface.frame = resting
|
|
413
|
+
// Active (even before focus) freezes Web layout for the whole search session.
|
|
414
|
+
XCTAssertTrue(controller.apply(try node(active: true, focused: false), webFrame: resting,
|
|
415
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
416
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
417
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
418
|
+
XCTAssertTrue(controller.apply(try node(active: true, focused: false), webFrame: shrunk,
|
|
419
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
420
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
421
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
422
|
+
// Application realtime value updates still cross the lock.
|
|
423
|
+
XCTAssertTrue(controller.apply(try node(active: true, focused: true, value: "external", valueVersion: 2), webFrame: shrunk,
|
|
424
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
425
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
426
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
427
|
+
let navigation = try XCTUnwrap(controller.tabs.last?.viewController as? UINavigationController)
|
|
428
|
+
let searchBar = try XCTUnwrap(navigation.topViewController?.navigationItem.searchController?.searchBar)
|
|
429
|
+
XCTAssertEqual(searchBar.text, "external")
|
|
430
|
+
// Blur request returns to presented (active, not focused) without unlocking projection.
|
|
431
|
+
XCTAssertTrue(controller.apply(try node(active: true, focused: false, value: "external", valueVersion: 2), webFrame: shrunk,
|
|
432
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
433
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
434
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
435
|
+
XCTAssertFalse(searchBar.searchTextField.isFirstResponder)
|
|
436
|
+
let tabsBeforeLeave = controller.tabs.map(\.identifier)
|
|
437
|
+
// Leave re-fits; without a window hierarchy fit may reject while still restoring selection.
|
|
438
|
+
_ = controller.apply(try node(active: false, focused: false, value: "external", valueVersion: 2), webFrame: resting,
|
|
439
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
440
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
|
|
441
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
442
|
+
// Leave must reassign tabs even when identifiers match (UISearchTab morph cleanup).
|
|
443
|
+
XCTAssertEqual(controller.tabs.map(\.identifier), tabsBeforeLeave)
|
|
444
|
+
XCTAssertEqual(controller.tabs.count, 3) // first, second, search
|
|
445
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "first")
|
|
446
|
+
// Switching ordinary tabs after leave must keep resting chrome (no deferred morph expand).
|
|
447
|
+
func nodeSelecting(_ id: String) throws -> ShellControl {
|
|
448
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
449
|
+
"closeId": "close", "active": false, "available": true, "focused": false,
|
|
450
|
+
"value": "external", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 2]
|
|
451
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
452
|
+
"items": [item(["id": "first", "selected": id == "first"]),
|
|
453
|
+
item(["id": "second", "selected": id == "second"])]])]).controls.first)
|
|
454
|
+
}
|
|
455
|
+
let tabsAfterLeave = controller.tabs.map(\.identifier)
|
|
456
|
+
_ = controller.apply(try nodeSelecting("second"), webFrame: resting,
|
|
457
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
458
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
|
|
459
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
460
|
+
XCTAssertEqual(controller.tabs.map(\.identifier), tabsAfterLeave)
|
|
461
|
+
_ = controller.apply(try nodeSelecting("first"), webFrame: resting,
|
|
462
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
463
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
|
|
464
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "first")
|
|
465
|
+
XCTAssertEqual(controller.tabs.map(\.identifier), tabsAfterLeave)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
@MainActor
|
|
469
|
+
func testSearchRelocksProjectionWidthAfterRotation() throws {
|
|
470
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
471
|
+
let controller = ShellSearchController()
|
|
472
|
+
let rendering = ShellRendering()
|
|
473
|
+
let portrait = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
474
|
+
let landscape = CGRect(x: 0, y: 0, width: 844, height: 390)
|
|
475
|
+
let keyboardShrunk = CGRect(x: 0, y: 0, width: 844, height: 200)
|
|
476
|
+
func node(active: Bool) throws -> ShellControl {
|
|
477
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
478
|
+
"closeId": "close", "active": active, "available": true, "focused": false,
|
|
479
|
+
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
480
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
481
|
+
"items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
|
|
482
|
+
}
|
|
483
|
+
let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
|
|
484
|
+
let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
|
|
485
|
+
_ = controller.apply(try node(active: false), webFrame: portrait, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
486
|
+
controller.surface.frame = portrait
|
|
487
|
+
XCTAssertTrue(controller.apply(try node(active: true), webFrame: portrait, barFrame: bar, triggerFrame: trigger, rendering: rendering))
|
|
488
|
+
XCTAssertEqual(controller.surface.frame, portrait)
|
|
489
|
+
// Rotation must adopt the new width instead of keeping the portrait lock.
|
|
490
|
+
XCTAssertTrue(controller.apply(try node(active: true), webFrame: landscape, barFrame: bar, triggerFrame: trigger, rendering: rendering))
|
|
491
|
+
XCTAssertEqual(controller.surface.frame, landscape)
|
|
492
|
+
// Keyboard shrink at the same width must still be ignored.
|
|
493
|
+
XCTAssertTrue(controller.apply(try node(active: true), webFrame: keyboardShrunk, barFrame: bar, triggerFrame: trigger, rendering: rendering))
|
|
494
|
+
XCTAssertEqual(controller.surface.frame, landscape)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
@MainActor
|
|
498
|
+
func testSearchPendingSelectionExpiresWithoutLaterApply() throws {
|
|
499
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
500
|
+
let controller = ShellSearchController()
|
|
501
|
+
let rendering = ShellRendering()
|
|
502
|
+
let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
503
|
+
let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
|
|
504
|
+
let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
|
|
505
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
506
|
+
"closeId": "close", "active": false, "available": true, "focused": false,
|
|
507
|
+
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
508
|
+
let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
509
|
+
"items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
|
|
510
|
+
_ = controller.apply(node, webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
511
|
+
let second = try XCTUnwrap(controller.tabs.first { $0.identifier == "second" })
|
|
512
|
+
XCTAssertTrue(controller.tabBarController(controller, shouldSelectTab: second))
|
|
513
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
514
|
+
let expectation = expectation(description: "pending selection expires back to DOM")
|
|
515
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.15) {
|
|
516
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "first")
|
|
517
|
+
expectation.fulfill()
|
|
518
|
+
}
|
|
519
|
+
wait(for: [expectation], timeout: 2)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
@MainActor
|
|
523
|
+
func testSearchEmitsInputAfterRestingToActive() throws {
|
|
524
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
525
|
+
let controller = ShellSearchController()
|
|
526
|
+
let rendering = ShellRendering()
|
|
527
|
+
let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
528
|
+
let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
|
|
529
|
+
let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
|
|
530
|
+
func node(active: Bool) throws -> ShellControl {
|
|
531
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
532
|
+
"closeId": "close", "active": active, "available": true, "focused": active,
|
|
533
|
+
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
534
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
535
|
+
"items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
|
|
536
|
+
}
|
|
537
|
+
// Resting must not permanently close the editing bridge.
|
|
538
|
+
_ = controller.apply(try node(active: false), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
539
|
+
var phases: [String] = []
|
|
540
|
+
controller.changed = { _, phase, _, _, _ in
|
|
541
|
+
phases.append(phase.rawValue)
|
|
542
|
+
return phases.count
|
|
543
|
+
}
|
|
544
|
+
XCTAssertTrue(controller.apply(try node(active: true), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering))
|
|
545
|
+
let navigation = try XCTUnwrap(controller.tabs.last?.viewController as? UINavigationController)
|
|
546
|
+
let searchBar = try XCTUnwrap(navigation.topViewController?.navigationItem.searchController?.searchBar)
|
|
547
|
+
controller.searchBar(searchBar, textDidChange: "query")
|
|
548
|
+
XCTAssertEqual(phases, ["input"])
|
|
549
|
+
}
|
|
550
|
+
|
|
295
551
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Masahiko Sakakibara
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @rdlabo/ionic-theme-utils
|
|
2
|
+
|
|
3
|
+
Shared, theme-neutral utilities for Rdlabo Ionic themes.
|
|
4
|
+
|
|
5
|
+
TypeScript and Sass sources are exported directly. Consumers are expected to
|
|
6
|
+
compile them as part of an Angular/Ionic application build.
|
|
7
|
+
|
|
8
|
+
```typescript
|
|
9
|
+
import { createIosTransitionAnimation, getPopoverPosition } from '@rdlabo/ionic-theme-utils';
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```scss
|
|
13
|
+
@use 'pkg:@rdlabo/ionic-theme-utils/styles/structured-list';
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The package currently contains shared iOS transition construction, popover
|
|
17
|
+
positioning, searchable tab-bar utilities, and structured-list Sass mixins.
|
|
18
|
+
|
|
19
|
+
## Development
|
|
20
|
+
|
|
21
|
+
```shell
|
|
22
|
+
npm install
|
|
23
|
+
npm run build
|
|
24
|
+
npm run lint
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Use `npm link` while developing this package together with a theme repository.
|
|
28
|
+
Theme repositories should use a tagged Git dependency for reproducible installs.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dom.d.ts","sourceRoot":"","sources":["../src/dom.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,cAAc,GAAI,IAAI,WAAW,EAAE,WAAU,WAAgB,6BAEzE,CAAC;AAEF,eAAO,MAAM,GAAG,GAAI,GAAG,oBAAoB,QAQ1C,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const getElementRoot = (el, fallback = el) => {
|
|
2
|
+
return el.shadowRoot || fallback;
|
|
3
|
+
};
|
|
4
|
+
export const raf = (h) => {
|
|
5
|
+
if (typeof __zone_symbol__requestAnimationFrame === 'function') {
|
|
6
|
+
return __zone_symbol__requestAnimationFrame(h);
|
|
7
|
+
}
|
|
8
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
9
|
+
return requestAnimationFrame(h);
|
|
10
|
+
}
|
|
11
|
+
return setTimeout(h);
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=dom.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dom.js","sourceRoot":"","sources":["../src/dom.ts"],"names":[],"mappings":"AAGA,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,EAAe,EAAE,WAAwB,EAAE,EAAE,EAAE;IAC5E,OAAO,EAAE,CAAC,UAAU,IAAI,QAAQ,CAAC;AACnC,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,GAAG,GAAG,CAAC,CAAuB,EAAE,EAAE;IAC7C,IAAI,OAAO,oCAAoC,KAAK,UAAU,EAAE,CAAC;QAC/D,OAAO,oCAAoC,CAAC,CAAC,CAAC,CAAC;IACjD,CAAC;IACD,IAAI,OAAO,qBAAqB,KAAK,UAAU,EAAE,CAAC;QAChD,OAAO,qBAAqB,CAAC,CAAC,CAAC,CAAC;IAClC,CAAC;IACD,OAAO,UAAU,CAAC,CAAC,CAAC,CAAC;AACvB,CAAC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"focus-controller.d.ts","sourceRoot":"","sources":["../src/focus-controller.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,eAAe;IAC9B,aAAa,CAAC,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/C,YAAY,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAAC;CAC9C;AAED,eAAO,MAAM,qBAAqB,QAAO,eAuBvC,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { config } from './theme';
|
|
2
|
+
const LAST_FOCUS = 'ion-last-focus';
|
|
3
|
+
const moveFocus = (element) => {
|
|
4
|
+
element.tabIndex = -1;
|
|
5
|
+
element.focus();
|
|
6
|
+
};
|
|
7
|
+
const isVisible = (element) => element.offsetParent !== null;
|
|
8
|
+
export const createFocusController = () => ({
|
|
9
|
+
saveViewFocus: (referenceEl) => {
|
|
10
|
+
if (!config.get('focusManagerPriority', false))
|
|
11
|
+
return;
|
|
12
|
+
const activeEl = document.activeElement;
|
|
13
|
+
if (activeEl && referenceEl?.contains(activeEl))
|
|
14
|
+
activeEl.setAttribute(LAST_FOCUS, 'true');
|
|
15
|
+
},
|
|
16
|
+
setViewFocus: (referenceEl) => {
|
|
17
|
+
const priorities = config.get('focusManagerPriority', false);
|
|
18
|
+
if (!Array.isArray(priorities) || referenceEl.contains(document.activeElement))
|
|
19
|
+
return;
|
|
20
|
+
const lastFocus = referenceEl.querySelector(`[${LAST_FOCUS}]`);
|
|
21
|
+
if (lastFocus && isVisible(lastFocus))
|
|
22
|
+
return moveFocus(lastFocus);
|
|
23
|
+
const selectors = {
|
|
24
|
+
content: 'main, [role="main"]',
|
|
25
|
+
heading: 'h1, [role="heading"][aria-level="1"]',
|
|
26
|
+
banner: 'header, [role="banner"]',
|
|
27
|
+
};
|
|
28
|
+
for (const priority of priorities) {
|
|
29
|
+
const selector = selectors[String(priority)];
|
|
30
|
+
const candidate = selector ? referenceEl.querySelector(selector) : null;
|
|
31
|
+
if (candidate && isVisible(candidate))
|
|
32
|
+
return moveFocus(candidate);
|
|
33
|
+
}
|
|
34
|
+
moveFocus(referenceEl);
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
//# sourceMappingURL=focus-controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"focus-controller.js","sourceRoot":"","sources":["../src/focus-controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAEjC,MAAM,UAAU,GAAG,gBAAgB,CAAC;AACpC,MAAM,SAAS,GAAG,CAAC,OAAoB,EAAE,EAAE;IACzC,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC;IACtB,OAAO,CAAC,KAAK,EAAE,CAAC;AAClB,CAAC,CAAC;AACF,MAAM,SAAS,GAAG,CAAC,OAAoB,EAAE,EAAE,CAAC,OAAO,CAAC,YAAY,KAAK,IAAI,CAAC;AAO1E,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAoB,EAAE,CAAC,CAAC;IAC3D,aAAa,EAAE,CAAC,WAAW,EAAE,EAAE;QAC7B,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,sBAAsB,EAAE,KAAK,CAAC;YAAE,OAAO;QACvD,MAAM,QAAQ,GAAG,QAAQ,CAAC,aAAa,CAAC;QACxC,IAAI,QAAQ,IAAI,WAAW,EAAE,QAAQ,CAAC,QAAQ,CAAC;YAAE,QAAQ,CAAC,YAAY,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;IAC7F,CAAC;IACD,YAAY,EAAE,CAAC,WAAW,EAAE,EAAE;QAC5B,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAU,sBAAsB,EAAE,KAAK,CAAC,CAAC;QACtE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,IAAI,WAAW,CAAC,QAAQ,CAAC,QAAQ,CAAC,aAAa,CAAC;YAAE,OAAO;QACvF,MAAM,SAAS,GAAG,WAAW,CAAC,aAAa,CAAc,IAAI,UAAU,GAAG,CAAC,CAAC;QAC5E,IAAI,SAAS,IAAI,SAAS,CAAC,SAAS,CAAC;YAAE,OAAO,SAAS,CAAC,SAAS,CAAC,CAAC;QACnE,MAAM,SAAS,GAA2B;YACxC,OAAO,EAAE,qBAAqB;YAC9B,OAAO,EAAE,sCAAsC;YAC/C,MAAM,EAAE,yBAAyB;SAClC,CAAC;QACF,KAAK,MAAM,QAAQ,IAAI,UAAU,EAAE,CAAC;YAClC,MAAM,QAAQ,GAAG,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;YAC7C,MAAM,SAAS,GAAG,QAAQ,CAAC,CAAC,CAAC,WAAW,CAAC,aAAa,CAAc,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;YACrF,IAAI,SAAS,IAAI,SAAS,CAAC,SAAS,CAAC;gBAAE,OAAO,SAAS,CAAC,SAAS,CAAC,CAAC;QACrE,CAAC;QACD,SAAS,CAAC,WAAW,CAAC,CAAC;IACzB,CAAC;CACF,CAAC,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { createIosTransitionAnimation, shadow, type IosTransitionAnimationConfig, type IosTransitionAnimationOptions, } from './transition/ios.transition';
|
|
2
|
+
export type { PopoverInterface, PopoverOptions, PopoverSize, TriggerAction, PositionReference, PositionSide, PositionAlign, } from './popover/popover-interface';
|
|
3
|
+
export { POPOVER_IOS_BODY_MARGIN, getPopoverDimensions, configureDismissInteraction, configureTriggerInteraction, getIndexOfItem, getNextItem, getPrevItem, getPopoverPosition, calculateWindowAdjustment, type ReferenceCoordinates, type PopoverStyles, } from './popover/utils';
|
|
4
|
+
export { createCalloutSurface } from './popover/callout-surface';
|
|
5
|
+
export { iosEnterAnimation as iosPopoverEnterAnimation } from './popover/animations/ios.enter';
|
|
6
|
+
export { iosLeaveAnimation as iosPopoverLeaveAnimation } from './popover/animations/ios.leave';
|
|
7
|
+
export { cloneElement, getStep, changeSelectedElement, Config, config } from './theme';
|
|
8
|
+
export { getElementRoot, raf } from './dom';
|
|
9
|
+
export { createFocusController, type FocusController } from './focus-controller';
|
|
10
|
+
export type { EffectScales, registeredEffect, AnimationPosition } from './sheets-of-glass';
|
|
11
|
+
export { TabBarSearchableType, type TabBarSearchableFunction, type SearchableEventCache, type ElementSizes, type ElementReferences, } from './tab-bar-searchable/interfaces';
|
|
12
|
+
export { ANIMATION_DURATION, ANIMATION_DELAY_BASE, ANIMATION_DELAY_CLOSE_BUTTONS, ANIMATION_EASING, OPACITY_TRANSITION, throwErrorByFailedClickElement, throwErrorByFailedExistElement, getElement, getElementReferences, getElementSizes, } from './tab-bar-searchable/utils';
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|