@rdlabo/ionic-theme-ios27 0.1.0 → 0.2.1-beta.pr185.sha0793981f6cf4

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