@rdlabo/capacitor-native-ui 0.0.0-stage → 2.0.0-1
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/LICENSE +21 -0
- package/Package.swift +23 -0
- package/README.md +66 -2
- package/RdlaboCapacitorNativeUi.podspec +17 -0
- package/android/build.gradle +69 -0
- package/android/proguard-rules.pro +0 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUI.kt +51 -0
- package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUIPlugin.kt +192 -0
- package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUISnapshot.kt +57 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/core.d.ts +4 -0
- package/dist/core.js +3 -0
- package/dist/native/activation.d.ts +14 -0
- package/dist/native/activation.d.ts.map +1 -0
- package/dist/native/activation.js +305 -0
- package/dist/native/activation.js.map +1 -0
- package/dist/native/components/index.d.ts +19 -0
- package/dist/native/components/index.d.ts.map +1 -0
- package/dist/native/components/index.js +38 -0
- package/dist/native/components/index.js.map +1 -0
- package/dist/native/components/ion-back-button.d.ts +10 -0
- package/dist/native/components/ion-back-button.d.ts.map +1 -0
- package/dist/native/components/ion-back-button.js +21 -0
- package/dist/native/components/ion-back-button.js.map +1 -0
- package/dist/native/components/ion-button.d.ts +10 -0
- package/dist/native/components/ion-button.d.ts.map +1 -0
- package/dist/native/components/ion-button.js +25 -0
- package/dist/native/components/ion-button.js.map +1 -0
- package/dist/native/components/ion-buttons.d.ts +11 -0
- package/dist/native/components/ion-buttons.d.ts.map +1 -0
- package/dist/native/components/ion-buttons.js +30 -0
- package/dist/native/components/ion-buttons.js.map +1 -0
- package/dist/native/components/ion-fab.d.ts +10 -0
- package/dist/native/components/ion-fab.d.ts.map +1 -0
- package/dist/native/components/ion-fab.js +132 -0
- package/dist/native/components/ion-fab.js.map +1 -0
- package/dist/native/components/ion-menu-button.d.ts +13 -0
- package/dist/native/components/ion-menu-button.d.ts.map +1 -0
- package/dist/native/components/ion-menu-button.js +44 -0
- package/dist/native/components/ion-menu-button.js.map +1 -0
- package/dist/native/components/ion-segment.d.ts +11 -0
- package/dist/native/components/ion-segment.d.ts.map +1 -0
- package/dist/native/components/ion-segment.js +30 -0
- package/dist/native/components/ion-segment.js.map +1 -0
- package/dist/native/components/ion-tab-bar.d.ts +11 -0
- package/dist/native/components/ion-tab-bar.d.ts.map +1 -0
- package/dist/native/components/ion-tab-bar.js +50 -0
- package/dist/native/components/ion-tab-bar.js.map +1 -0
- package/dist/native/components/searchable-tabs.d.ts +20 -0
- package/dist/native/components/searchable-tabs.d.ts.map +1 -0
- package/dist/native/components/searchable-tabs.js +413 -0
- package/dist/native/components/searchable-tabs.js.map +1 -0
- package/dist/native/definitions.d.ts +237 -0
- package/dist/native/definitions.d.ts.map +1 -0
- package/dist/native/definitions.js +0 -0
- package/dist/native/extensions.d.ts +31 -0
- package/dist/native/extensions.d.ts.map +1 -0
- package/dist/native/extensions.js +122 -0
- package/dist/native/extensions.js.map +1 -0
- package/dist/native/foldable/fold-state.d.ts +5 -0
- package/dist/native/foldable/fold-state.d.ts.map +1 -0
- package/dist/native/foldable/fold-state.js +15 -0
- package/dist/native/foldable/fold-state.js.map +1 -0
- package/dist/native/foldable/types.d.ts +12 -0
- package/dist/native/foldable/types.d.ts.map +1 -0
- package/dist/native/foldable/types.js +0 -0
- package/dist/native/html.d.ts +9 -0
- package/dist/native/html.d.ts.map +1 -0
- package/dist/native/html.js +197 -0
- package/dist/native/html.js.map +1 -0
- package/dist/native/index.d.ts +10 -0
- package/dist/native/index.d.ts.map +1 -0
- package/dist/native/index.js +19 -0
- package/dist/native/index.js.map +1 -0
- package/dist/native/legacy-layout.d.ts +5 -0
- package/dist/native/legacy-layout.d.ts.map +1 -0
- package/dist/native/legacy-layout.js +27 -0
- package/dist/native/legacy-layout.js.map +1 -0
- package/dist/native/overlays/content.d.ts +4 -0
- package/dist/native/overlays/content.d.ts.map +1 -0
- package/dist/native/overlays/content.js +51 -0
- package/dist/native/overlays/content.js.map +1 -0
- package/dist/native/overlays/controller.d.ts +8 -0
- package/dist/native/overlays/controller.d.ts.map +1 -0
- package/dist/native/overlays/controller.js +285 -0
- package/dist/native/overlays/controller.js.map +1 -0
- package/dist/native/overlays/dialog-animation.d.ts +9 -0
- package/dist/native/overlays/dialog-animation.d.ts.map +1 -0
- package/dist/native/overlays/dialog-animation.js +53 -0
- package/dist/native/overlays/dialog-animation.js.map +1 -0
- package/dist/native/overlays/dismiss-animation.d.ts +4 -0
- package/dist/native/overlays/dismiss-animation.d.ts.map +1 -0
- package/dist/native/overlays/dismiss-animation.js +18 -0
- package/dist/native/overlays/dismiss-animation.js.map +1 -0
- package/dist/native/overlays/focus.d.ts +5 -0
- package/dist/native/overlays/focus.d.ts.map +1 -0
- package/dist/native/overlays/focus.js +59 -0
- package/dist/native/overlays/focus.js.map +1 -0
- package/dist/native/overlays/popover.d.ts +12 -0
- package/dist/native/overlays/popover.d.ts.map +1 -0
- package/dist/native/overlays/popover.js +110 -0
- package/dist/native/overlays/popover.js.map +1 -0
- package/dist/native/overlays/styles.d.ts +11 -0
- package/dist/native/overlays/styles.d.ts.map +1 -0
- package/dist/native/overlays/styles.js +167 -0
- package/dist/native/overlays/styles.js.map +1 -0
- package/dist/native/prehide.d.ts +8 -0
- package/dist/native/prehide.d.ts.map +1 -0
- package/dist/native/prehide.js +238 -0
- package/dist/native/prehide.js.map +1 -0
- package/dist/native/runtime.d.ts +7 -0
- package/dist/native/runtime.d.ts.map +1 -0
- package/dist/native/runtime.js +776 -0
- package/dist/native/runtime.js.map +1 -0
- package/dist/native/shared/bridge.d.ts +6 -0
- package/dist/native/shared/bridge.d.ts.map +1 -0
- package/dist/native/shared/bridge.js +18 -0
- package/dist/native/shared/bridge.js.map +1 -0
- package/dist/native/shared/candidate.d.ts +25 -0
- package/dist/native/shared/candidate.d.ts.map +1 -0
- package/dist/native/shared/candidate.js +106 -0
- package/dist/native/shared/candidate.js.map +1 -0
- package/dist/native/shared/clipping.d.ts +5 -0
- package/dist/native/shared/clipping.d.ts.map +1 -0
- package/dist/native/shared/clipping.js +26 -0
- package/dist/native/shared/clipping.js.map +1 -0
- package/dist/native/shared/control-options.d.ts +6 -0
- package/dist/native/shared/control-options.d.ts.map +1 -0
- package/dist/native/shared/control-options.js +17 -0
- package/dist/native/shared/control-options.js.map +1 -0
- package/dist/native/shared/crossfade.d.ts +12 -0
- package/dist/native/shared/crossfade.d.ts.map +1 -0
- package/dist/native/shared/crossfade.js +67 -0
- package/dist/native/shared/crossfade.js.map +1 -0
- package/dist/native/shared/diagnostics.d.ts +4 -0
- package/dist/native/shared/diagnostics.d.ts.map +1 -0
- package/dist/native/shared/diagnostics.js +18 -0
- package/dist/native/shared/diagnostics.js.map +1 -0
- package/dist/native/shared/dom.d.ts +46 -0
- package/dist/native/shared/dom.d.ts.map +1 -0
- package/dist/native/shared/dom.js +180 -0
- package/dist/native/shared/dom.js.map +1 -0
- package/dist/native/shared/events.d.ts +15 -0
- package/dist/native/shared/events.d.ts.map +1 -0
- package/dist/native/shared/events.js +17 -0
- package/dist/native/shared/events.js.map +1 -0
- package/dist/native/shared/icons.d.ts +8 -0
- package/dist/native/shared/icons.d.ts.map +1 -0
- package/dist/native/shared/icons.js +61 -0
- package/dist/native/shared/icons.js.map +1 -0
- package/dist/native/shared/modal.d.ts +10 -0
- package/dist/native/shared/modal.d.ts.map +1 -0
- package/dist/native/shared/modal.js +160 -0
- package/dist/native/shared/modal.js.map +1 -0
- package/dist/native/shared/projection-id.d.ts +4 -0
- package/dist/native/shared/projection-id.d.ts.map +1 -0
- package/dist/native/shared/projection-id.js +7 -0
- package/dist/native/shared/projection-id.js.map +1 -0
- package/dist/native/shared/toolbar-options.d.ts +13 -0
- package/dist/native/shared/toolbar-options.d.ts.map +1 -0
- package/dist/native/shared/toolbar-options.js +28 -0
- package/dist/native/shared/toolbar-options.js.map +1 -0
- package/dist/native/shared/vertical-bars-geometry.d.ts +10 -0
- package/dist/native/shared/vertical-bars-geometry.d.ts.map +1 -0
- package/dist/native/shared/vertical-bars-geometry.js +85 -0
- package/dist/native/shared/vertical-bars-geometry.js.map +1 -0
- package/dist/native/startup.d.ts +8 -0
- package/dist/native/startup.d.ts.map +1 -0
- package/dist/native/startup.js +70 -0
- package/dist/native/startup.js.map +1 -0
- package/dist/native/vertical-bars-web-search.d.ts +10 -0
- package/dist/native/vertical-bars-web-search.d.ts.map +1 -0
- package/dist/native/vertical-bars-web-search.js +90 -0
- package/dist/native/vertical-bars-web-search.js.map +1 -0
- package/dist/native/vertical-bars-web.d.ts +5 -0
- package/dist/native/vertical-bars-web.d.ts.map +1 -0
- package/dist/native/vertical-bars-web.js +422 -0
- package/dist/native/vertical-bars-web.js.map +1 -0
- package/dist/native-integration/index.d.ts +25 -0
- package/dist/native-integration/index.d.ts.map +1 -0
- package/dist/native-integration/index.js +33 -0
- package/dist/native-integration/index.js.map +1 -0
- package/dist/vertical-bars.d.ts +6 -0
- package/dist/vertical-bars.js +5 -0
- package/docs/appearance.md +45 -0
- package/docs/configuration.md +219 -0
- package/docs/framework-independent.md +107 -0
- package/docs/ionic-vertical-bars.md +173 -0
- package/docs/migration.md +184 -0
- package/docs/native-ui-extensions.md +394 -0
- package/docs/native-ui.md +528 -0
- package/docs/setup.md +224 -0
- package/docs/vertical-bars.md +431 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIButton.swift +71 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIButtons.swift +24 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIComponents.swift +19 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIFab.swift +80 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift +439 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUISegment.swift +91 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift +218 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +590 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalSearch.swift +118 -0
- package/ios/Sources/NativeUIPlugin/Extensions/NativeUI.swift +178 -0
- package/ios/Sources/NativeUIPlugin/Extensions/NativeUIRendering.swift +38 -0
- package/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +615 -0
- package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayController.swift +119 -0
- package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayHost.swift +75 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUIControlRendering.swift +21 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUICrossfade.swift +35 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUIHost.swift +8 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift +161 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUIVerticalBarsGeometry.swift +52 -0
- package/ios/Tests/NativeUIPluginTests/NativeUIExtensionTests.swift +165 -0
- package/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +805 -0
- package/package.json +89 -4
- package/src/core.ts +14 -0
- package/src/native/activation.ts +385 -0
- package/src/native/components/index.ts +45 -0
- package/src/native/components/ion-back-button.ts +22 -0
- package/src/native/components/ion-button.ts +25 -0
- package/src/native/components/ion-buttons.ts +37 -0
- package/src/native/components/ion-fab.ts +151 -0
- package/src/native/components/ion-menu-button.ts +37 -0
- package/src/native/components/ion-segment.ts +27 -0
- package/src/native/components/ion-tab-bar.ts +50 -0
- package/src/native/components/searchable-tabs.ts +499 -0
- package/src/native/definitions.ts +274 -0
- package/src/native/extensions.ts +176 -0
- package/src/native/foldable/fold-state.ts +10 -0
- package/src/native/foldable/types.ts +4 -0
- package/src/native/html.ts +199 -0
- package/src/native/index.ts +32 -0
- package/src/native/legacy-layout.ts +21 -0
- package/src/native/overlays/content.ts +51 -0
- package/src/native/overlays/controller.ts +349 -0
- package/src/native/overlays/dialog-animation.ts +53 -0
- package/src/native/overlays/dismiss-animation.ts +15 -0
- package/src/native/overlays/focus.ts +64 -0
- package/src/native/overlays/popover.ts +120 -0
- package/src/native/overlays/styles.ts +181 -0
- package/src/native/prehide.ts +285 -0
- package/src/native/runtime.ts +979 -0
- package/src/native/shared/bridge.ts +18 -0
- package/src/native/shared/candidate.ts +162 -0
- package/src/native/shared/clipping.ts +24 -0
- package/src/native/shared/control-options.ts +20 -0
- package/src/native/shared/crossfade.ts +67 -0
- package/src/native/shared/diagnostics.ts +13 -0
- package/src/native/shared/dom.ts +304 -0
- package/src/native/shared/events.ts +14 -0
- package/src/native/shared/icons.ts +57 -0
- package/src/native/shared/modal.ts +157 -0
- package/src/native/shared/projection-id.ts +2 -0
- package/src/native/shared/toolbar-options.ts +49 -0
- package/src/native/shared/vertical-bars-geometry.ts +80 -0
- package/src/native/startup.ts +68 -0
- package/src/native/vertical-bars-web-search.ts +86 -0
- package/src/native/vertical-bars-web.ts +500 -0
- package/src/native-integration/index.ts +49 -0
- package/src/vertical-bars.ts +12 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import WebKit
|
|
3
|
+
import Capacitor
|
|
4
|
+
@testable import NativeUIPlugin
|
|
5
|
+
|
|
6
|
+
final class NativeUISnapshotTests: XCTestCase {
|
|
7
|
+
func testStandardRendererKindsDoNotRequireIonicNames() throws {
|
|
8
|
+
let pairs: [(String, String, NativeUIComponent)] = [
|
|
9
|
+
("button", "ion-button", .button), ("button-group", "ion-buttons", .buttons),
|
|
10
|
+
("back-button", "ion-back-button", .backButton), ("menu-button", "ion-menu-button", .menuButton),
|
|
11
|
+
("tab-bar", "ion-tab-bar", .tabBar), ("segment", "ion-segment", .segment),
|
|
12
|
+
("floating-action-button", "ion-fab", .fab)
|
|
13
|
+
]
|
|
14
|
+
for (standard, legacy, expected) in pairs {
|
|
15
|
+
for name in [standard, legacy] {
|
|
16
|
+
let value = try JSONDecoder().decode(NativeUIComponent.self, from: JSONEncoder().encode(name))
|
|
17
|
+
XCTAssertEqual(value, expected)
|
|
18
|
+
XCTAssertEqual(value.rawValue, standard)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
XCTAssertThrowsError(try JSONDecoder().decode(NativeUIComponent.self, from: JSONEncoder().encode("unknown")))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@MainActor func testVerticalBarGeometryIgnoresZeroSizeScaffoldsAndConvertsCoordinates() throws {
|
|
25
|
+
let owner = UIViewController()
|
|
26
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 466, height: 678))
|
|
27
|
+
window.rootViewController = owner
|
|
28
|
+
window.makeKeyAndVisible()
|
|
29
|
+
defer { window.isHidden = true }
|
|
30
|
+
let web = UIView(frame: CGRect(x: 20, y: 30, width: 446, height: 648))
|
|
31
|
+
owner.view.addSubview(web)
|
|
32
|
+
let root = UIView(frame: owner.view.bounds)
|
|
33
|
+
owner.view.addSubview(root)
|
|
34
|
+
let scaffold = UIView(frame: .zero)
|
|
35
|
+
root.addSubview(scaffold)
|
|
36
|
+
let surface = UIView(frame: CGRect(x: 100, y: 170, width: 48, height: 48))
|
|
37
|
+
scaffold.addSubview(surface)
|
|
38
|
+
let label = UIView(frame: CGRect(x: 5, y: 6, width: 38, height: 36))
|
|
39
|
+
label.accessibilityIdentifier = "BackButton"
|
|
40
|
+
surface.addSubview(label)
|
|
41
|
+
let frames = NativeUIVerticalBarsGeometry.measure(in: root, relativeTo: web, ids: ["BackButton"])
|
|
42
|
+
XCTAssertEqual(frames["BackButton"], CGRect(x: 80, y: 140, width: 48, height: 48))
|
|
43
|
+
XCTAssertTrue(NativeUIVerticalBarsGeometry.measure(in: root, relativeTo: web, ids: ["missing"]).isEmpty)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@MainActor func testVerticalPopoverCanCancelBeforeSwiftUIAppearsAndReopen() throws {
|
|
47
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native Vertical Bars") }
|
|
48
|
+
let owner = UIViewController()
|
|
49
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 700, height: 900))
|
|
50
|
+
window.rootViewController = owner
|
|
51
|
+
window.makeKeyAndVisible()
|
|
52
|
+
defer { window.isHidden = true }
|
|
53
|
+
let rail = NativeUIVerticalBarsController(activate: { _ in }, changed: { _, _, _, _, _ in 0 })
|
|
54
|
+
rail.attach(to: owner, in: owner.view)
|
|
55
|
+
defer { rail.detach() }
|
|
56
|
+
rail.apply(try decode([control(["placement": "vertical-bars"])]).controls,
|
|
57
|
+
rendering: NativeUIControlRendering(), edge: "right")
|
|
58
|
+
for _ in 0..<3 {
|
|
59
|
+
let host = NativeUIOverlayHost(configuration: WKWebViewConfiguration(), options: nil, event: { _ in })
|
|
60
|
+
var results: [Bool] = []
|
|
61
|
+
XCTAssertTrue(rail.presentPopover(anchor: "action", host: host, completion: { results.append($0) }))
|
|
62
|
+
// Cancel in the same runloop turn, before SwiftUI can call onAppear.
|
|
63
|
+
XCTAssertFalse(host.anchoredVisible)
|
|
64
|
+
host.dismissAnchored?()
|
|
65
|
+
XCTAssertEqual(results, [false])
|
|
66
|
+
XCTAssertFalse(host.anchoredVisible)
|
|
67
|
+
host.dismissAnchored?()
|
|
68
|
+
XCTAssertEqual(results, [false], "Cancellation must settle presentation only once")
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@MainActor func testVisiblePopoverDismissalNotifiesAllOverlappingCallersOnce() {
|
|
73
|
+
let host = NativeUIOverlayHost(configuration: WKWebViewConfiguration(), options: nil, event: { _ in })
|
|
74
|
+
host.anchoredVisible = true
|
|
75
|
+
var dismissals = 0
|
|
76
|
+
var completed: [Int] = []
|
|
77
|
+
host.dismissAnchored = { dismissals += 1 }
|
|
78
|
+
for caller in 0..<3 {
|
|
79
|
+
host.dismissPopover(animated: false) { completed.append(caller) }
|
|
80
|
+
}
|
|
81
|
+
XCTAssertEqual(dismissals, 1)
|
|
82
|
+
XCTAssertTrue(completed.isEmpty)
|
|
83
|
+
// SwiftUI delivers this callback only after the visible popover disappears.
|
|
84
|
+
host.anchoredVisible = false
|
|
85
|
+
host.completeDismissal()
|
|
86
|
+
XCTAssertEqual(completed, [0, 1, 2])
|
|
87
|
+
host.completeDismissal()
|
|
88
|
+
XCTAssertEqual(completed, [0, 1, 2])
|
|
89
|
+
host.dismissPopover(animated: false) { completed.append(3) }
|
|
90
|
+
XCTAssertEqual(completed, [0, 1, 2, 3])
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@MainActor func testMissingPopoverDisappearanceCancelsPresentationAndSettlesAllWaiters() {
|
|
94
|
+
let host = NativeUIOverlayHost(configuration: WKWebViewConfiguration(), options: nil, event: { _ in })
|
|
95
|
+
host.anchoredVisible = true
|
|
96
|
+
host.dismissAnchored = {} // No onDisappear callback arrives.
|
|
97
|
+
var cancelled = false
|
|
98
|
+
host.cancelAnchored = { cancelled = true }
|
|
99
|
+
let closed = expectation(description: "Both stop requests finish after cancellation")
|
|
100
|
+
closed.expectedFulfillmentCount = 2
|
|
101
|
+
for _ in 0..<2 {
|
|
102
|
+
host.dismissPopover(animated: false) {
|
|
103
|
+
XCTAssertTrue(cancelled)
|
|
104
|
+
closed.fulfill()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
waitForExpectations(timeout: NativeUIOverlayHost.completionTimeout + 2)
|
|
108
|
+
XCTAssertFalse(host.anchoredVisible)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@MainActor func testDetachingRailSettlesPopoverPresentationAndDismissal() throws {
|
|
112
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native Vertical Bars") }
|
|
113
|
+
let owner = UIViewController()
|
|
114
|
+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 700, height: 900))
|
|
115
|
+
window.rootViewController = owner
|
|
116
|
+
window.makeKeyAndVisible()
|
|
117
|
+
defer { window.isHidden = true }
|
|
118
|
+
let rail = NativeUIVerticalBarsController(activate: { _ in }, changed: { _, _, _, _, _ in 0 })
|
|
119
|
+
rail.attach(to: owner, in: owner.view)
|
|
120
|
+
rail.apply(try decode([control(["placement": "vertical-bars"])]).controls,
|
|
121
|
+
rendering: NativeUIControlRendering(), edge: "right")
|
|
122
|
+
let host = NativeUIOverlayHost(configuration: WKWebViewConfiguration(), options: nil, event: { _ in })
|
|
123
|
+
var presentations: [Bool] = []
|
|
124
|
+
XCTAssertTrue(rail.presentPopover(anchor: "action", host: host) { presentations.append($0) })
|
|
125
|
+
host.anchoredVisible = true
|
|
126
|
+
var dismissals = 0
|
|
127
|
+
host.dismissPopover(animated: false) { dismissals += 1 }
|
|
128
|
+
rail.detach()
|
|
129
|
+
XCTAssertEqual(presentations, [false])
|
|
130
|
+
XCTAssertEqual(dismissals, 1)
|
|
131
|
+
XCTAssertFalse(host.anchoredVisible)
|
|
132
|
+
rail.detach()
|
|
133
|
+
XCTAssertEqual(dismissals, 1)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@MainActor func testStoppingPreparedPopoverRestoresWebViewAndAllowsNewSession() {
|
|
137
|
+
let source = WKWebView(frame: .zero)
|
|
138
|
+
let owner = UIViewController()
|
|
139
|
+
final class Delegate: NSObject, WKUIDelegate {}
|
|
140
|
+
let original = Delegate()
|
|
141
|
+
source.uiDelegate = original
|
|
142
|
+
for initiallyAllowed in [false, true] {
|
|
143
|
+
source.configuration.preferences.javaScriptCanOpenWindowsAutomatically = initiallyAllowed
|
|
144
|
+
let previous = NativeUIOverlayController(source: source, owner: owner, event: { _, _ in })
|
|
145
|
+
XCTAssertTrue(previous.prepare("old-context"))
|
|
146
|
+
XCTAssertTrue(source.configuration.preferences.javaScriptCanOpenWindowsAutomatically)
|
|
147
|
+
var stopped = false
|
|
148
|
+
previous.stop { stopped = true }
|
|
149
|
+
XCTAssertTrue(stopped)
|
|
150
|
+
XCTAssertTrue(source.uiDelegate === original)
|
|
151
|
+
XCTAssertEqual(source.configuration.preferences.javaScriptCanOpenWindowsAutomatically, initiallyAllowed)
|
|
152
|
+
|
|
153
|
+
let replacement = NativeUIOverlayController(source: source, owner: owner, event: { _, _ in })
|
|
154
|
+
XCTAssertTrue(replacement.prepare("new-context"))
|
|
155
|
+
replacement.stop {}
|
|
156
|
+
XCTAssertTrue(source.uiDelegate === original)
|
|
157
|
+
XCTAssertEqual(source.configuration.preferences.javaScriptCanOpenWindowsAutomatically, initiallyAllowed)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private func item(_ changes: JSObject = [:]) -> JSObject {
|
|
162
|
+
var value: JSObject = ["id": "action", "x": 4.0, "y": 8.0, "width": 44.0, "height": 44.0,
|
|
163
|
+
"label": "Send", "accessibilityLabel": "Send message", "disabled": false, "selected": false,
|
|
164
|
+
"fontSize": 17.0, "fontWeight": 400.0, "color": "rgb(0, 0, 0)"]
|
|
165
|
+
value.merge(changes) { _, new in new }
|
|
166
|
+
return value
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private func control(_ changes: JSObject = [:]) -> JSObject {
|
|
170
|
+
var value: JSObject = ["id": "control", "kind": "ion-button", "x": 16.0, "y": 60.0,
|
|
171
|
+
"width": 44.0, "height": 44.0, "items": [item()], "dark": false, "rtl": false]
|
|
172
|
+
value.merge(changes) { _, new in new }
|
|
173
|
+
return value
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private func decode(_ controls: [JSObject], width: Double = 390) throws -> NativeUISnapshot {
|
|
177
|
+
try JSValueDecoder().decode(NativeUISnapshot.self, from: ["revision": 1, "viewportWidth": width, "controls": controls])
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
func testModalRailFrameIsOptionalAndRequiresPositiveBounds() throws {
|
|
181
|
+
var payload: JSObject = ["revision": 1, "viewportWidth": 466.0, "controls": [control()]]
|
|
182
|
+
let page = try JSValueDecoder().decode(NativeUISnapshot.self, from: payload)
|
|
183
|
+
XCTAssertNil(page.verticalBarFrame)
|
|
184
|
+
for height in [339.0, 0.0, -1.0] {
|
|
185
|
+
payload["verticalBarFrame"] = ["x": 0.0, "y": 339.0, "width": 466.0, "height": height]
|
|
186
|
+
let modal = try JSValueDecoder().decode(NativeUISnapshot.self, from: payload)
|
|
187
|
+
XCTAssertEqual(modal.isValid, height > 0)
|
|
188
|
+
XCTAssertEqual(modal.verticalBarFrame?.y, 339)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@MainActor func testTabTypographyPreservesCSSWeightsAndSize() throws {
|
|
193
|
+
let weights: [UIFont.Weight] = [.ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .black]
|
|
194
|
+
let tab = UITabBarItem()
|
|
195
|
+
for (index, weight) in weights.enumerated() {
|
|
196
|
+
let cssWeight = Double((index + 1) * 100)
|
|
197
|
+
let content = try decode([control(["items": [item(["fontSize": 19.0, "fontWeight": cssWeight])]])]).controls[0].items[0].content
|
|
198
|
+
NativeUITabBar.applyTypography(content, to: tab)
|
|
199
|
+
for state in [UIControl.State.normal, .selected] {
|
|
200
|
+
let font = try XCTUnwrap(tab.titleTextAttributes(for: state)?[.font] as? UIFont)
|
|
201
|
+
XCTAssertEqual(font.pointSize, 19)
|
|
202
|
+
XCTAssertEqual(font, UIFont.systemFont(ofSize: 19, weight: weight), "CSS weight \(cssWeight)")
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
@MainActor func testVerticalBarsPreserveButtonAppearance() throws {
|
|
208
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive controls") }
|
|
209
|
+
let controls = try decode([control(["kind": "ion-buttons", "items": [
|
|
210
|
+
item(["backgroundColor": "rgb(0, 122, 255)"]),
|
|
211
|
+
item(["id": "clear", "buttonFill": "clear"]),
|
|
212
|
+
item(["id": "outline", "borderColor": "rgb(255, 0, 0)", "borderWidth": 2.0])
|
|
213
|
+
]])]).controls
|
|
214
|
+
XCTAssertEqual(controls.first?.items.first?.content.backgroundColor, "rgb(0, 122, 255)")
|
|
215
|
+
let model = NativeUIVerticalBarsModel()
|
|
216
|
+
model.apply(controls, rendering: NativeUIControlRendering())
|
|
217
|
+
XCTAssertNotNil(model.groups.first?.items.first?.background)
|
|
218
|
+
XCTAssertEqual(model.groups.first?.items.map(\.clear), [false, true, false])
|
|
219
|
+
XCTAssertNotNil(model.groups.first?.items.last?.borderColor)
|
|
220
|
+
XCTAssertEqual(model.groups.first?.items.last?.borderWidth, 2)
|
|
221
|
+
model.apply(try decode([control(["kind": "ion-button"])]).controls, rendering: NativeUIControlRendering())
|
|
222
|
+
XCTAssertNil(model.groups.first?.items.first?.background)
|
|
223
|
+
XCTAssertEqual(model.groups.first?.items.first?.clear, false)
|
|
224
|
+
XCTAssertNil(model.groups.first?.items.first?.borderColor)
|
|
225
|
+
XCTAssertEqual(model.groups.first?.items.first?.borderWidth, 0)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
@MainActor func testVerticalBarsTabOptimismWaitsForWebAndRollsBackWhenStale() throws {
|
|
229
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive tabs") }
|
|
230
|
+
func tabs(_ selected: String, includeRight: Bool = true) throws -> [NativeUIControl] {
|
|
231
|
+
var items = [item(["id": "left", "selected": selected == "left"])]
|
|
232
|
+
if includeRight {
|
|
233
|
+
items.append(item(["id": "right", "selected": selected == "right",
|
|
234
|
+
"badge": ["value": "3", "color": "rgb(255, 0, 0)", "textColor": "rgb(255, 255, 255)"]]))
|
|
235
|
+
}
|
|
236
|
+
return try decode([control(["kind": "ion-tab-bar", "items": items])]).controls
|
|
237
|
+
}
|
|
238
|
+
let model = NativeUIVerticalBarsModel()
|
|
239
|
+
let rendering = NativeUIControlRendering()
|
|
240
|
+
model.apply(try tabs("left"), rendering: rendering, now: 100)
|
|
241
|
+
XCTAssertEqual(model.tabs.last?.badge?.value, "3")
|
|
242
|
+
model.select("right", now: 100, ttl: 10)
|
|
243
|
+
model.apply(try tabs("left"), rendering: rendering, now: 101)
|
|
244
|
+
XCTAssertEqual(model.selection, "right", "a stale Web echo must not undo the optimistic selection")
|
|
245
|
+
model.apply(try tabs("right"), rendering: rendering, now: 102)
|
|
246
|
+
XCTAssertEqual(model.selection, "right", "the matching Web echo confirms the selection")
|
|
247
|
+
|
|
248
|
+
model.apply(try tabs("left"), rendering: rendering, now: 200)
|
|
249
|
+
model.select("right", now: 200, ttl: 10)
|
|
250
|
+
model.apply(try tabs("left"), rendering: rendering, now: 211)
|
|
251
|
+
XCTAssertEqual(model.selection, "left", "an expired selection rolls back to Web state")
|
|
252
|
+
model.select("right", now: 220, ttl: 10)
|
|
253
|
+
model.apply(try tabs("left", includeRight: false), rendering: rendering, now: 221)
|
|
254
|
+
XCTAssertEqual(model.selection, "left", "a removed target rolls back immediately")
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
@MainActor func testSegmentSelectionEchoPreservesNativeViewsAndActionsUseUpdatedItems() throws {
|
|
258
|
+
guard #available(iOS 26.0, *) else { return }
|
|
259
|
+
func node(_ items: [JSObject]) throws -> NativeUIControl {
|
|
260
|
+
try XCTUnwrap(decode([control(["kind": "ion-segment", "width": 160.0, "items": items])]).controls.first)
|
|
261
|
+
}
|
|
262
|
+
let first = try node([item(["id": "left", "selected": true]), item(["id": "right"])])
|
|
263
|
+
var activated: [String] = []
|
|
264
|
+
let segment = try XCTUnwrap(NativeUISegment.make(first, scale: 1, rendering: NativeUIControlRendering(),
|
|
265
|
+
activate: { activated.append($0) }) as? NativeUISegment)
|
|
266
|
+
segment.frame = CGRect(x: 0, y: 0, width: 160, height: 48)
|
|
267
|
+
segment.layoutIfNeeded()
|
|
268
|
+
XCTAssertEqual(segment.widthForSegment(at: 0) + segment.widthForSegment(at: 1), 160)
|
|
269
|
+
segment.selectedSegmentIndex = 1
|
|
270
|
+
segment.layoutIfNeeded()
|
|
271
|
+
let children = segment.subviews
|
|
272
|
+
let animation = CABasicAnimation(keyPath: "opacity")
|
|
273
|
+
animation.fromValue = 0.5
|
|
274
|
+
animation.toValue = 1
|
|
275
|
+
animation.duration = 1
|
|
276
|
+
segment.layer.add(animation, forKey: "selection-in-flight")
|
|
277
|
+
let echo = try node([item(["id": "left"]), item(["id": "right", "selected": true])])
|
|
278
|
+
segment.update(echo, scale: 1, rendering: NativeUIControlRendering())
|
|
279
|
+
segment.layoutIfNeeded()
|
|
280
|
+
XCTAssertEqual(segment.selectedSegmentIndex, 1)
|
|
281
|
+
XCTAssertEqual(segment.subviews, children)
|
|
282
|
+
XCTAssertNotNil(segment.layer.animation(forKey: "selection-in-flight"))
|
|
283
|
+
segment.sendActions(for: .valueChanged)
|
|
284
|
+
XCTAssertEqual(activated, ["right"])
|
|
285
|
+
|
|
286
|
+
// Web can reject a selection, clear it, or replace the available actions.
|
|
287
|
+
segment.update(first, scale: 1, rendering: NativeUIControlRendering())
|
|
288
|
+
XCTAssertEqual(segment.selectedSegmentIndex, 0)
|
|
289
|
+
let replaced = try node([item(["id": "replacement", "label": "Updated", "disabled": true])])
|
|
290
|
+
segment.update(replaced, scale: 2, rendering: NativeUIControlRendering())
|
|
291
|
+
XCTAssertEqual(segment.numberOfSegments, 1)
|
|
292
|
+
XCTAssertEqual(segment.selectedSegmentIndex, UISegmentedControl.noSegment)
|
|
293
|
+
XCTAssertEqual(segment.titleForSegment(at: 0), "Updated")
|
|
294
|
+
XCTAssertFalse(segment.isEnabledForSegment(at: 0))
|
|
295
|
+
XCTAssertEqual(segment.widthForSegment(at: 0), 320)
|
|
296
|
+
segment.selectedSegmentIndex = 0
|
|
297
|
+
segment.sendActions(for: .valueChanged)
|
|
298
|
+
XCTAssertEqual(activated, ["right", "replacement"])
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
func testFlatWireFormatAndOptionalFields() throws {
|
|
302
|
+
let snapshot = try decode([control()])
|
|
303
|
+
XCTAssertTrue(snapshot.isValid)
|
|
304
|
+
let node = try XCTUnwrap(snapshot.controls.first)
|
|
305
|
+
XCTAssertEqual(node.kind, .button)
|
|
306
|
+
XCTAssertEqual(node.frame.rect, CGRect(x: 16, y: 60, width: 44, height: 44))
|
|
307
|
+
let action = try XCTUnwrap(node.items.first)
|
|
308
|
+
XCTAssertEqual(action.content.accessibilityLabel, "Send message")
|
|
309
|
+
XCTAssertNil(action.content.icon)
|
|
310
|
+
XCTAssertNil(action.visible)
|
|
311
|
+
XCTAssertNil(node.search)
|
|
312
|
+
XCTAssertTrue(try decode([]).isValid)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
func testRequiredFieldsAndEnumsRejectMalformedInput() throws {
|
|
316
|
+
var missing = item()
|
|
317
|
+
missing.removeValue(forKey: "label")
|
|
318
|
+
for malformed in [missing, item(["disabled": "false"]), item(["iconPosition": "bottom"])] {
|
|
319
|
+
XCTAssertThrowsError(try decode([control(["items": [malformed]])]))
|
|
320
|
+
}
|
|
321
|
+
XCTAssertThrowsError(try decode([control(["kind": "ion-input"])]))
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
func testGeometryRejectsNonFiniteAndNonPositiveSizes() throws {
|
|
325
|
+
for width in [0, -1, Double.infinity, Double.nan] {
|
|
326
|
+
XCTAssertFalse(try decode([control(["width": width])]).isValid)
|
|
327
|
+
XCTAssertFalse(try decode([control(["items": [item(["width": width])]])]).isValid)
|
|
328
|
+
XCTAssertFalse(try decode([], width: width).isValid)
|
|
329
|
+
}
|
|
330
|
+
XCTAssertFalse(try decode([control(["x": Double.infinity])]).isValid)
|
|
331
|
+
XCTAssertTrue(try decode([control(["x": -20.0, "y": -10.0])]).isValid)
|
|
332
|
+
XCTAssertFalse(try decode([control(["items": [item(["iconWidth": Double.nan])]])]).isValid)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
func testBatchLimitsAndDuplicateIdentifiers() throws {
|
|
336
|
+
let controls = (0..<100).map { control(["id": "control-\($0)"]) }
|
|
337
|
+
XCTAssertTrue(try decode(controls).isValid)
|
|
338
|
+
XCTAssertFalse(try decode(controls + [control()]).isValid)
|
|
339
|
+
XCTAssertFalse(try decode([control(), control()]).isValid)
|
|
340
|
+
let items = (0..<30).map { item(["id": "item-\($0)"]) }
|
|
341
|
+
XCTAssertTrue(try decode([control(["kind": "ion-segment", "items": items])]).isValid)
|
|
342
|
+
XCTAssertFalse(try decode([control(["kind": "ion-segment", "items": items + [item()]])]).isValid)
|
|
343
|
+
XCTAssertFalse(try decode([control(["kind": "ion-segment", "items": [item(), item()]])]).isValid)
|
|
344
|
+
XCTAssertFalse(try decode([control(), control(["id": "invalid", "height": 0.0])]).isValid)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
func testSearchUsesContentForFieldAndGeometryForTrigger() throws {
|
|
348
|
+
// The hidden Web search field may have no layout. Its content is still projected.
|
|
349
|
+
var search: JSObject = ["id": "search", "field": item(["width": 0.0, "height": 0.0]),
|
|
350
|
+
"trigger": item(), "closeId": "close", "active": false, "available": true,
|
|
351
|
+
"focused": false, "value": "あ", "placeholder": "Search", "disabled": false,
|
|
352
|
+
"editSequence": 2, "valueVersion": 1]
|
|
353
|
+
let snapshot = try decode([control(["kind": "ion-tab-bar", "search": search])])
|
|
354
|
+
XCTAssertTrue(snapshot.isValid)
|
|
355
|
+
XCTAssertEqual(snapshot.controls.first?.search?.value, "あ")
|
|
356
|
+
XCTAssertFalse(try decode([control(["search": search])]).isValid)
|
|
357
|
+
search["trigger"] = item(["width": 0.0])
|
|
358
|
+
XCTAssertFalse(try decode([control(["kind": "ion-tab-bar", "search": search])]).isValid)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
func testTabAnchorsKeepOnlySupportedPlacements() throws {
|
|
362
|
+
for x in [0.0, 0.5, 1.0] {
|
|
363
|
+
for y in [0.0, 1.0] {
|
|
364
|
+
XCTAssertTrue(try decode([control(["kind": "ion-tab-bar", "tabBarAnchor": ["x": x, "y": y]])]).isValid)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
XCTAssertFalse(try decode([control(["kind": "ion-tab-bar", "tabBarAnchor": ["x": 0.2, "y": 0.0]])]).isValid)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
func testFabMovementAndVisibilityDoNotInvalidateArtwork() throws {
|
|
371
|
+
let original = try JSValueDecoder().decode(NativeUIItem.self, from: item(["visible": true]))
|
|
372
|
+
let moved = try JSValueDecoder().decode(NativeUIItem.self, from: item(["x": 20.0, "visible": false]))
|
|
373
|
+
XCTAssertNotEqual(original, moved)
|
|
374
|
+
XCTAssertEqual(original.content.fabArtwork, moved.content.fabArtwork)
|
|
375
|
+
let opened = try JSValueDecoder().decode(NativeUIItem.self, from: item([
|
|
376
|
+
"selected": true, "icon": "normal", "closeIcon": "close", "closeIconWidth": 20.0, "closeIconHeight": 20.0]))
|
|
377
|
+
XCTAssertEqual(opened.content.fabArtwork.icon, "close")
|
|
378
|
+
XCTAssertEqual(opened.content.fabArtwork.iconWidth, 20)
|
|
379
|
+
XCTAssertEqual(opened.content.fabArtwork.label, "")
|
|
380
|
+
XCTAssertNotEqual(original.content.fabArtwork, opened.content.fabArtwork)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
@MainActor
|
|
384
|
+
func testTabVariantsAndBadgeUpdatesKeepItemIdentity() throws {
|
|
385
|
+
let rendering = NativeUIControlRendering()
|
|
386
|
+
let image = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { context in
|
|
387
|
+
UIColor.black.setFill()
|
|
388
|
+
context.fill(CGRect(x: 0, y: 0, width: 24, height: 24))
|
|
389
|
+
}.pngData()!.base64EncodedString()
|
|
390
|
+
let dot: JSObject = ["value": "", "color": "rgb(235, 68, 90)", "textColor": "rgb(255, 255, 255)"]
|
|
391
|
+
let number: JSObject = ["value": "47", "color": "rgb(0, 102, 255)", "textColor": "rgb(255, 255, 255)"]
|
|
392
|
+
func node(_ badges: Bool) throws -> NativeUIControl {
|
|
393
|
+
let items = [
|
|
394
|
+
item(["id": "icon", "label": "", "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
|
|
395
|
+
item(["id": "label", "label": "Music"]),
|
|
396
|
+
item(["id": "dot", "badge": badges ? dot : NSNull()]),
|
|
397
|
+
item(["id": "number", "badge": badges ? number : NSNull()])
|
|
398
|
+
]
|
|
399
|
+
return try decode([control(["kind": "ion-tab-bar", "items": items])]).controls[0]
|
|
400
|
+
}
|
|
401
|
+
let bar = UITabBar()
|
|
402
|
+
NativeUITabBar.update(bar, node: try node(true), rendering: rendering)
|
|
403
|
+
let items = try XCTUnwrap(bar.items)
|
|
404
|
+
XCTAssertEqual(items[0].title, "")
|
|
405
|
+
XCTAssertNotNil(items[0].image)
|
|
406
|
+
XCTAssertEqual(items[1].title, "Music")
|
|
407
|
+
XCTAssertNil(items[1].image)
|
|
408
|
+
XCTAssertEqual(items[2].badgeValue, "")
|
|
409
|
+
XCTAssertEqual(items[3].badgeValue, "47")
|
|
410
|
+
XCTAssertEqual(items[2].badgeColor, rendering.color("rgb(235, 68, 90)"))
|
|
411
|
+
XCTAssertEqual(items[3].badgeColor, rendering.color("rgb(0, 102, 255)"))
|
|
412
|
+
NativeUITabBar.update(bar, node: try node(false), rendering: rendering)
|
|
413
|
+
for (before, after) in zip(items, bar.items!) {
|
|
414
|
+
XCTAssertTrue(before === after)
|
|
415
|
+
XCTAssertNil(after.badgeValue)
|
|
416
|
+
XCTAssertNil(after.badgeColor)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@MainActor
|
|
422
|
+
func testSearchTabsPreserveAccessibilityAndBadgeAppearance() throws {
|
|
423
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
424
|
+
let badge: JSObject = ["value": "47", "color": "rgb(235, 68, 90)", "textColor": "rgb(255, 255, 255)"]
|
|
425
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
426
|
+
"closeId": "close", "active": false, "available": true, "focused": false,
|
|
427
|
+
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
428
|
+
let node = try decode([control(["kind": "ion-tab-bar", "width": 300.0, "search": search,
|
|
429
|
+
"items": [item(["id": "first", "label": "", "accessibilityLabel": "Favorites", "badge": badge]),
|
|
430
|
+
item(["id": "second", "label": "Music"]) ]])]).controls[0]
|
|
431
|
+
let controller = NativeUISearchController()
|
|
432
|
+
let rendering = NativeUIControlRendering()
|
|
433
|
+
_ = controller.apply(node, webFrame: CGRect(x: 0, y: 0, width: 390, height: 844),
|
|
434
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
435
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
|
|
436
|
+
let tab = try XCTUnwrap(controller.tabBar.items?.first)
|
|
437
|
+
XCTAssertEqual(tab.accessibilityLabel, "Favorites")
|
|
438
|
+
XCTAssertEqual(tab.badgeValue, "47")
|
|
439
|
+
XCTAssertEqual(tab.accessibilityValue, "47")
|
|
440
|
+
XCTAssertEqual(tab.badgeColor, rendering.color("rgb(235, 68, 90)"))
|
|
441
|
+
XCTAssertEqual(tab.badgeTextAttributes(for: .normal)?[.foregroundColor] as? UIColor, rendering.color("rgb(255, 255, 255)"))
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@MainActor
|
|
446
|
+
func testReplacingSearchResetsEditingSequenceWithoutOverwritingCurrentInput() throws {
|
|
447
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
448
|
+
let controller = NativeUISearchController()
|
|
449
|
+
let rendering = NativeUIControlRendering()
|
|
450
|
+
func apply(_ id: String, value: String) throws {
|
|
451
|
+
let search: JSObject = ["id": id, "field": item(), "trigger": item(["id": "trigger"]),
|
|
452
|
+
"closeId": "close", "active": true, "available": true, "focused": false,
|
|
453
|
+
"value": value, "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
454
|
+
let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
455
|
+
"items": [item(["id": "first", "selected": true]), item(["id": "second"]) ]])]).controls.first)
|
|
456
|
+
XCTAssertTrue(controller.apply(node, webFrame: CGRect(x: 0, y: 0, width: 390, height: 844),
|
|
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
|
+
}
|
|
460
|
+
try apply("old-search", value: "initial")
|
|
461
|
+
let navigation = try XCTUnwrap(controller.tabs.last?.viewController as? UINavigationController)
|
|
462
|
+
let searchBar = try XCTUnwrap(navigation.topViewController?.navigationItem.searchController?.searchBar)
|
|
463
|
+
searchBar.text = "native edit"
|
|
464
|
+
controller.changed = { _, _, _, _, _ in 7 }
|
|
465
|
+
controller.searchBar(searchBar, textDidChange: "native edit")
|
|
466
|
+
try apply("old-search", value: "stale")
|
|
467
|
+
XCTAssertEqual(searchBar.text, "native edit")
|
|
468
|
+
try apply("new-search", value: "replacement initial")
|
|
469
|
+
XCTAssertEqual(searchBar.text, "replacement initial")
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
@MainActor
|
|
473
|
+
func testSingleButtonsRejectEmptyAndMultipleItems() throws {
|
|
474
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native glass buttons") }
|
|
475
|
+
for kind in ["ion-button", "ion-back-button", "ion-menu-button"] {
|
|
476
|
+
for items in [[JSObject](), [item(), item(["id": "second"])]] {
|
|
477
|
+
let snapshot = try decode([control(["kind": kind, "items": items])])
|
|
478
|
+
XCTAssertFalse(snapshot.isValid)
|
|
479
|
+
let node = try XCTUnwrap(snapshot.controls.first)
|
|
480
|
+
XCTAssertNil(NativeUIButton.make(node, rendering: NativeUIControlRendering(), activate: { _ in
|
|
481
|
+
XCTFail("Invalid input must not create an action")
|
|
482
|
+
}))
|
|
483
|
+
}
|
|
484
|
+
let snapshot = try decode([control(["kind": kind])])
|
|
485
|
+
XCTAssertTrue(snapshot.isValid)
|
|
486
|
+
let node = try XCTUnwrap(snapshot.controls.first)
|
|
487
|
+
var activated: String?
|
|
488
|
+
let button = try XCTUnwrap(NativeUIButton.make(node, rendering: NativeUIControlRendering(), activate: { activated = $0 }))
|
|
489
|
+
button.sendActions(for: .touchUpInside)
|
|
490
|
+
XCTAssertEqual(activated, "action")
|
|
491
|
+
}
|
|
492
|
+
let emptySegment = try decode([control(["kind": "ion-segment", "items": [JSObject]()])])
|
|
493
|
+
XCTAssertFalse(emptySegment.isValid)
|
|
494
|
+
XCTAssertNil(NativeUISegment.make(try XCTUnwrap(emptySegment.controls.first), scale: 1,
|
|
495
|
+
rendering: NativeUIControlRendering(), activate: { _ in XCTFail("Empty segment") }))
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@MainActor
|
|
500
|
+
func testTabSelectionWithEmptySingleAndReorderedItems() throws {
|
|
501
|
+
let first = item(["id": "first"])
|
|
502
|
+
let selected = item(["id": "selected", "selected": true])
|
|
503
|
+
let bar = UITabBar()
|
|
504
|
+
for items in [[first, selected], [selected, first], [first], []] {
|
|
505
|
+
let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": items])]).controls.first)
|
|
506
|
+
NativeUITabBar.update(bar, node: node, rendering: NativeUIControlRendering())
|
|
507
|
+
XCTAssertEqual(bar.items?.count ?? 0, items.count)
|
|
508
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, items.count == 2 ? "selected" : nil)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
@MainActor
|
|
513
|
+
func testTabTitleLayoutWarmupRestoresSelection() throws {
|
|
514
|
+
let image = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { context in
|
|
515
|
+
UIColor.black.setFill()
|
|
516
|
+
context.fill(CGRect(x: 0, y: 0, width: 24, height: 24))
|
|
517
|
+
}.pngData()!.base64EncodedString()
|
|
518
|
+
let items = [
|
|
519
|
+
item(["id": "first", "label": "Index", "selected": true, "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
|
|
520
|
+
item(["id": "second", "label": "Docs", "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
|
|
521
|
+
item(["id": "third", "label": "Library", "icon": image, "iconWidth": 24.0, "iconHeight": 24.0]),
|
|
522
|
+
]
|
|
523
|
+
let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "width": 280.0, "height": 62.0, "items": items])]).controls.first)
|
|
524
|
+
let bar = UITabBar()
|
|
525
|
+
NativeUITabBar.update(bar, node: node, rendering: NativeUIControlRendering())
|
|
526
|
+
XCTAssertTrue(NativeUITabBar.fit(bar, node: node, bounds: CGRect(x: 20, y: 700, width: 280, height: 62)))
|
|
527
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
528
|
+
// A second fit without content changes must not leave a pending warmup.
|
|
529
|
+
XCTAssertTrue(NativeUITabBar.fit(bar, node: node, bounds: CGRect(x: 20, y: 700, width: 280, height: 62)))
|
|
530
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
@MainActor
|
|
534
|
+
func testTabPendingSelectionIgnoresStaleDomEcho() throws {
|
|
535
|
+
let first = item(["id": "first", "selected": true])
|
|
536
|
+
let second = item(["id": "second"])
|
|
537
|
+
let bar = UITabBar()
|
|
538
|
+
let initial = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [first, second]])]).controls.first)
|
|
539
|
+
NativeUITabBar.update(bar, node: initial, rendering: NativeUIControlRendering())
|
|
540
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
541
|
+
|
|
542
|
+
var pending: NativeUITabBar.PendingSelection? = .start("second")
|
|
543
|
+
// Stale Web echo still reports the previous tab.
|
|
544
|
+
NativeUITabBar.update(bar, node: initial, rendering: NativeUIControlRendering(), pendingSelection: &pending)
|
|
545
|
+
XCTAssertEqual(pending?.id, "second")
|
|
546
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "second")
|
|
547
|
+
|
|
548
|
+
let echoed = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [
|
|
549
|
+
item(["id": "first"]), item(["id": "second", "selected": true]),
|
|
550
|
+
]])]).controls.first)
|
|
551
|
+
NativeUITabBar.update(bar, node: echoed, rendering: NativeUIControlRendering(), pendingSelection: &pending)
|
|
552
|
+
XCTAssertNil(pending)
|
|
553
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "second")
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
@MainActor
|
|
557
|
+
func testTabPendingSelectionFallsBackWhenDomRejects() throws {
|
|
558
|
+
let first = item(["id": "first", "selected": true])
|
|
559
|
+
let second = item(["id": "second"])
|
|
560
|
+
let bar = UITabBar()
|
|
561
|
+
let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [first, second]])]).controls.first)
|
|
562
|
+
NativeUITabBar.update(bar, node: node, rendering: NativeUIControlRendering())
|
|
563
|
+
bar.selectedItem = bar.items?.first { $0.accessibilityIdentifier == "second" }
|
|
564
|
+
|
|
565
|
+
var pending: NativeUITabBar.PendingSelection? = NativeUITabBar.PendingSelection(id: "second", until: CFAbsoluteTimeGetCurrent() - 1)
|
|
566
|
+
NativeUITabBar.update(bar, node: node, rendering: NativeUIControlRendering(), pendingSelection: &pending)
|
|
567
|
+
XCTAssertNil(pending)
|
|
568
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
569
|
+
|
|
570
|
+
pending = .start("second")
|
|
571
|
+
let disabled = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "items": [
|
|
572
|
+
item(["id": "first", "selected": true]), item(["id": "second", "disabled": true]),
|
|
573
|
+
]])]).controls.first)
|
|
574
|
+
bar.selectedItem = bar.items?.first { $0.accessibilityIdentifier == "second" }
|
|
575
|
+
NativeUITabBar.update(bar, node: disabled, rendering: NativeUIControlRendering(), pendingSelection: &pending)
|
|
576
|
+
XCTAssertNil(pending)
|
|
577
|
+
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, "first")
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
@MainActor
|
|
581
|
+
func testSearchOptimisticTabSelectionIgnoresStaleDomEcho() throws {
|
|
582
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
583
|
+
let controller = NativeUISearchController()
|
|
584
|
+
let rendering = NativeUIControlRendering()
|
|
585
|
+
let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
586
|
+
let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
|
|
587
|
+
let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
|
|
588
|
+
func node(selected: String) throws -> NativeUIControl {
|
|
589
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
590
|
+
"closeId": "close", "active": false, "available": true, "focused": false,
|
|
591
|
+
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
592
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
593
|
+
"items": [item(["id": "first", "selected": selected == "first"]),
|
|
594
|
+
item(["id": "second", "selected": selected == "second"])]])]).controls.first)
|
|
595
|
+
}
|
|
596
|
+
_ = controller.apply(try node(selected: "first"), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
597
|
+
let second = try XCTUnwrap(controller.tabs.first { $0.identifier == "second" })
|
|
598
|
+
var activated: [String] = []
|
|
599
|
+
controller.activate = { activated.append($0) }
|
|
600
|
+
XCTAssertTrue(controller.tabBarController(controller, shouldSelectTab: second))
|
|
601
|
+
XCTAssertEqual(activated, ["second"])
|
|
602
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
603
|
+
// Stale Web echo still reports the previous tab.
|
|
604
|
+
_ = controller.apply(try node(selected: "first"), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
605
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
606
|
+
// Web catches up — clear pending without bouncing selection.
|
|
607
|
+
_ = controller.apply(try node(selected: "second"), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
608
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
@MainActor
|
|
612
|
+
func testSearchLocksProjectionWhileActiveAndKeepsApplicationValue() throws {
|
|
613
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
614
|
+
let controller = NativeUISearchController()
|
|
615
|
+
let rendering = NativeUIControlRendering()
|
|
616
|
+
let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
617
|
+
let shrunk = CGRect(x: 0, y: 0, width: 390, height: 500)
|
|
618
|
+
func node(active: Bool, focused: Bool, value: String = "", valueVersion: Int = 0) throws -> NativeUIControl {
|
|
619
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
620
|
+
"closeId": "close", "active": active, "available": true, "focused": focused,
|
|
621
|
+
"value": value, "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": valueVersion]
|
|
622
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
623
|
+
"items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
|
|
624
|
+
}
|
|
625
|
+
// Resting layout may reject without a full window hierarchy; surface is still assigned.
|
|
626
|
+
_ = controller.apply(try node(active: false, focused: false), webFrame: resting,
|
|
627
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
628
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
|
|
629
|
+
controller.surface.frame = resting
|
|
630
|
+
// Active (even before focus) freezes Web layout for the whole search session.
|
|
631
|
+
XCTAssertTrue(controller.apply(try node(active: true, focused: false), webFrame: resting,
|
|
632
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
633
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
634
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
635
|
+
XCTAssertTrue(controller.apply(try node(active: true, focused: false), webFrame: shrunk,
|
|
636
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
637
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
638
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
639
|
+
// Application realtime value updates still cross the lock.
|
|
640
|
+
XCTAssertTrue(controller.apply(try node(active: true, focused: true, value: "external", valueVersion: 2), webFrame: shrunk,
|
|
641
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
642
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
643
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
644
|
+
let navigation = try XCTUnwrap(controller.tabs.last?.viewController as? UINavigationController)
|
|
645
|
+
let searchBar = try XCTUnwrap(navigation.topViewController?.navigationItem.searchController?.searchBar)
|
|
646
|
+
XCTAssertEqual(searchBar.text, "external")
|
|
647
|
+
// Blur request returns to presented (active, not focused) without unlocking projection.
|
|
648
|
+
XCTAssertTrue(controller.apply(try node(active: true, focused: false, value: "external", valueVersion: 2), webFrame: shrunk,
|
|
649
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
650
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering))
|
|
651
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
652
|
+
XCTAssertFalse(searchBar.searchTextField.isFirstResponder)
|
|
653
|
+
let tabsBeforeLeave = controller.tabs.map(\.identifier)
|
|
654
|
+
// Leave re-fits; without a window hierarchy fit may reject while still restoring selection.
|
|
655
|
+
_ = controller.apply(try node(active: false, focused: false, value: "external", valueVersion: 2), webFrame: resting,
|
|
656
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
657
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
|
|
658
|
+
XCTAssertEqual(controller.surface.frame, resting)
|
|
659
|
+
// Leave must reassign tabs even when identifiers match (UISearchTab morph cleanup).
|
|
660
|
+
XCTAssertEqual(controller.tabs.map(\.identifier), tabsBeforeLeave)
|
|
661
|
+
XCTAssertEqual(controller.tabs.count, 3) // first, second, search
|
|
662
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "first")
|
|
663
|
+
// Switching ordinary tabs after leave must keep resting chrome (no deferred morph expand).
|
|
664
|
+
func nodeSelecting(_ id: String) throws -> NativeUIControl {
|
|
665
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
666
|
+
"closeId": "close", "active": false, "available": true, "focused": false,
|
|
667
|
+
"value": "external", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 2]
|
|
668
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
669
|
+
"items": [item(["id": "first", "selected": id == "first"]),
|
|
670
|
+
item(["id": "second", "selected": id == "second"])]])]).controls.first)
|
|
671
|
+
}
|
|
672
|
+
let tabsAfterLeave = controller.tabs.map(\.identifier)
|
|
673
|
+
_ = controller.apply(try nodeSelecting("second"), webFrame: resting,
|
|
674
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
675
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
|
|
676
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
677
|
+
XCTAssertEqual(controller.tabs.map(\.identifier), tabsAfterLeave)
|
|
678
|
+
_ = controller.apply(try nodeSelecting("first"), webFrame: resting,
|
|
679
|
+
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
|
|
680
|
+
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
|
|
681
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "first")
|
|
682
|
+
XCTAssertEqual(controller.tabs.map(\.identifier), tabsAfterLeave)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
@MainActor
|
|
686
|
+
func testSearchRelocksProjectionWidthAfterRotation() throws {
|
|
687
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
688
|
+
let controller = NativeUISearchController()
|
|
689
|
+
let rendering = NativeUIControlRendering()
|
|
690
|
+
let portrait = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
691
|
+
let landscape = CGRect(x: 0, y: 0, width: 844, height: 390)
|
|
692
|
+
let keyboardShrunk = CGRect(x: 0, y: 0, width: 844, height: 200)
|
|
693
|
+
func node(active: Bool) throws -> NativeUIControl {
|
|
694
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
695
|
+
"closeId": "close", "active": active, "available": true, "focused": false,
|
|
696
|
+
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
697
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
698
|
+
"items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
|
|
699
|
+
}
|
|
700
|
+
let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
|
|
701
|
+
let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
|
|
702
|
+
_ = controller.apply(try node(active: false), webFrame: portrait, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
703
|
+
controller.surface.frame = portrait
|
|
704
|
+
XCTAssertTrue(controller.apply(try node(active: true), webFrame: portrait, barFrame: bar, triggerFrame: trigger, rendering: rendering))
|
|
705
|
+
XCTAssertEqual(controller.surface.frame, portrait)
|
|
706
|
+
// Rotation must adopt the new width instead of keeping the portrait lock.
|
|
707
|
+
XCTAssertTrue(controller.apply(try node(active: true), webFrame: landscape, barFrame: bar, triggerFrame: trigger, rendering: rendering))
|
|
708
|
+
XCTAssertEqual(controller.surface.frame, landscape)
|
|
709
|
+
// Keyboard shrink at the same width must still be ignored.
|
|
710
|
+
XCTAssertTrue(controller.apply(try node(active: true), webFrame: keyboardShrunk, barFrame: bar, triggerFrame: trigger, rendering: rendering))
|
|
711
|
+
XCTAssertEqual(controller.surface.frame, landscape)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
@MainActor
|
|
715
|
+
func testSearchPendingSelectionExpiresWithoutLaterApply() throws {
|
|
716
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
717
|
+
let controller = NativeUISearchController()
|
|
718
|
+
let rendering = NativeUIControlRendering()
|
|
719
|
+
let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
720
|
+
let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
|
|
721
|
+
let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
|
|
722
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
723
|
+
"closeId": "close", "active": false, "available": true, "focused": false,
|
|
724
|
+
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
725
|
+
let node = try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
726
|
+
"items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
|
|
727
|
+
_ = controller.apply(node, webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
728
|
+
let second = try XCTUnwrap(controller.tabs.first { $0.identifier == "second" })
|
|
729
|
+
XCTAssertTrue(controller.tabBarController(controller, shouldSelectTab: second))
|
|
730
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "second")
|
|
731
|
+
let expectation = expectation(description: "pending selection expires back to DOM")
|
|
732
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.15) {
|
|
733
|
+
XCTAssertEqual(controller.selectedTab?.identifier, "first")
|
|
734
|
+
expectation.fulfill()
|
|
735
|
+
}
|
|
736
|
+
wait(for: [expectation], timeout: 2)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
@MainActor
|
|
740
|
+
func testSearchEmitsInputAfterRestingToActive() throws {
|
|
741
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
|
|
742
|
+
let controller = NativeUISearchController()
|
|
743
|
+
let rendering = NativeUIControlRendering()
|
|
744
|
+
let resting = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
745
|
+
let bar = CGRect(x: 18, y: 730, width: 280, height: 62)
|
|
746
|
+
let trigger = CGRect(x: 320, y: 730, width: 56, height: 56)
|
|
747
|
+
func node(active: Bool) throws -> NativeUIControl {
|
|
748
|
+
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
|
|
749
|
+
"closeId": "close", "active": active, "available": true, "focused": active,
|
|
750
|
+
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
|
|
751
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "search": search,
|
|
752
|
+
"items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
|
|
753
|
+
}
|
|
754
|
+
// Resting must not permanently close the editing bridge.
|
|
755
|
+
_ = controller.apply(try node(active: false), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering)
|
|
756
|
+
var phases: [String] = []
|
|
757
|
+
controller.changed = { _, phase, _, _, _ in
|
|
758
|
+
phases.append(phase.rawValue)
|
|
759
|
+
return phases.count
|
|
760
|
+
}
|
|
761
|
+
XCTAssertTrue(controller.apply(try node(active: true), webFrame: resting, barFrame: bar, triggerFrame: trigger, rendering: rendering))
|
|
762
|
+
let navigation = try XCTUnwrap(controller.tabs.last?.viewController as? UINavigationController)
|
|
763
|
+
let searchBar = try XCTUnwrap(navigation.topViewController?.navigationItem.searchController?.searchBar)
|
|
764
|
+
controller.searchBar(searchBar, textDidChange: "query")
|
|
765
|
+
XCTAssertEqual(phases, ["input"])
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
func testVerticalSearchBridgesInputFocusAndDismissalWithoutStaleWebEchoes() throws {
|
|
769
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native glass search") }
|
|
770
|
+
func node(active: Bool, value: String = "", version: Int = 0) throws -> NativeUIControl {
|
|
771
|
+
let search: JSObject = ["id": "search", "field": item(),
|
|
772
|
+
"trigger": item(["id": "trigger"]), "closeId": "close", "active": active,
|
|
773
|
+
"available": true, "focused": false, "value": value, "placeholder": "Search",
|
|
774
|
+
"disabled": false, "editSequence": 0, "valueVersion": version]
|
|
775
|
+
return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "placement": "vertical-bars",
|
|
776
|
+
"search": search, "items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
|
|
777
|
+
}
|
|
778
|
+
var actions: [String] = []
|
|
779
|
+
var values: [String] = []
|
|
780
|
+
let rail = UIView()
|
|
781
|
+
let model = NativeUIVerticalSearchModel(try XCTUnwrap(node(active: false).search), in: rail,
|
|
782
|
+
activate: { actions.append($0) },
|
|
783
|
+
changed: { _, _, value, _, _ in values.append(value); return values.count })
|
|
784
|
+
model.present(true)
|
|
785
|
+
XCTAssertEqual(actions, ["trigger"])
|
|
786
|
+
model.apply(try XCTUnwrap(node(active: true).search))
|
|
787
|
+
XCTAssertTrue(model.presented)
|
|
788
|
+
model.input("native query")
|
|
789
|
+
XCTAssertEqual(values, ["native query"])
|
|
790
|
+
model.apply(try XCTUnwrap(node(active: true).search))
|
|
791
|
+
XCTAssertEqual(model.text, "native query")
|
|
792
|
+
// The first presentation acknowledgement must not undo native focus.
|
|
793
|
+
model.focus(true)
|
|
794
|
+
model.apply(try XCTUnwrap(node(active: true).search))
|
|
795
|
+
XCTAssertTrue(model.focused)
|
|
796
|
+
model.apply(try XCTUnwrap(node(active: true, value: "application", version: 1).search))
|
|
797
|
+
XCTAssertEqual(model.text, "application")
|
|
798
|
+
model.present(false)
|
|
799
|
+
XCTAssertEqual(actions, ["trigger", "close"])
|
|
800
|
+
model.apply(try XCTUnwrap(node(active: false).search))
|
|
801
|
+
XCTAssertFalse(model.presented)
|
|
802
|
+
XCTAssertFalse(model.focused)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
}
|