@rdlabo/ionic-theme-ios27 0.1.0-beta.pr154.shac3e35365120e → 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.
Files changed (95) hide show
  1. package/README.md +3 -3
  2. package/dist/css/components/ion-action-sheet.css +1 -1
  3. package/dist/css/components/ion-alert.css +1 -1
  4. package/dist/css/components/ion-button.css +1 -1
  5. package/dist/css/components/ion-card.css +1 -1
  6. package/dist/css/components/ion-fab.css +1 -1
  7. package/dist/css/components/ion-list.css +1 -1
  8. package/dist/css/components/ion-menu.css +1 -1
  9. package/dist/css/components/ion-modal.css +1 -1
  10. package/dist/css/components/ion-popover.css +1 -1
  11. package/dist/css/components/ion-range.css +1 -1
  12. package/dist/css/components/ion-segment.css +1 -1
  13. package/dist/css/components/ion-tabs.css +1 -1
  14. package/dist/css/components/ion-toast.css +1 -1
  15. package/dist/css/components/ion-toolbar.css +1 -1
  16. package/dist/css/default-variables.css +1 -1
  17. package/dist/css/ionic-theme-ios27-dark-always.css +1 -1
  18. package/dist/css/ionic-theme-ios27-dark-class.css +1 -1
  19. package/dist/css/ionic-theme-ios27-dark-system.css +1 -1
  20. package/dist/css/ionic-theme-ios27.css +1 -1
  21. package/dist/css/md-ion-list-inset.css +1 -1
  22. package/dist/css/utils/dark/ion-list.css +1 -0
  23. package/dist/css/utils/group-heading.css +1 -0
  24. package/dist/native/components/searchable-tabs.d.ts.map +1 -1
  25. package/dist/native/components/searchable-tabs.js +9 -1
  26. package/dist/native/definitions.d.ts +4 -0
  27. package/dist/native/definitions.d.ts.map +1 -1
  28. package/dist/native/runtime.d.ts.map +1 -1
  29. package/dist/native/runtime.js +51 -27
  30. package/dist/native/shared/candidate.d.ts.map +1 -1
  31. package/dist/native/shared/candidate.js +17 -0
  32. package/dist/native/shared/crossfade.d.ts +8 -0
  33. package/dist/native/shared/crossfade.d.ts.map +1 -0
  34. package/dist/native/shared/crossfade.js +46 -0
  35. package/dist/native/shared/dom.d.ts.map +1 -1
  36. package/dist/native/shared/dom.js +2 -1
  37. package/dist/popover/animations/ios.enter.d.ts.map +1 -1
  38. package/dist/popover/animations/ios.enter.js +105 -9
  39. package/dist/popover/animations/ios.leave.d.ts.map +1 -1
  40. package/dist/popover/animations/ios.leave.js +15 -1
  41. package/dist/popover/callout-surface.d.ts +2 -0
  42. package/dist/popover/callout-surface.d.ts.map +1 -0
  43. package/dist/popover/callout-surface.js +44 -0
  44. package/dist/sheets-of-glass/index.js +2 -1
  45. package/dist/transition/ios.transition.d.ts.map +1 -1
  46. package/dist/transition/ios.transition.js +0 -223
  47. package/docs/experimental-animation.md +3 -3
  48. package/docs/features.md +7 -2
  49. package/docs/native-ui-shell.md +9 -5
  50. package/docs/special-markup.md +17 -0
  51. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellButton.swift +7 -2
  52. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +10 -0
  53. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSegment.swift +47 -15
  54. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabBar.swift +42 -7
  55. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +13 -6
  56. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellCrossfade.swift +35 -0
  57. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +6 -1
  58. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +59 -0
  59. package/package.json +1 -1
  60. package/src/native/components/searchable-tabs.ts +12 -1
  61. package/src/native/definitions.ts +4 -0
  62. package/src/native/runtime.ts +54 -31
  63. package/src/native/shared/candidate.ts +19 -0
  64. package/src/native/shared/crossfade.ts +49 -0
  65. package/src/native/shared/dom.ts +4 -1
  66. package/src/popover/animations/ios.enter.ts +123 -10
  67. package/src/popover/animations/ios.leave.ts +15 -1
  68. package/src/popover/callout-surface.ts +54 -0
  69. package/src/sheets-of-glass/index.ts +2 -2
  70. package/src/styles/components/ion-action-sheet.scss +35 -17
  71. package/src/styles/components/ion-alert.scss +51 -31
  72. package/src/styles/components/ion-button.scss +4 -7
  73. package/src/styles/components/ion-card.scss +64 -3
  74. package/src/styles/components/ion-fab.scss +3 -5
  75. package/src/styles/components/ion-list.scss +14 -1
  76. package/src/styles/components/ion-menu.scss +19 -7
  77. package/src/styles/components/ion-modal.scss +63 -14
  78. package/src/styles/components/ion-popover.scss +52 -9
  79. package/src/styles/components/ion-range.scss +49 -50
  80. package/src/styles/components/ion-segment.scss +15 -16
  81. package/src/styles/components/ion-tabs.scss +73 -13
  82. package/src/styles/components/ion-toast.scss +16 -25
  83. package/src/styles/components/ion-toolbar.scss +17 -1
  84. package/src/styles/default-variables.scss +4 -0
  85. package/src/styles/utils/_glass.scss +96 -0
  86. package/src/styles/utils/api.scss +46 -4
  87. package/src/styles/utils/dark/ion-button.scss +13 -0
  88. package/src/styles/utils/dark/ion-fab.scss +9 -0
  89. package/src/styles/utils/dark/ion-list.scss +7 -0
  90. package/src/styles/utils/dark/ion-segment.scss +8 -3
  91. package/src/styles/utils/dark/ion-tabs.scss +20 -0
  92. package/src/styles/utils/group-heading.scss +5 -0
  93. package/src/styles/utils/structured-list.scss +68 -15
  94. package/src/styles/utils/theme-dark.scss +23 -0
  95. 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.itemPositioning = .fill
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: tabBar.bounds.width > 0 ? tabBar.bounds.width : bounds.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
- // Some UIKit layouts cap the platter width (notably on iPad).
86
- // Keep the original Web bar instead of changing its item widths.
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)?.removeFromSuperview()
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(removeControl)
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rdlabo/ionic-theme-ios27",
3
3
  "private": false,
4
- "version": "0.1.0-beta.pr154.shac3e35365120e",
4
+ "version": "0.1.0",
5
5
  "description": "iOS27 Theme for Ionic Framework",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
@@ -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))) state.rejectedLayout = state.layout;
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
  }
@@ -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
- style.textContent = `[${marker}], [${marker}] *, [${marker}]::before, [${marker}]::after, [${marker}]::part(native) { visibility: hidden !important; }`;
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
+ };
@@ -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) return false;
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' &&