@rdlabo/ionic-theme-ios27 0.1.0-beta.pr153.sha1c3caf4dc12b → 0.1.0
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 +3 -3
- package/dist/css/components/ion-action-sheet.css +1 -1
- package/dist/css/components/ion-alert.css +1 -1
- package/dist/css/components/ion-button.css +1 -1
- package/dist/css/components/ion-card.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-modal.css +1 -1
- package/dist/css/components/ion-popover.css +1 -1
- package/dist/css/components/ion-range.css +1 -1
- package/dist/css/components/ion-segment.css +1 -1
- package/dist/css/components/ion-tabs.css +1 -1
- package/dist/css/components/ion-toast.css +1 -1
- package/dist/css/components/ion-toolbar.css +1 -1
- package/dist/css/default-variables.css +1 -1
- package/dist/css/ionic-theme-ios27-dark-always.css +1 -1
- package/dist/css/ionic-theme-ios27-dark-class.css +1 -1
- package/dist/css/ionic-theme-ios27-dark-system.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/dark/ion-list.css +1 -0
- package/dist/css/utils/group-heading.css +1 -0
- package/dist/native/components/searchable-tabs.d.ts.map +1 -1
- package/dist/native/components/searchable-tabs.js +9 -1
- package/dist/native/definitions.d.ts +4 -0
- package/dist/native/definitions.d.ts.map +1 -1
- package/dist/native/runtime.d.ts.map +1 -1
- package/dist/native/runtime.js +51 -27
- package/dist/native/shared/candidate.d.ts.map +1 -1
- package/dist/native/shared/candidate.js +17 -0
- package/dist/native/shared/crossfade.d.ts +8 -0
- package/dist/native/shared/crossfade.d.ts.map +1 -0
- package/dist/native/shared/crossfade.js +46 -0
- package/dist/native/shared/dom.d.ts.map +1 -1
- package/dist/native/shared/dom.js +2 -1
- package/dist/popover/animations/ios.enter.d.ts.map +1 -1
- package/dist/popover/animations/ios.enter.js +105 -9
- package/dist/popover/animations/ios.leave.d.ts.map +1 -1
- package/dist/popover/animations/ios.leave.js +15 -1
- package/dist/popover/callout-surface.d.ts +2 -0
- package/dist/popover/callout-surface.d.ts.map +1 -0
- package/dist/popover/callout-surface.js +44 -0
- package/dist/sheets-of-glass/index.js +2 -1
- package/dist/transition/ios.transition.d.ts.map +1 -1
- package/dist/transition/ios.transition.js +0 -223
- package/docs/experimental-animation.md +3 -3
- package/docs/features.md +7 -2
- package/docs/native-ui-shell.md +14 -5
- package/docs/special-markup.md +17 -0
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellButton.swift +7 -2
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +10 -0
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSegment.swift +47 -15
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabBar.swift +42 -7
- package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +13 -6
- package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellCrossfade.swift +35 -0
- package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +6 -1
- package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +59 -0
- package/package.json +1 -1
- package/src/native/components/searchable-tabs.ts +12 -1
- package/src/native/definitions.ts +4 -0
- package/src/native/runtime.ts +54 -31
- package/src/native/shared/candidate.ts +19 -0
- package/src/native/shared/crossfade.ts +49 -0
- package/src/native/shared/dom.ts +4 -1
- package/src/popover/animations/ios.enter.ts +123 -10
- package/src/popover/animations/ios.leave.ts +15 -1
- package/src/popover/callout-surface.ts +54 -0
- package/src/sheets-of-glass/index.ts +2 -2
- package/src/styles/components/ion-action-sheet.scss +35 -17
- package/src/styles/components/ion-alert.scss +51 -31
- package/src/styles/components/ion-button.scss +4 -7
- package/src/styles/components/ion-card.scss +64 -3
- package/src/styles/components/ion-fab.scss +3 -5
- package/src/styles/components/ion-list.scss +14 -1
- package/src/styles/components/ion-menu.scss +19 -7
- package/src/styles/components/ion-modal.scss +63 -14
- package/src/styles/components/ion-popover.scss +52 -9
- package/src/styles/components/ion-range.scss +49 -50
- package/src/styles/components/ion-segment.scss +15 -16
- package/src/styles/components/ion-tabs.scss +73 -13
- package/src/styles/components/ion-toast.scss +16 -25
- package/src/styles/components/ion-toolbar.scss +17 -1
- package/src/styles/default-variables.scss +4 -0
- package/src/styles/utils/_glass.scss +96 -0
- package/src/styles/utils/api.scss +46 -4
- package/src/styles/utils/dark/ion-button.scss +13 -0
- package/src/styles/utils/dark/ion-fab.scss +9 -0
- package/src/styles/utils/dark/ion-list.scss +7 -0
- package/src/styles/utils/dark/ion-segment.scss +8 -3
- package/src/styles/utils/dark/ion-tabs.scss +20 -0
- package/src/styles/utils/group-heading.scss +5 -0
- package/src/styles/utils/structured-list.scss +68 -15
- package/src/styles/utils/theme-dark.scss +23 -0
- package/src/transition/ios.transition.ts +0 -487
|
@@ -11,7 +11,7 @@ enum ShellTabBar {
|
|
|
11
11
|
|
|
12
12
|
static func make(_ node: ShellControl, rendering: ShellRendering, delegate: UITabBarDelegate) -> UITabBar {
|
|
13
13
|
let control = UITabBar()
|
|
14
|
-
control
|
|
14
|
+
configureLayout(control)
|
|
15
15
|
#if DEBUG
|
|
16
16
|
NSLog("[Native UI Shell] Created UITabBar %@", node.id)
|
|
17
17
|
#endif
|
|
@@ -20,6 +20,16 @@ enum ShellTabBar {
|
|
|
20
20
|
return control
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
static func configureLayout(_ tabBar: UITabBar) {
|
|
24
|
+
// The reader accepts icon-top tabs. Keep that layout local to the native
|
|
25
|
+
// bar instead of letting the iPad idiom turn it into inline items.
|
|
26
|
+
if #available(iOS 17.0, *) {
|
|
27
|
+
tabBar.traitOverrides.horizontalSizeClass = .compact
|
|
28
|
+
tabBar.traitOverrides.verticalSizeClass = .regular
|
|
29
|
+
}
|
|
30
|
+
tabBar.itemPositioning = .fill
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
static func update(_ tabBar: UITabBar, node: ShellControl, rendering: ShellRendering) {
|
|
24
34
|
let items = node.items
|
|
25
35
|
let ids = items.map(\.id)
|
|
@@ -40,6 +50,7 @@ enum ShellTabBar {
|
|
|
40
50
|
if tab.selectedImage !== icon { tab.selectedImage = icon }
|
|
41
51
|
tab.isEnabled = !item.content.disabled
|
|
42
52
|
tab.accessibilityLabel = item.content.accessibilityLabel
|
|
53
|
+
applyTypography(item.content, to: tab)
|
|
43
54
|
applyBadge(item.content.badge, to: tab, rendering: rendering)
|
|
44
55
|
}
|
|
45
56
|
let selectedItem = zip(nativeItems, items).first { $0.1.content.selected }?.0
|
|
@@ -48,6 +59,27 @@ enum ShellTabBar {
|
|
|
48
59
|
tabBar.accessibilityIdentifier = node.id
|
|
49
60
|
}
|
|
50
61
|
|
|
62
|
+
static func applyTypography(_ content: ShellItemContent, to item: UITabBarItem) {
|
|
63
|
+
let weight: UIFont.Weight
|
|
64
|
+
switch content.fontWeight {
|
|
65
|
+
case ..<150: weight = .ultraLight
|
|
66
|
+
case ..<250: weight = .thin
|
|
67
|
+
case ..<350: weight = .light
|
|
68
|
+
case ..<450: weight = .regular
|
|
69
|
+
case ..<550: weight = .medium
|
|
70
|
+
case ..<650: weight = .semibold
|
|
71
|
+
case ..<750: weight = .bold
|
|
72
|
+
case ..<850: weight = .heavy
|
|
73
|
+
default: weight = .black
|
|
74
|
+
}
|
|
75
|
+
let font = UIFont.systemFont(ofSize: content.fontSize, weight: weight)
|
|
76
|
+
for state in [UIControl.State.normal, .selected] {
|
|
77
|
+
if item.titleTextAttributes(for: state)?[.font] as? UIFont != font {
|
|
78
|
+
item.setTitleTextAttributes([.font: font], for: state)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
51
83
|
static func applyBadge(_ badge: ShellBadge?, to item: UITabBarItem, rendering: ShellRendering) {
|
|
52
84
|
// nil removes a badge; an empty string keeps the native notification dot.
|
|
53
85
|
item.badgeValue = badge?.value
|
|
@@ -59,8 +91,14 @@ enum ShellTabBar {
|
|
|
59
91
|
}
|
|
60
92
|
|
|
61
93
|
static func fit(_ tabBar: UITabBar, node: ShellControl, bounds: CGRect) -> Bool {
|
|
94
|
+
// Place a new bar before asking UIKit for its safe-area-dependent size.
|
|
95
|
+
// Measuring at the initial zero frame makes a restored iPad bar 5pt shorter.
|
|
96
|
+
if tabBar.bounds.isEmpty {
|
|
97
|
+
tabBar.frame = bounds
|
|
98
|
+
tabBar.layoutIfNeeded()
|
|
99
|
+
}
|
|
62
100
|
let fitted = tabBar.sizeThatFits(bounds.size)
|
|
63
|
-
var size = CGSize(width:
|
|
101
|
+
var size = CGSize(width: bounds.width,
|
|
64
102
|
height: max(bounds.height, fitted.height))
|
|
65
103
|
if tabBar.bounds.size != size { tabBar.bounds.size = size }
|
|
66
104
|
tabBar.layoutIfNeeded()
|
|
@@ -82,11 +120,8 @@ enum ShellTabBar {
|
|
|
82
120
|
}
|
|
83
121
|
content = measured
|
|
84
122
|
}
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
guard abs(content.width - bounds.width) <= 1 else {
|
|
88
|
-
return false
|
|
89
|
-
}
|
|
123
|
+
// UIKit owns the adaptive platter size, including changes after badge/title updates.
|
|
124
|
+
// Preserve the DOM placement anchor instead of requiring identical item widths.
|
|
90
125
|
let anchor = node.tabBarAnchor
|
|
91
126
|
let x = anchor?.x ?? 0
|
|
92
127
|
let y = anchor?.y ?? 0
|
|
@@ -95,16 +95,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
private func removeControl(_ id: String) {
|
|
98
|
+
private func removeControl(_ id: String, duration: TimeInterval = 0) {
|
|
99
99
|
if #available(iOS 26.0, *) {
|
|
100
100
|
(searchControllers.removeValue(forKey: id) as? ShellSearchController)?.detach()
|
|
101
101
|
}
|
|
102
|
-
controls.removeValue(forKey: id)
|
|
102
|
+
if let control = controls.removeValue(forKey: id) { ShellCrossfade.retire(control, duration: duration) }
|
|
103
103
|
fingerprints.removeValue(forKey: id)
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
private func removeControls() {
|
|
107
|
-
Array(controls.keys).forEach(
|
|
106
|
+
private func removeControls(duration: TimeInterval = 0) {
|
|
107
|
+
Array(controls.keys).forEach { removeControl($0, duration: duration) }
|
|
108
108
|
host?.removeFromSuperview()
|
|
109
109
|
host = nil
|
|
110
110
|
rendering.clear()
|
|
@@ -123,11 +123,13 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
123
123
|
guard let snapshot = try? call.decode(ShellSnapshot.self), snapshot.isValid else {
|
|
124
124
|
call.reject("Invalid control snapshot"); return
|
|
125
125
|
}
|
|
126
|
+
let duration = ShellCrossfade.duration(snapshot.transitionDuration)
|
|
127
|
+
let existing = Set(self.controls.keys)
|
|
126
128
|
let snapshots = snapshot.controls
|
|
127
129
|
let width = snapshot.viewportWidth
|
|
128
130
|
self.revision = next
|
|
129
131
|
if snapshots.isEmpty {
|
|
130
|
-
self.removeControls()
|
|
132
|
+
self.removeControls(duration: duration)
|
|
131
133
|
call.resolve(["revision": next]); return
|
|
132
134
|
}
|
|
133
135
|
let host = self.host ?? ShellHost()
|
|
@@ -139,7 +141,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
139
141
|
let scale = webView.bounds.width / width
|
|
140
142
|
let retained = Set(snapshots.map(\.id))
|
|
141
143
|
for id in Array(self.controls.keys) where !retained.contains(id) {
|
|
142
|
-
self.removeControl(id)
|
|
144
|
+
self.removeControl(id, duration: duration)
|
|
143
145
|
}
|
|
144
146
|
var rejectedControls: [String] = []
|
|
145
147
|
var fabs: [(ShellFab, ShellControl)] = []
|
|
@@ -203,6 +205,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
203
205
|
if fab.superview == nil { host.addSubview(fab) }
|
|
204
206
|
self.controls[id] = fab
|
|
205
207
|
fabs.append((fab, node))
|
|
208
|
+
} else if let segment = self.controls[id] as? ShellSegment, node.kind == ShellSegment.kind {
|
|
209
|
+
segment.update(node, scale: scale, rendering: self.rendering)
|
|
206
210
|
} else if let tabBar = self.controls[id] as? UITabBar, node.kind == ShellTabBar.kind {
|
|
207
211
|
ShellTabBar.update(tabBar, node: node, rendering: self.rendering)
|
|
208
212
|
} else {
|
|
@@ -240,6 +244,9 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
|
|
|
240
244
|
controller.surface.isHidden = self.keyboardVisible && !controller.ownsKeyboard
|
|
241
245
|
}
|
|
242
246
|
}
|
|
247
|
+
for (id, control) in self.controls where !existing.contains(id) && self.searchControllers[id] == nil {
|
|
248
|
+
ShellCrossfade.enter(control, duration: duration)
|
|
249
|
+
}
|
|
243
250
|
let complete = { call.resolve(["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls]) }
|
|
244
251
|
if let coordinator = searches.first?.0.transitionCoordinator,
|
|
245
252
|
coordinator.animate(alongsideTransition: nil, completion: { _ in complete() }) { return }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
@MainActor enum ShellCrossfade {
|
|
4
|
+
static func duration(_ milliseconds: Double?) -> TimeInterval {
|
|
5
|
+
UIAccessibility.isReduceMotionEnabled ? 0 : (milliseconds ?? 0) / 1000
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
static func enter(_ view: UIView, duration: TimeInterval) {
|
|
9
|
+
guard duration > 0 else { return }
|
|
10
|
+
view.alpha = 0
|
|
11
|
+
UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
|
|
12
|
+
view.alpha = 1
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static func retire(_ view: UIView, duration: TimeInterval) {
|
|
17
|
+
// The outgoing copy is visual only: it cannot keep accepting actions or
|
|
18
|
+
// VoiceOver focus once the Web source has been restored.
|
|
19
|
+
if duration > 0, let host = view.superview, let parent = host.superview,
|
|
20
|
+
!view.isHidden, !host.isHidden, let copy = view.snapshotView(afterScreenUpdates: false) {
|
|
21
|
+
copy.frame = host.convert(view.frame, to: parent)
|
|
22
|
+
copy.alpha = CGFloat(view.layer.presentation()?.opacity ?? Float(view.alpha))
|
|
23
|
+
copy.isUserInteractionEnabled = false
|
|
24
|
+
copy.isAccessibilityElement = false
|
|
25
|
+
copy.accessibilityElementsHidden = true
|
|
26
|
+
parent.insertSubview(copy, aboveSubview: host)
|
|
27
|
+
UIView.animate(withDuration: duration, delay: 0, options: [.curveEaseInOut]) {
|
|
28
|
+
copy.alpha = 0
|
|
29
|
+
} completion: { _ in
|
|
30
|
+
copy.removeFromSuperview()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
view.removeFromSuperview()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -9,11 +9,12 @@ enum ShellComponent: String, Decodable {
|
|
|
9
9
|
|
|
10
10
|
struct ShellSnapshot: Decodable {
|
|
11
11
|
let revision: Int
|
|
12
|
+
let transitionDuration: Double?
|
|
12
13
|
let viewportWidth: Double
|
|
13
14
|
let controls: [ShellControl]
|
|
14
15
|
|
|
15
16
|
var isValid: Bool {
|
|
16
|
-
revision >= 0 && viewportWidth.isFinite && viewportWidth > 0 && controls.count <= 100 &&
|
|
17
|
+
revision >= 0 && (transitionDuration.map { $0.isFinite && $0 >= 0 && $0 <= 500 } ?? true) && viewportWidth.isFinite && viewportWidth > 0 && controls.count <= 100 &&
|
|
17
18
|
Set(controls.map(\.id)).count == controls.count && controls.allSatisfy(\.isValid)
|
|
18
19
|
}
|
|
19
20
|
}
|
|
@@ -100,6 +101,9 @@ struct ShellItemContent: Decodable, Equatable {
|
|
|
100
101
|
var iconWidth: Double?
|
|
101
102
|
var iconHeight: Double?
|
|
102
103
|
let iconPosition: IconPosition?
|
|
104
|
+
let imagePadding: Double?
|
|
105
|
+
let contentInsetLeading: Double?
|
|
106
|
+
let contentInsetTrailing: Double?
|
|
103
107
|
let iconTemplate: Bool?
|
|
104
108
|
let closeIcon: String?
|
|
105
109
|
let closeIconWidth: Double?
|
|
@@ -109,6 +113,7 @@ struct ShellItemContent: Decodable, Equatable {
|
|
|
109
113
|
var isValid: Bool {
|
|
110
114
|
!id.isEmpty && fontSize.isFinite && fontSize >= 0 && fontWeight.isFinite &&
|
|
111
115
|
[iconWidth, iconHeight, closeIconWidth, closeIconHeight].compactMap { $0 }.allSatisfy { $0.isFinite && $0 > 0 } &&
|
|
116
|
+
[imagePadding, contentInsetLeading, contentInsetTrailing].compactMap { $0 }.allSatisfy { $0.isFinite } &&
|
|
112
117
|
(iconTransition.map { $0.isFinite && $0 >= 0 } ?? true)
|
|
113
118
|
}
|
|
114
119
|
}
|
|
@@ -22,6 +22,65 @@ final class ShellSnapshotTests: XCTestCase {
|
|
|
22
22
|
try JSValueDecoder().decode(ShellSnapshot.self, from: ["revision": 1, "viewportWidth": width, "controls": controls])
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
@MainActor func testTabTypographyPreservesCSSWeightsAndSize() throws {
|
|
26
|
+
let weights: [UIFont.Weight] = [.ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .black]
|
|
27
|
+
let tab = UITabBarItem()
|
|
28
|
+
for (index, weight) in weights.enumerated() {
|
|
29
|
+
let cssWeight = Double((index + 1) * 100)
|
|
30
|
+
let content = try decode([control(["items": [item(["fontSize": 19.0, "fontWeight": cssWeight])]])]).controls[0].items[0].content
|
|
31
|
+
ShellTabBar.applyTypography(content, to: tab)
|
|
32
|
+
for state in [UIControl.State.normal, .selected] {
|
|
33
|
+
let font = try XCTUnwrap(tab.titleTextAttributes(for: state)?[.font] as? UIFont)
|
|
34
|
+
XCTAssertEqual(font.pointSize, 19)
|
|
35
|
+
XCTAssertEqual(font, UIFont.systemFont(ofSize: 19, weight: weight), "CSS weight \(cssWeight)")
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@MainActor func testSegmentSelectionEchoPreservesNativeViewsAndActionsUseUpdatedItems() throws {
|
|
41
|
+
guard #available(iOS 26.0, *) else { return }
|
|
42
|
+
func node(_ items: [JSObject]) throws -> ShellControl {
|
|
43
|
+
try XCTUnwrap(decode([control(["kind": "ion-segment", "width": 160.0, "items": items])]).controls.first)
|
|
44
|
+
}
|
|
45
|
+
let first = try node([item(["id": "left", "selected": true]), item(["id": "right"])])
|
|
46
|
+
var activated: [String] = []
|
|
47
|
+
let segment = try XCTUnwrap(ShellSegment.make(first, scale: 1, rendering: ShellRendering(),
|
|
48
|
+
activate: { activated.append($0) }) as? ShellSegment)
|
|
49
|
+
segment.frame = CGRect(x: 0, y: 0, width: 160, height: 48)
|
|
50
|
+
segment.layoutIfNeeded()
|
|
51
|
+
XCTAssertEqual(segment.widthForSegment(at: 0) + segment.widthForSegment(at: 1), 160)
|
|
52
|
+
segment.selectedSegmentIndex = 1
|
|
53
|
+
segment.layoutIfNeeded()
|
|
54
|
+
let children = segment.subviews
|
|
55
|
+
let animation = CABasicAnimation(keyPath: "opacity")
|
|
56
|
+
animation.fromValue = 0.5
|
|
57
|
+
animation.toValue = 1
|
|
58
|
+
animation.duration = 1
|
|
59
|
+
segment.layer.add(animation, forKey: "selection-in-flight")
|
|
60
|
+
let echo = try node([item(["id": "left"]), item(["id": "right", "selected": true])])
|
|
61
|
+
segment.update(echo, scale: 1, rendering: ShellRendering())
|
|
62
|
+
segment.layoutIfNeeded()
|
|
63
|
+
XCTAssertEqual(segment.selectedSegmentIndex, 1)
|
|
64
|
+
XCTAssertEqual(segment.subviews, children)
|
|
65
|
+
XCTAssertNotNil(segment.layer.animation(forKey: "selection-in-flight"))
|
|
66
|
+
segment.sendActions(for: .valueChanged)
|
|
67
|
+
XCTAssertEqual(activated, ["right"])
|
|
68
|
+
|
|
69
|
+
// Web can reject a selection, clear it, or replace the available actions.
|
|
70
|
+
segment.update(first, scale: 1, rendering: ShellRendering())
|
|
71
|
+
XCTAssertEqual(segment.selectedSegmentIndex, 0)
|
|
72
|
+
let replaced = try node([item(["id": "replacement", "label": "Updated", "disabled": true])])
|
|
73
|
+
segment.update(replaced, scale: 2, rendering: ShellRendering())
|
|
74
|
+
XCTAssertEqual(segment.numberOfSegments, 1)
|
|
75
|
+
XCTAssertEqual(segment.selectedSegmentIndex, UISegmentedControl.noSegment)
|
|
76
|
+
XCTAssertEqual(segment.titleForSegment(at: 0), "Updated")
|
|
77
|
+
XCTAssertFalse(segment.isEnabledForSegment(at: 0))
|
|
78
|
+
XCTAssertEqual(segment.widthForSegment(at: 0), 320)
|
|
79
|
+
segment.selectedSegmentIndex = 0
|
|
80
|
+
segment.sendActions(for: .valueChanged)
|
|
81
|
+
XCTAssertEqual(activated, ["right", "replacement"])
|
|
82
|
+
}
|
|
83
|
+
|
|
25
84
|
func testFlatWireFormatAndOptionalFields() throws {
|
|
26
85
|
let snapshot = try decode([control()])
|
|
27
86
|
XCTAssertTrue(snapshot.isValid)
|
package/package.json
CHANGED
|
@@ -224,6 +224,12 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
|
|
|
224
224
|
trigger.y,
|
|
225
225
|
trigger.width,
|
|
226
226
|
trigger.height,
|
|
227
|
+
candidate.control.items,
|
|
228
|
+
candidate.icons.map(({ source }) => source),
|
|
229
|
+
field,
|
|
230
|
+
trigger,
|
|
231
|
+
bar.placeholder,
|
|
232
|
+
bar.disabled,
|
|
227
233
|
]);
|
|
228
234
|
if (state.rejectedLayout === state.layout) continue;
|
|
229
235
|
candidate.control.search = {
|
|
@@ -259,7 +265,12 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
|
|
|
259
265
|
},
|
|
260
266
|
reject(ids: string[]) {
|
|
261
267
|
states.forEach((state) => {
|
|
262
|
-
if (ids.includes(id(state.binding.tabBar)))
|
|
268
|
+
if (ids.includes(id(state.binding.tabBar))) {
|
|
269
|
+
state.rejectedLayout = state.layout;
|
|
270
|
+
// A rejected search surface has been removed by UIKit. Do not reuse its
|
|
271
|
+
// cached configuration on an inactive page; project ordinary tabs instead.
|
|
272
|
+
state.last = undefined;
|
|
273
|
+
}
|
|
263
274
|
});
|
|
264
275
|
},
|
|
265
276
|
event(event: ShellSearchEvent) {
|
|
@@ -42,6 +42,9 @@ export interface ShellItem extends Frame {
|
|
|
42
42
|
iconWidth?: number;
|
|
43
43
|
iconHeight?: number;
|
|
44
44
|
iconPosition?: 'leading' | 'trailing' | 'top';
|
|
45
|
+
imagePadding?: number;
|
|
46
|
+
contentInsetLeading?: number;
|
|
47
|
+
contentInsetTrailing?: number;
|
|
45
48
|
iconTemplate?: boolean;
|
|
46
49
|
visible?: boolean;
|
|
47
50
|
closeIcon?: string;
|
|
@@ -84,6 +87,7 @@ export interface ShellSearchEvent extends ShellActivation {
|
|
|
84
87
|
|
|
85
88
|
export interface ShellSnapshot {
|
|
86
89
|
revision: number;
|
|
90
|
+
transitionDuration?: number;
|
|
87
91
|
viewportWidth: number;
|
|
88
92
|
controls: ShellControl[];
|
|
89
93
|
}
|
package/src/native/runtime.ts
CHANGED
|
@@ -2,12 +2,13 @@ import type { PluginListenerHandle } from '@capacitor/core';
|
|
|
2
2
|
import { LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE } from '@ionic/core';
|
|
3
3
|
import { getNativeSearchBindings, setNativeUIShellIntegration } from '../native-integration';
|
|
4
4
|
import { createSearchSupport } from './components/searchable-tabs';
|
|
5
|
-
import type { ShellSnapshot, NativeUIShellHandle, NativeUIShellPlugin, NativeUIShellStatus } from './definitions';
|
|
5
|
+
import type { ShellActivation, ShellSnapshot, NativeUIShellHandle, NativeUIShellPlugin, NativeUIShellStatus } from './definitions';
|
|
6
6
|
import { readCandidate, selector, shadowSelector, motionSelector } from './components';
|
|
7
7
|
import { marker, unprojected } from './shared/dom';
|
|
8
8
|
import { createIconRenderer } from './shared/icons';
|
|
9
9
|
import type { Candidate } from './shared/candidate';
|
|
10
10
|
import { CSS_MOTION_EVENTS } from './shared/events';
|
|
11
|
+
import { createCrossfade, fadeMarker } from './shared/crossfade';
|
|
11
12
|
|
|
12
13
|
const overlays = 'ion-modal, ion-popover, ion-alert, ion-action-sheet, ion-loading, ion-picker, ion-toast, ion-menu';
|
|
13
14
|
const overlayNames = ['Modal', 'Popover', 'Alert', 'ActionSheet', 'Loading', 'Picker', 'Toast'];
|
|
@@ -22,6 +23,7 @@ const bounded = <T>(promise: Promise<T>): Promise<T> =>
|
|
|
22
23
|
export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin): Promise<NativeUIShellHandle> => {
|
|
23
24
|
const win = doc.defaultView!;
|
|
24
25
|
const icons = createIconRenderer();
|
|
26
|
+
const crossfade = createCrossfade(win);
|
|
25
27
|
const ids = new WeakMap<Element, string>();
|
|
26
28
|
let rejected = new WeakMap<HTMLElement, string>();
|
|
27
29
|
const sources = new Map<HTMLElement, string | null>();
|
|
@@ -35,6 +37,8 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
|
|
|
35
37
|
let nextId = 0;
|
|
36
38
|
let revision = 0;
|
|
37
39
|
let acceptedRevision = 0;
|
|
40
|
+
let updatingRevision: number | undefined;
|
|
41
|
+
let pendingActivations: ShellActivation[] = [];
|
|
38
42
|
let lastSequence = 0;
|
|
39
43
|
let lastSnapshot = '';
|
|
40
44
|
let viewport = `${win.innerWidth}:${win.innerHeight}`;
|
|
@@ -57,12 +61,15 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
|
|
|
57
61
|
return value;
|
|
58
62
|
};
|
|
59
63
|
const style = doc.createElement('style');
|
|
60
|
-
|
|
64
|
+
const hidden = `[${marker}]:not([${fadeMarker}])`;
|
|
65
|
+
style.textContent = `${hidden}, ${hidden} *, ${hidden}::before, ${hidden}::after, ${hidden}::part(native) { visibility: hidden !important; }
|
|
66
|
+
[${marker}], [${marker}] * { pointer-events: none !important; }`;
|
|
61
67
|
|
|
62
68
|
const restore = (element: HTMLElement) => {
|
|
63
69
|
lastSnapshot = '';
|
|
64
70
|
search.release(element);
|
|
65
71
|
element.removeAttribute(marker);
|
|
72
|
+
if (!stopped) crossfade.play(element, false);
|
|
66
73
|
if (element.getAttribute('aria-hidden') === 'true') {
|
|
67
74
|
const previous = sources.get(element);
|
|
68
75
|
if (previous == null) element.removeAttribute('aria-hidden');
|
|
@@ -197,10 +204,11 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
|
|
|
197
204
|
const data = { viewportWidth: win.innerWidth, controls: candidates.map((candidate) => candidate.control) };
|
|
198
205
|
const serialized = JSON.stringify(data);
|
|
199
206
|
if (serialized === lastSnapshot && !forceRefresh) return;
|
|
200
|
-
const snapshot: ShellSnapshot = { ...data, revision: ++revision };
|
|
207
|
+
const snapshot: ShellSnapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration() };
|
|
201
208
|
// A native visibility notification during this update must survive its ack.
|
|
202
209
|
forceRefresh = false;
|
|
203
210
|
updates++;
|
|
211
|
+
updatingRevision = snapshot.revision;
|
|
204
212
|
const result = await bounded(plugin.update(snapshot));
|
|
205
213
|
if (stopped) return;
|
|
206
214
|
if (result.revision !== snapshot.revision) throw new Error('Native UI Shell revision mismatch');
|
|
@@ -236,11 +244,16 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
|
|
|
236
244
|
for (const element of accepted.flatMap(candidateSources)) {
|
|
237
245
|
if (!sources.has(element)) {
|
|
238
246
|
sources.set(element, element.getAttribute('aria-hidden'));
|
|
247
|
+
crossfade.play(element, true);
|
|
239
248
|
element.setAttribute(marker, '');
|
|
240
249
|
element.setAttribute('aria-hidden', 'true');
|
|
241
250
|
element.dispatchEvent(new CustomEvent('nativeUIShellChange'));
|
|
242
251
|
}
|
|
243
252
|
}
|
|
253
|
+
const received = pendingActivations;
|
|
254
|
+
pendingActivations = [];
|
|
255
|
+
received.forEach(activate);
|
|
256
|
+
await crossfade.settled();
|
|
244
257
|
if (invalidated) {
|
|
245
258
|
dirty = true;
|
|
246
259
|
// Sources that became ineligible must paint before their cover is removed.
|
|
@@ -249,6 +262,8 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
|
|
|
249
262
|
} catch (error) {
|
|
250
263
|
await fail(error);
|
|
251
264
|
} finally {
|
|
265
|
+
updatingRevision = undefined;
|
|
266
|
+
pendingActivations = [];
|
|
252
267
|
// A refresh received before the ack must also invalidate that ack's rejects.
|
|
253
268
|
if (forceRefresh) rejected = new WeakMap();
|
|
254
269
|
pending = false;
|
|
@@ -262,6 +277,7 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
|
|
|
262
277
|
records.some(
|
|
263
278
|
(record) =>
|
|
264
279
|
record.attributeName !== marker &&
|
|
280
|
+
record.attributeName !== fadeMarker &&
|
|
265
281
|
!(
|
|
266
282
|
record.attributeName === 'aria-hidden' &&
|
|
267
283
|
sources.has(record.target as HTMLElement) &&
|
|
@@ -369,7 +385,9 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
|
|
|
369
385
|
resize.disconnect();
|
|
370
386
|
observed.clear();
|
|
371
387
|
actions.clear();
|
|
388
|
+
pendingActivations = [];
|
|
372
389
|
search.destroy();
|
|
390
|
+
crossfade.destroy();
|
|
373
391
|
restoreAll();
|
|
374
392
|
if (!doc.hidden) await painted();
|
|
375
393
|
try {
|
|
@@ -388,35 +406,40 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
|
|
|
388
406
|
finishWaiters();
|
|
389
407
|
},
|
|
390
408
|
};
|
|
409
|
+
const activate = (event: ShellActivation) => {
|
|
410
|
+
if (
|
|
411
|
+
stopped ||
|
|
412
|
+
doc.hidden ||
|
|
413
|
+
event.revision < acceptedRevision ||
|
|
414
|
+
event.revision > revision ||
|
|
415
|
+
event.sequence <= lastSequence ||
|
|
416
|
+
overlayOpen()
|
|
417
|
+
)
|
|
418
|
+
return;
|
|
419
|
+
// Native may send input before update() resolves on the JS bridge.
|
|
420
|
+
// Revalidate it after ownership is accepted, using the same action path.
|
|
421
|
+
if (event.revision > acceptedRevision && event.revision === updatingRevision) {
|
|
422
|
+
pendingActivations.push(event);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
lastSequence = event.sequence;
|
|
426
|
+
const element = actions.get(event.id);
|
|
427
|
+
if (!element) return;
|
|
428
|
+
const owner = Array.from(sources.keys()).find((source) => source === element || source.contains(element));
|
|
429
|
+
if (!owner) return;
|
|
430
|
+
const direct = !blocked(owner) && unprojected(sources.keys(), () => readCandidate(owner, id));
|
|
431
|
+
const candidate = direct || read().find((candidate) => candidate.actions.has(event.id));
|
|
432
|
+
const item = candidate?.control.items.find((item) => item.id === event.id);
|
|
433
|
+
const searchAction =
|
|
434
|
+
candidate?.control.search && [candidate.control.search.trigger.id, candidate.control.search.closeId].includes(event.id);
|
|
435
|
+
if (!searchAction && (!item || item.disabled || item.visible === false)) return;
|
|
436
|
+
// The original Ionic host owns form submission, routerLink and selection events.
|
|
437
|
+
element.click();
|
|
438
|
+
lastSnapshot = ''; // Reconcile even if Ionic rejects the proposed native selection.
|
|
439
|
+
schedule();
|
|
440
|
+
};
|
|
391
441
|
try {
|
|
392
|
-
listener = await bounded(
|
|
393
|
-
plugin.addListener('activate', (event) => {
|
|
394
|
-
if (
|
|
395
|
-
stopped ||
|
|
396
|
-
doc.hidden ||
|
|
397
|
-
event.revision < acceptedRevision ||
|
|
398
|
-
event.revision > revision ||
|
|
399
|
-
event.sequence <= lastSequence ||
|
|
400
|
-
overlayOpen()
|
|
401
|
-
)
|
|
402
|
-
return;
|
|
403
|
-
lastSequence = event.sequence;
|
|
404
|
-
const element = actions.get(event.id);
|
|
405
|
-
if (!element) return;
|
|
406
|
-
const owner = Array.from(sources.keys()).find((source) => source === element || source.contains(element));
|
|
407
|
-
if (!owner) return;
|
|
408
|
-
const direct = !blocked(owner) && unprojected(sources.keys(), () => readCandidate(owner, id));
|
|
409
|
-
const candidate = direct || read().find((candidate) => candidate.actions.has(event.id));
|
|
410
|
-
const item = candidate?.control.items.find((item) => item.id === event.id);
|
|
411
|
-
const searchAction =
|
|
412
|
-
candidate?.control.search && [candidate.control.search.trigger.id, candidate.control.search.closeId].includes(event.id);
|
|
413
|
-
if (!searchAction && (!item || item.disabled || item.visible === false)) return;
|
|
414
|
-
// The original Ionic host owns form submission, routerLink and selection events.
|
|
415
|
-
element.click();
|
|
416
|
-
lastSnapshot = ''; // Reconcile even if Ionic rejects the proposed native selection.
|
|
417
|
-
schedule();
|
|
418
|
-
}),
|
|
419
|
-
);
|
|
442
|
+
listener = await bounded(plugin.addListener('activate', activate));
|
|
420
443
|
searchListener = await bounded(
|
|
421
444
|
plugin.addListener('search', (event) => {
|
|
422
445
|
if (stopped || doc.hidden || event.revision < acceptedRevision || event.revision > revision || overlayOpen()) return;
|
|
@@ -77,6 +77,25 @@ export const appendItem = (
|
|
|
77
77
|
item.iconPosition = (icons[0] ?? svg).getAttribute('slot') === 'end' ? 'trailing' : 'leading';
|
|
78
78
|
candidate.icons.push({ item, source });
|
|
79
79
|
}
|
|
80
|
+
if (native && child.matches('ion-button, ion-back-button')) {
|
|
81
|
+
const style = getComputedStyle(native);
|
|
82
|
+
const icon = icons[0] ?? svg;
|
|
83
|
+
const iconStyle = icon && getComputedStyle(icon);
|
|
84
|
+
const leadingMargin = parseFloat(iconStyle?.marginInlineStart ?? '') || 0;
|
|
85
|
+
const trailingMargin = parseFloat(iconStyle?.marginInlineEnd ?? '') || 0;
|
|
86
|
+
const trailing = item.iconPosition === 'trailing';
|
|
87
|
+
const inner = native.querySelector('.button-inner');
|
|
88
|
+
const gap = inner ? parseFloat(getComputedStyle(inner).columnGap) || 0 : 0;
|
|
89
|
+
// UIKit centers the title/image pair using its insets. Include Ionic's
|
|
90
|
+
// border and negative outer icon margin instead of applying a fixed offset.
|
|
91
|
+
item.contentInsetLeading =
|
|
92
|
+
(parseFloat(style.paddingInlineStart) || 0) +
|
|
93
|
+
(parseFloat(style.borderInlineStartWidth) || 0) +
|
|
94
|
+
(!trailing || !label ? leadingMargin : 0);
|
|
95
|
+
item.contentInsetTrailing =
|
|
96
|
+
(parseFloat(style.paddingInlineEnd) || 0) + (parseFloat(style.borderInlineEndWidth) || 0) + (trailing || !label ? trailingMargin : 0);
|
|
97
|
+
item.imagePadding = label && icon ? gap + (trailing ? leadingMargin : trailingMargin) : 0;
|
|
98
|
+
}
|
|
80
99
|
if (!label && !svg) return undefined;
|
|
81
100
|
candidate.control.items.push(item);
|
|
82
101
|
candidate.actions.set(item.id, child);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const fadeMarker = 'data-native-ui-shell-fading';
|
|
2
|
+
|
|
3
|
+
/** Only visual opacity animates; the runtime transfers input/accessibility ownership once. */
|
|
4
|
+
export const createCrossfade = (win: Window) => {
|
|
5
|
+
const active = new Map<HTMLElement, Animation>();
|
|
6
|
+
const reduced = win.matchMedia('(prefers-reduced-motion: reduce)');
|
|
7
|
+
const finish = (element: HTMLElement, animation: Animation) => {
|
|
8
|
+
if (active.get(element) !== animation) return;
|
|
9
|
+
active.delete(element);
|
|
10
|
+
element.removeAttribute(fadeMarker);
|
|
11
|
+
animation.cancel();
|
|
12
|
+
};
|
|
13
|
+
const cancelAll = () => {
|
|
14
|
+
for (const [element, animation] of active) finish(element, animation);
|
|
15
|
+
};
|
|
16
|
+
reduced.addEventListener('change', cancelAll);
|
|
17
|
+
return {
|
|
18
|
+
duration: () => (reduced.matches ? 0 : 180),
|
|
19
|
+
play(element: HTMLElement, toNative: boolean) {
|
|
20
|
+
const previous = active.get(element);
|
|
21
|
+
const current = Number(win.getComputedStyle(element).opacity);
|
|
22
|
+
previous?.cancel();
|
|
23
|
+
active.delete(element);
|
|
24
|
+
const opacity = Number(win.getComputedStyle(element).opacity);
|
|
25
|
+
if (reduced.matches || !element.isConnected) {
|
|
26
|
+
element.removeAttribute(fadeMarker);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
element.setAttribute(fadeMarker, '');
|
|
30
|
+
const animation = element.animate([{ opacity: previous ? current : toNative ? opacity : 0 }, { opacity: toNative ? 0 : opacity }], {
|
|
31
|
+
duration: 180,
|
|
32
|
+
easing: 'ease-in-out',
|
|
33
|
+
fill: 'both',
|
|
34
|
+
});
|
|
35
|
+
active.set(element, animation);
|
|
36
|
+
void animation.finished.then(
|
|
37
|
+
() => finish(element, animation),
|
|
38
|
+
() => finish(element, animation),
|
|
39
|
+
);
|
|
40
|
+
},
|
|
41
|
+
async settled() {
|
|
42
|
+
await Promise.all(Array.from(active.values(), (animation) => animation.finished.catch(() => {})));
|
|
43
|
+
},
|
|
44
|
+
destroy() {
|
|
45
|
+
reduced.removeEventListener('change', cancelAll);
|
|
46
|
+
cancelAll();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
};
|
package/src/native/shared/dom.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { fadeMarker } from './crossfade';
|
|
2
|
+
|
|
1
3
|
import type { Frame } from '../definitions';
|
|
2
4
|
|
|
3
5
|
export const marker = 'data-native-ui-shell';
|
|
@@ -24,7 +26,8 @@ export const visible = (element: HTMLElement): boolean => {
|
|
|
24
26
|
if (!element.isConnected || element.closest(excluded) || isShellDisabled(element)) return false;
|
|
25
27
|
for (let current: HTMLElement | null = element; current; current = current.parentElement) {
|
|
26
28
|
const style = getComputedStyle(current);
|
|
27
|
-
if (style.display === 'none' || style.visibility !== 'visible' || Number(style.opacity) === 0
|
|
29
|
+
if (style.display === 'none' || style.visibility !== 'visible' || (Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker)))
|
|
30
|
+
return false;
|
|
28
31
|
// Moving/collapsing/custom transformed surfaces stay in Web coordinates.
|
|
29
32
|
if (
|
|
30
33
|
style.transform !== 'none' &&
|