@rdlabo/capacitor-native-ui 0.0.0-stage → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/LICENSE +21 -0
  2. package/Package.swift +23 -0
  3. package/README.md +53 -2
  4. package/RdlaboCapacitorNativeUi.podspec +17 -0
  5. package/android/build.gradle +69 -0
  6. package/android/proguard-rules.pro +0 -0
  7. package/android/src/main/AndroidManifest.xml +1 -0
  8. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUI.kt +51 -0
  9. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUIPlugin.kt +192 -0
  10. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUISnapshot.kt +57 -0
  11. package/dist/_virtual/_rolldown/runtime.js +13 -0
  12. package/dist/core.d.ts +4 -0
  13. package/dist/core.js +3 -0
  14. package/dist/native/activation.d.ts +14 -0
  15. package/dist/native/activation.d.ts.map +1 -0
  16. package/dist/native/activation.js +305 -0
  17. package/dist/native/activation.js.map +1 -0
  18. package/dist/native/components/index.d.ts +19 -0
  19. package/dist/native/components/index.d.ts.map +1 -0
  20. package/dist/native/components/index.js +38 -0
  21. package/dist/native/components/index.js.map +1 -0
  22. package/dist/native/components/ion-back-button.d.ts +10 -0
  23. package/dist/native/components/ion-back-button.d.ts.map +1 -0
  24. package/dist/native/components/ion-back-button.js +21 -0
  25. package/dist/native/components/ion-back-button.js.map +1 -0
  26. package/dist/native/components/ion-button.d.ts +10 -0
  27. package/dist/native/components/ion-button.d.ts.map +1 -0
  28. package/dist/native/components/ion-button.js +25 -0
  29. package/dist/native/components/ion-button.js.map +1 -0
  30. package/dist/native/components/ion-buttons.d.ts +11 -0
  31. package/dist/native/components/ion-buttons.d.ts.map +1 -0
  32. package/dist/native/components/ion-buttons.js +30 -0
  33. package/dist/native/components/ion-buttons.js.map +1 -0
  34. package/dist/native/components/ion-fab.d.ts +10 -0
  35. package/dist/native/components/ion-fab.d.ts.map +1 -0
  36. package/dist/native/components/ion-fab.js +132 -0
  37. package/dist/native/components/ion-fab.js.map +1 -0
  38. package/dist/native/components/ion-menu-button.d.ts +13 -0
  39. package/dist/native/components/ion-menu-button.d.ts.map +1 -0
  40. package/dist/native/components/ion-menu-button.js +44 -0
  41. package/dist/native/components/ion-menu-button.js.map +1 -0
  42. package/dist/native/components/ion-segment.d.ts +11 -0
  43. package/dist/native/components/ion-segment.d.ts.map +1 -0
  44. package/dist/native/components/ion-segment.js +30 -0
  45. package/dist/native/components/ion-segment.js.map +1 -0
  46. package/dist/native/components/ion-tab-bar.d.ts +11 -0
  47. package/dist/native/components/ion-tab-bar.d.ts.map +1 -0
  48. package/dist/native/components/ion-tab-bar.js +50 -0
  49. package/dist/native/components/ion-tab-bar.js.map +1 -0
  50. package/dist/native/components/searchable-tabs.d.ts +20 -0
  51. package/dist/native/components/searchable-tabs.d.ts.map +1 -0
  52. package/dist/native/components/searchable-tabs.js +413 -0
  53. package/dist/native/components/searchable-tabs.js.map +1 -0
  54. package/dist/native/definitions.d.ts +255 -0
  55. package/dist/native/definitions.d.ts.map +1 -0
  56. package/dist/native/definitions.js +0 -0
  57. package/dist/native/extensions.d.ts +31 -0
  58. package/dist/native/extensions.d.ts.map +1 -0
  59. package/dist/native/extensions.js +122 -0
  60. package/dist/native/extensions.js.map +1 -0
  61. package/dist/native/foldable/fold-state.d.ts +5 -0
  62. package/dist/native/foldable/fold-state.d.ts.map +1 -0
  63. package/dist/native/foldable/fold-state.js +15 -0
  64. package/dist/native/foldable/fold-state.js.map +1 -0
  65. package/dist/native/foldable/types.d.ts +12 -0
  66. package/dist/native/foldable/types.d.ts.map +1 -0
  67. package/dist/native/foldable/types.js +0 -0
  68. package/dist/native/html.d.ts +9 -0
  69. package/dist/native/html.d.ts.map +1 -0
  70. package/dist/native/html.js +188 -0
  71. package/dist/native/html.js.map +1 -0
  72. package/dist/native/index.d.ts +10 -0
  73. package/dist/native/index.d.ts.map +1 -0
  74. package/dist/native/index.js +28 -0
  75. package/dist/native/index.js.map +1 -0
  76. package/dist/native/legacy-layout.d.ts +5 -0
  77. package/dist/native/legacy-layout.d.ts.map +1 -0
  78. package/dist/native/legacy-layout.js +27 -0
  79. package/dist/native/legacy-layout.js.map +1 -0
  80. package/dist/native/overlays/content.d.ts +4 -0
  81. package/dist/native/overlays/content.d.ts.map +1 -0
  82. package/dist/native/overlays/content.js +51 -0
  83. package/dist/native/overlays/content.js.map +1 -0
  84. package/dist/native/overlays/controller.d.ts +8 -0
  85. package/dist/native/overlays/controller.d.ts.map +1 -0
  86. package/dist/native/overlays/controller.js +285 -0
  87. package/dist/native/overlays/controller.js.map +1 -0
  88. package/dist/native/overlays/dialog-animation.d.ts +9 -0
  89. package/dist/native/overlays/dialog-animation.d.ts.map +1 -0
  90. package/dist/native/overlays/dialog-animation.js +53 -0
  91. package/dist/native/overlays/dialog-animation.js.map +1 -0
  92. package/dist/native/overlays/dismiss-animation.d.ts +4 -0
  93. package/dist/native/overlays/dismiss-animation.d.ts.map +1 -0
  94. package/dist/native/overlays/dismiss-animation.js +18 -0
  95. package/dist/native/overlays/dismiss-animation.js.map +1 -0
  96. package/dist/native/overlays/focus.d.ts +5 -0
  97. package/dist/native/overlays/focus.d.ts.map +1 -0
  98. package/dist/native/overlays/focus.js +59 -0
  99. package/dist/native/overlays/focus.js.map +1 -0
  100. package/dist/native/overlays/popover.d.ts +12 -0
  101. package/dist/native/overlays/popover.d.ts.map +1 -0
  102. package/dist/native/overlays/popover.js +110 -0
  103. package/dist/native/overlays/popover.js.map +1 -0
  104. package/dist/native/overlays/styles.d.ts +11 -0
  105. package/dist/native/overlays/styles.d.ts.map +1 -0
  106. package/dist/native/overlays/styles.js +167 -0
  107. package/dist/native/overlays/styles.js.map +1 -0
  108. package/dist/native/prehide.d.ts +8 -0
  109. package/dist/native/prehide.d.ts.map +1 -0
  110. package/dist/native/prehide.js +238 -0
  111. package/dist/native/prehide.js.map +1 -0
  112. package/dist/native/runtime.d.ts +7 -0
  113. package/dist/native/runtime.d.ts.map +1 -0
  114. package/dist/native/runtime.js +788 -0
  115. package/dist/native/runtime.js.map +1 -0
  116. package/dist/native/shared/bridge.d.ts +6 -0
  117. package/dist/native/shared/bridge.d.ts.map +1 -0
  118. package/dist/native/shared/bridge.js +18 -0
  119. package/dist/native/shared/bridge.js.map +1 -0
  120. package/dist/native/shared/candidate.d.ts +25 -0
  121. package/dist/native/shared/candidate.d.ts.map +1 -0
  122. package/dist/native/shared/candidate.js +106 -0
  123. package/dist/native/shared/candidate.js.map +1 -0
  124. package/dist/native/shared/clipping.d.ts +5 -0
  125. package/dist/native/shared/clipping.d.ts.map +1 -0
  126. package/dist/native/shared/clipping.js +26 -0
  127. package/dist/native/shared/clipping.js.map +1 -0
  128. package/dist/native/shared/crossfade.d.ts +12 -0
  129. package/dist/native/shared/crossfade.d.ts.map +1 -0
  130. package/dist/native/shared/crossfade.js +67 -0
  131. package/dist/native/shared/crossfade.js.map +1 -0
  132. package/dist/native/shared/diagnostics.d.ts +4 -0
  133. package/dist/native/shared/diagnostics.d.ts.map +1 -0
  134. package/dist/native/shared/diagnostics.js +18 -0
  135. package/dist/native/shared/diagnostics.js.map +1 -0
  136. package/dist/native/shared/dom.d.ts +46 -0
  137. package/dist/native/shared/dom.d.ts.map +1 -0
  138. package/dist/native/shared/dom.js +180 -0
  139. package/dist/native/shared/dom.js.map +1 -0
  140. package/dist/native/shared/events.d.ts +15 -0
  141. package/dist/native/shared/events.d.ts.map +1 -0
  142. package/dist/native/shared/events.js +17 -0
  143. package/dist/native/shared/events.js.map +1 -0
  144. package/dist/native/shared/icons.d.ts +8 -0
  145. package/dist/native/shared/icons.d.ts.map +1 -0
  146. package/dist/native/shared/icons.js +61 -0
  147. package/dist/native/shared/icons.js.map +1 -0
  148. package/dist/native/shared/modal.d.ts +10 -0
  149. package/dist/native/shared/modal.d.ts.map +1 -0
  150. package/dist/native/shared/modal.js +160 -0
  151. package/dist/native/shared/modal.js.map +1 -0
  152. package/dist/native/shared/projection-id.d.ts +4 -0
  153. package/dist/native/shared/projection-id.d.ts.map +1 -0
  154. package/dist/native/shared/projection-id.js +7 -0
  155. package/dist/native/shared/projection-id.js.map +1 -0
  156. package/dist/native/shared/toolbar-options.d.ts +9 -0
  157. package/dist/native/shared/toolbar-options.d.ts.map +1 -0
  158. package/dist/native/shared/toolbar-options.js +36 -0
  159. package/dist/native/shared/toolbar-options.js.map +1 -0
  160. package/dist/native/shared/vertical-bars-geometry.d.ts +10 -0
  161. package/dist/native/shared/vertical-bars-geometry.d.ts.map +1 -0
  162. package/dist/native/shared/vertical-bars-geometry.js +85 -0
  163. package/dist/native/shared/vertical-bars-geometry.js.map +1 -0
  164. package/dist/native/startup.d.ts +8 -0
  165. package/dist/native/startup.d.ts.map +1 -0
  166. package/dist/native/startup.js +86 -0
  167. package/dist/native/startup.js.map +1 -0
  168. package/dist/native/vertical-bars-web-search.d.ts +10 -0
  169. package/dist/native/vertical-bars-web-search.d.ts.map +1 -0
  170. package/dist/native/vertical-bars-web-search.js +90 -0
  171. package/dist/native/vertical-bars-web-search.js.map +1 -0
  172. package/dist/native/vertical-bars-web.d.ts +5 -0
  173. package/dist/native/vertical-bars-web.d.ts.map +1 -0
  174. package/dist/native/vertical-bars-web.js +421 -0
  175. package/dist/native/vertical-bars-web.js.map +1 -0
  176. package/dist/native-integration/index.d.ts +25 -0
  177. package/dist/native-integration/index.d.ts.map +1 -0
  178. package/dist/native-integration/index.js +33 -0
  179. package/dist/native-integration/index.js.map +1 -0
  180. package/dist/vertical-bars.d.ts +6 -0
  181. package/dist/vertical-bars.js +5 -0
  182. package/docs/configuration.md +187 -0
  183. package/docs/framework-independent.md +82 -0
  184. package/docs/migration.md +206 -0
  185. package/docs/native-mini-player.md +47 -0
  186. package/docs/native-ui-extensions.md +365 -0
  187. package/docs/native-ui.md +409 -0
  188. package/docs/setup.md +205 -0
  189. package/docs/vertical-bars.md +379 -0
  190. package/ios/Sources/NativeUIPlugin/Components/NativeUIButton.swift +71 -0
  191. package/ios/Sources/NativeUIPlugin/Components/NativeUIButtons.swift +24 -0
  192. package/ios/Sources/NativeUIPlugin/Components/NativeUIComponents.swift +19 -0
  193. package/ios/Sources/NativeUIPlugin/Components/NativeUIFab.swift +80 -0
  194. package/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift +439 -0
  195. package/ios/Sources/NativeUIPlugin/Components/NativeUISegment.swift +91 -0
  196. package/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift +218 -0
  197. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +590 -0
  198. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalSearch.swift +118 -0
  199. package/ios/Sources/NativeUIPlugin/Extensions/NativeUI.swift +178 -0
  200. package/ios/Sources/NativeUIPlugin/Extensions/NativeUIRendering.swift +38 -0
  201. package/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +615 -0
  202. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayController.swift +119 -0
  203. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayHost.swift +75 -0
  204. package/ios/Sources/NativeUIPlugin/Shared/NativeUIControlRendering.swift +21 -0
  205. package/ios/Sources/NativeUIPlugin/Shared/NativeUICrossfade.swift +35 -0
  206. package/ios/Sources/NativeUIPlugin/Shared/NativeUIHost.swift +8 -0
  207. package/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift +161 -0
  208. package/ios/Sources/NativeUIPlugin/Shared/NativeUIVerticalBarsGeometry.swift +52 -0
  209. package/ios/Tests/NativeUIPluginTests/NativeUIExtensionTests.swift +165 -0
  210. package/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +805 -0
  211. package/package.json +89 -4
  212. package/src/core.ts +15 -0
  213. package/src/native/activation.ts +394 -0
  214. package/src/native/components/index.ts +45 -0
  215. package/src/native/components/ion-back-button.ts +22 -0
  216. package/src/native/components/ion-button.ts +25 -0
  217. package/src/native/components/ion-buttons.ts +37 -0
  218. package/src/native/components/ion-fab.ts +151 -0
  219. package/src/native/components/ion-menu-button.ts +37 -0
  220. package/src/native/components/ion-segment.ts +27 -0
  221. package/src/native/components/ion-tab-bar.ts +50 -0
  222. package/src/native/components/searchable-tabs.ts +499 -0
  223. package/src/native/definitions.ts +324 -0
  224. package/src/native/extensions.ts +176 -0
  225. package/src/native/foldable/fold-state.ts +10 -0
  226. package/src/native/foldable/types.ts +4 -0
  227. package/src/native/html.ts +193 -0
  228. package/src/native/index.ts +41 -0
  229. package/src/native/legacy-layout.ts +21 -0
  230. package/src/native/overlays/content.ts +51 -0
  231. package/src/native/overlays/controller.ts +349 -0
  232. package/src/native/overlays/dialog-animation.ts +53 -0
  233. package/src/native/overlays/dismiss-animation.ts +15 -0
  234. package/src/native/overlays/focus.ts +64 -0
  235. package/src/native/overlays/popover.ts +120 -0
  236. package/src/native/overlays/styles.ts +181 -0
  237. package/src/native/prehide.ts +285 -0
  238. package/src/native/runtime.ts +988 -0
  239. package/src/native/shared/bridge.ts +18 -0
  240. package/src/native/shared/candidate.ts +162 -0
  241. package/src/native/shared/clipping.ts +24 -0
  242. package/src/native/shared/crossfade.ts +67 -0
  243. package/src/native/shared/diagnostics.ts +13 -0
  244. package/src/native/shared/dom.ts +304 -0
  245. package/src/native/shared/events.ts +14 -0
  246. package/src/native/shared/icons.ts +57 -0
  247. package/src/native/shared/modal.ts +157 -0
  248. package/src/native/shared/projection-id.ts +2 -0
  249. package/src/native/shared/toolbar-options.ts +53 -0
  250. package/src/native/shared/vertical-bars-geometry.ts +80 -0
  251. package/src/native/startup.ts +84 -0
  252. package/src/native/vertical-bars-web-search.ts +86 -0
  253. package/src/native/vertical-bars-web.ts +498 -0
  254. package/src/native-integration/index.ts +49 -0
  255. package/src/vertical-bars.ts +11 -0
@@ -0,0 +1,439 @@
1
+ import UIKit
2
+
3
+ struct NativeUISearch: Decodable, Equatable {
4
+ let id: String
5
+ let field: NativeUIItemContent
6
+ let trigger: NativeUIItem
7
+ let closeId: String
8
+ let active: Bool
9
+ let available: Bool
10
+ let focused: Bool
11
+ let value: String
12
+ let placeholder: String
13
+ let disabled: Bool
14
+ let editSequence: Int
15
+ let valueVersion: Int
16
+
17
+ var isValid: Bool {
18
+ !id.isEmpty && !closeId.isEmpty && field.isValid && trigger.isValid && editSequence >= 0 && valueVersion >= 0
19
+ }
20
+ }
21
+
22
+ enum NativeUISearchPhase: String { case input, focus, blur, clear, commit }
23
+
24
+ // A controller's empty content must not intercept the existing WebView.
25
+ final class NativeUISearchHost: UIView {
26
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
27
+ guard let hit = super.hitTest(point, with: event) else { return nil }
28
+ var ancestor: UIView? = hit
29
+ while let current = ancestor, current !== self {
30
+ if current is UIControl || current is UISearchBar || current is UITabBar { return hit }
31
+ ancestor = current.superview
32
+ }
33
+ return nil
34
+ }
35
+ }
36
+
37
+ // Preserve UISearchBar's delegate while returning its clear action to Ionic.
38
+ final class NativeUISearchInputDelegate: NSObject, UITextFieldDelegate {
39
+ weak var original: UITextFieldDelegate?
40
+ var clear: (() -> Void)?
41
+ override func responds(to selector: Selector!) -> Bool {
42
+ super.responds(to: selector) || original?.responds(to: selector) == true
43
+ }
44
+ override func forwardingTarget(for selector: Selector!) -> Any? { original }
45
+ func textFieldShouldClear(_ textField: UITextField) -> Bool { clear?(); return false }
46
+ }
47
+
48
+ // Lets the plugin registry retain iOS 26-gated search controllers without
49
+ // availability-gated stored properties, mirroring NativeUIVerticalBarsControlling.
50
+ protocol NativeUISearchControlling: AnyObject {
51
+ var surface: NativeUISearchHost { get }
52
+ var ownsKeyboard: Bool { get }
53
+ var ownsKeyboardChrome: Bool { get }
54
+ var transitionCoordinator: UIViewControllerTransitionCoordinator? { get }
55
+ var activate: ((String) -> Void)? { get set }
56
+ var changed: ((String, NativeUISearchPhase, String, Bool, Int) -> Int)? { get set }
57
+ func attach(to parent: UIViewController, in container: UIView)
58
+ func detach()
59
+ func apply(_ snapshot: NativeUIControl, webFrame: CGRect, barFrame: CGRect, triggerFrame: CGRect, rendering: NativeUIControlRendering) -> Bool
60
+ }
61
+
62
+ @available(iOS 26.0, *)
63
+ final class NativeUISearchController: UITabBarController, UITabBarControllerDelegate, UISearchBarDelegate, NativeUISearchControlling {
64
+ // Wire stays active+focused; local session drives chrome (idle / presented / focused).
65
+ private enum Session: Equatable { case idle, presented, focused }
66
+
67
+ let surface = NativeUISearchHost()
68
+ private var restingBarFrame: CGRect?
69
+ private var restingBarAnchor: NativeUITabBar.Anchor?
70
+ private var fittingTabBar = false
71
+ private let search = UISearchController(searchResultsController: nil)
72
+ private let inputDelegate = NativeUISearchInputDelegate()
73
+ private var searchTab: UISearchTab!
74
+ private var ordinary: [String: UITab] = [:]
75
+ private var configuration: NativeUISearch?
76
+ private var selectedID = ""
77
+ private var closing = false
78
+ private var editingSequence = 0
79
+ private var valueVersion = -1
80
+ private var lockedWebFrame: CGRect? // frozen while search is active (width changes re-lock)
81
+ private var session: Session = .idle
82
+ private var focusWork: DispatchWorkItem?
83
+ private var pendingSelection: NativeUITabBar.PendingSelection? // optimistic ordinary tab
84
+ private var pendingExpiryWork: DispatchWorkItem?
85
+ var ownsKeyboard: Bool { search.searchBar.searchTextField.isFirstResponder }
86
+ var ownsKeyboardChrome: Bool { session == .focused || ownsKeyboard }
87
+ var activate: ((String) -> Void)?
88
+ var changed: ((String, NativeUISearchPhase, String, Bool, Int) -> Int)?
89
+
90
+ override func viewDidLoad() {
91
+ super.viewDidLoad()
92
+ view.backgroundColor = .clear
93
+ view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
94
+ surface.backgroundColor = .clear
95
+ surface.autoresizingMask = [.flexibleWidth, .flexibleHeight]
96
+ delegate = self
97
+ mode = .tabBar
98
+ NativeUITabBar.configureLayout(tabBar)
99
+ search.obscuresBackgroundDuringPresentation = false
100
+ search.hidesNavigationBarDuringPresentation = false
101
+ search.searchBar.delegate = self
102
+ search.searchBar.autocapitalizationType = .none
103
+ search.searchBar.autocorrectionType = .no
104
+ search.searchBar.spellCheckingType = .no
105
+ search.searchBar.searchTextField.clearButtonMode = .always
106
+ searchTab = UISearchTab { [weak self] _ in
107
+ let child = UIViewController()
108
+ child.view.backgroundColor = .clear
109
+ child.definesPresentationContext = true
110
+ child.navigationItem.searchController = self?.search
111
+ child.navigationItem.hidesSearchBarWhenScrolling = false
112
+ child.navigationItem.preferredSearchBarPlacement = .integrated
113
+ let navigation = UINavigationController(rootViewController: child)
114
+ navigation.view.backgroundColor = .clear
115
+ return navigation
116
+ }
117
+ searchTab.automaticallyActivatesSearch = false
118
+ inputDelegate.clear = { [weak self] in self?.emit(.clear) }
119
+ }
120
+
121
+ deinit {
122
+ focusWork?.cancel()
123
+ pendingExpiryWork?.cancel()
124
+ }
125
+
126
+ private func wantedSession(active: Bool, focused: Bool) -> Session {
127
+ if !active { return .idle }
128
+ return focused ? .focused : .presented
129
+ }
130
+
131
+ private func endEditing() {
132
+ focusWork?.cancel()
133
+ focusWork = nil
134
+ if search.searchBar.searchTextField.isFirstResponder {
135
+ search.searchBar.searchTextField.resignFirstResponder()
136
+ }
137
+ if search.isActive { search.isActive = false }
138
+ }
139
+
140
+ private func applySession(_ wanted: Session, selectingSearchTab: Bool) {
141
+ if wanted == session && !selectingSearchTab { return }
142
+ if wanted == .idle {
143
+ endEditing()
144
+ session = .idle
145
+ return
146
+ }
147
+ if selectingSearchTab || selectedTab !== searchTab { selectedTab = searchTab }
148
+ if wanted == .presented {
149
+ if session == .focused { endEditing() }
150
+ session = .presented
151
+ return
152
+ }
153
+ session = .focused
154
+ if !search.isActive { search.isActive = true }
155
+ guard !search.searchBar.searchTextField.isFirstResponder, focusWork == nil else { return }
156
+ let work = DispatchWorkItem { [weak self] in
157
+ guard let self else { return }
158
+ self.focusWork = nil
159
+ guard self.session == .focused, !self.search.searchBar.searchTextField.isFirstResponder else { return }
160
+ if !self.search.isActive { self.search.isActive = true }
161
+ _ = self.search.searchBar.searchTextField.becomeFirstResponder()
162
+ }
163
+ focusWork = work
164
+ DispatchQueue.main.async(execute: work)
165
+ }
166
+
167
+ private func armPendingSelection(_ id: String) {
168
+ pendingSelection = .start(id)
169
+ schedulePendingExpiry()
170
+ }
171
+
172
+ private func clearPendingSelection() {
173
+ pendingExpiryWork?.cancel()
174
+ pendingExpiryWork = nil
175
+ pendingSelection = nil
176
+ }
177
+
178
+ private func schedulePendingExpiry() {
179
+ pendingExpiryWork?.cancel()
180
+ pendingExpiryWork = nil
181
+ guard let pending = pendingSelection else { return }
182
+ let delay = max(0, pending.until - CFAbsoluteTimeGetCurrent()) + 0.02
183
+ let work = DispatchWorkItem { [weak self] in
184
+ guard let self else { return }
185
+ self.pendingExpiryWork = nil
186
+ guard let pending = self.pendingSelection, CFAbsoluteTimeGetCurrent() >= pending.until else { return }
187
+ self.pendingSelection = nil
188
+ // Revert to the last DOM selection if Ionic did not accept the action.
189
+ if let selected = self.ordinary[self.selectedID], self.selectedTab !== selected {
190
+ self.selectedTab = selected
191
+ }
192
+ }
193
+ pendingExpiryWork = work
194
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
195
+ }
196
+
197
+ private func resolveOrdinarySelection(_ items: [NativeUIItem], fallback: UITab?) {
198
+ let domSelected = ordinary[selectedID] ?? fallback
199
+ guard let pending = pendingSelection else {
200
+ pendingExpiryWork?.cancel()
201
+ pendingExpiryWork = nil
202
+ if selectedTab !== domSelected { selectedTab = domSelected }
203
+ return
204
+ }
205
+ let pendingTab = ordinary[pending.id]
206
+ let expired = CFAbsoluteTimeGetCurrent() >= pending.until
207
+ let unavailable = pendingTab == nil || (items.first { $0.id == pending.id }?.content.disabled ?? true)
208
+ if unavailable || expired {
209
+ clearPendingSelection()
210
+ selectedTab = domSelected
211
+ } else if selectedID == pending.id {
212
+ clearPendingSelection()
213
+ if selectedTab !== domSelected { selectedTab = domSelected }
214
+ } else if selectedTab !== pendingTab {
215
+ selectedTab = pendingTab
216
+ }
217
+ }
218
+
219
+ func attach(to parent: UIViewController, in container: UIView) {
220
+ parent.addChild(self)
221
+ container.addSubview(surface)
222
+ surface.addSubview(view)
223
+ didMove(toParent: parent)
224
+ }
225
+
226
+ func detach() {
227
+ closing = true
228
+ lockedWebFrame = nil
229
+ clearPendingSelection()
230
+ applySession(.idle, selectingSearchTab: false)
231
+ willMove(toParent: nil)
232
+ restingBarFrame = nil
233
+ surface.removeFromSuperview()
234
+ view.removeFromSuperview()
235
+ removeFromParent()
236
+ }
237
+
238
+ func apply(_ snapshot: NativeUIControl, webFrame: CGRect, barFrame: CGRect, triggerFrame: CGRect,
239
+ rendering: NativeUIControlRendering) -> Bool {
240
+ loadViewIfNeeded()
241
+ guard let configuration = snapshot.search else { return false }
242
+ if self.configuration?.id != configuration.id {
243
+ editingSequence = 0
244
+ valueVersion = -1
245
+ }
246
+ let wasActive = self.configuration.map { $0.available && $0.active } ?? false
247
+ self.configuration = configuration
248
+ let available = configuration.available
249
+ let active = available && configuration.active
250
+ let wanted = wantedSession(active: active, focused: configuration.focused)
251
+ let items = snapshot.items
252
+ let ids = items.map(\.id)
253
+ for id in Array(ordinary.keys) where !ids.contains(id) { ordinary.removeValue(forKey: id) }
254
+ for item in items {
255
+ let id = item.id
256
+ let tab = ordinary[id] ?? UITab(title: "", image: nil, identifier: id) { _ in
257
+ let child = UIViewController()
258
+ child.view.backgroundColor = .clear
259
+ return child
260
+ }
261
+ tab.accessibilityIdentifier = id
262
+ tab.title = item.content.label
263
+ tab.image = rendering.image(item.content)
264
+ tab.badgeValue = item.content.badge?.value
265
+ tab.isEnabled = !item.content.disabled
266
+ ordinary[id] = tab
267
+ if item.content.selected { selectedID = id }
268
+ }
269
+ let trigger = configuration.trigger
270
+ searchTab.accessibilityIdentifier = trigger.id
271
+ searchTab.image = rendering.image(trigger.content)
272
+ searchTab.isEnabled = !configuration.disabled
273
+ let requested = ids.compactMap { ordinary[$0] } + (available ? [searchTab!] : [])
274
+ if !active, tabs.isEmpty || tabs.map(\.identifier) != requested.map(\.identifier) {
275
+ tabs = requested
276
+ }
277
+ for item in items {
278
+ if let nativeItem = ordinary[item.id]?.viewController?.tabBarItem {
279
+ nativeItem.accessibilityLabel = item.content.accessibilityLabel
280
+ NativeUITabBar.applyTypography(item.content, to: nativeItem)
281
+ NativeUITabBar.applyBadge(item.content.badge, to: nativeItem, rendering: rendering)
282
+ }
283
+ }
284
+ searchTab.viewController?.tabBarItem.accessibilityLabel = trigger.content.accessibilityLabel
285
+ let prominent = NSSelectorFromString("setProminentTabIdentifier:")
286
+ if responds(to: prominent) { setValue(available ? searchTab.identifier : nil, forKey: "prominentTabIdentifier") }
287
+ let field = configuration.field
288
+ search.searchBar.searchTextField.accessibilityIdentifier = configuration.id
289
+ search.searchBar.searchTextField.accessibilityLabel = field.accessibilityLabel
290
+ search.searchBar.setImage(rendering.image(field), for: .search, state: .normal)
291
+ search.searchBar.placeholder = configuration.placeholder
292
+ search.searchBar.searchTextField.isEnabled = !configuration.disabled
293
+ let nextValueVersion = configuration.valueVersion
294
+ if nextValueVersion != valueVersion || configuration.editSequence >= editingSequence {
295
+ valueVersion = nextValueVersion
296
+ let value = configuration.value
297
+ if search.searchBar.text != value { search.searchBar.text = value }
298
+ }
299
+ if search.searchBar.searchTextField.delegate !== inputDelegate {
300
+ inputDelegate.original = search.searchBar.searchTextField.delegate
301
+ search.searchBar.searchTextField.delegate = inputDelegate
302
+ }
303
+ // Resting keeps closing=true so hidden UISearchBar noise cannot emit; clear it while active.
304
+ closing = !active
305
+ surface.isHidden = false
306
+ surface.overrideUserInterfaceStyle = snapshot.dark ? .dark : .light
307
+ if active {
308
+ // UISearchTab session owns the bar + keyboard; freeze Web layout for the session.
309
+ if tabs.isEmpty || tabs.map(\.identifier) != requested.map(\.identifier) {
310
+ tabs = requested
311
+ }
312
+ restingBarFrame = nil
313
+ view.frame = surface.bounds
314
+ let nextLock = surface.bounds.isEmpty ? webFrame : surface.frame
315
+ if let locked = lockedWebFrame, abs(locked.width - webFrame.width) > 0.5 {
316
+ // Rotation / size-class change: adopt the new width while still ignoring keyboard shrink.
317
+ lockedWebFrame = webFrame
318
+ } else if lockedWebFrame == nil {
319
+ lockedWebFrame = nextLock
320
+ }
321
+ if let lockedWebFrame, surface.frame != lockedWebFrame { surface.frame = lockedWebFrame }
322
+ clearPendingSelection()
323
+ applySession(wanted, selectingSearchTab: !wasActive)
324
+ return true
325
+ }
326
+
327
+ surface.frame = webFrame
328
+ view.semanticContentAttribute = snapshot.rtl ? .forceRightToLeft : .forceLeftToRight
329
+ tabBar.semanticContentAttribute = snapshot.rtl ? .forceRightToLeft : .forceLeftToRight
330
+ tabBar.accessibilityIdentifier = snapshot.id
331
+ if lockedWebFrame != nil {
332
+ lockedWebFrame = nil
333
+ }
334
+ applySession(.idle, selectingSearchTab: false)
335
+
336
+ view.frame = surface.bounds
337
+ resolveOrdinarySelection(items, fallback: requested.first)
338
+ restingBarFrame = surface.convert(barFrame, from: surface.superview)
339
+ restingBarAnchor = snapshot.tabBarAnchor
340
+ view.setNeedsLayout()
341
+ view.layoutIfNeeded()
342
+ return fitRestingTabBar()
343
+ }
344
+
345
+ override func viewDidLayoutSubviews() {
346
+ super.viewDidLayoutSubviews()
347
+ if session == .idle { _ = fitRestingTabBar() }
348
+ }
349
+
350
+ /// Match the ordinary tab platter to the source ion-tab-bar by resizing
351
+ /// the controller's view, so UIKit can keep managing its tab bar through
352
+ /// search expansion and dismissal.
353
+ private func fitRestingTabBar() -> Bool {
354
+ guard let bounds = restingBarFrame, !fittingTabBar else { return true }
355
+ fittingTabBar = true
356
+ defer { fittingTabBar = false }
357
+ tabBar.layoutIfNeeded()
358
+ for _ in 0..<2 {
359
+ let regions = NativeUITabBar.contentViews(tabBar)
360
+ func controlCount(_ view: UIView) -> Int {
361
+ (view is UIControl ? 1 : 0) + view.subviews.reduce(0) { $0 + controlCount($1) }
362
+ }
363
+ // Ordinary tabs contain their item controls; Search contains one.
364
+ // For a single tab, UIKit places ordinary tabs at the leading edge.
365
+ let rtl = tabBar.effectiveUserInterfaceLayoutDirection == .rightToLeft
366
+ guard let region = regions.max(by: {
367
+ let lhs = controlCount($0), rhs = controlCount($1)
368
+ if lhs != rhs { return lhs < rhs }
369
+ return rtl ? $0.frame.midX < $1.frame.midX : $0.frame.midX > $1.frame.midX
370
+ }) else { return false }
371
+ let content = region.frame
372
+ let contents = regions.map { $0.frame }
373
+ let measured = tabBar.convert(content, to: surface)
374
+ let width = bounds.width - measured.width
375
+ let x = bounds.minX + (bounds.width - measured.width) * (restingBarAnchor?.x ?? 0) - measured.minX
376
+ let y = bounds.minY + (bounds.height - measured.height) * (restingBarAnchor?.y ?? 0) - measured.minY
377
+ // UIKit rounds the platter frame; a subpoint difference must not
378
+ // keep invalidating its layout during search transitions.
379
+ if abs(width) <= 0.5 && abs(x) <= 0.5 && abs(y) <= 0.5 { break }
380
+ var frame = view.frame
381
+ // The controller also contains the separate search button. Include
382
+ // that space in its width limit without enlarging capped tab groups.
383
+ let searchWidth = contents.filter { $0 != content }.reduce(CGFloat(0)) { $0 + $1.width }
384
+ frame.size.width = min(frame.width + width, surface.bounds.width + searchWidth)
385
+ frame.origin.x += x
386
+ frame.origin.y += y
387
+ if abs(frame.width - view.frame.width) <= 0.5 && abs(x) <= 0.5 && abs(y) <= 0.5 { break }
388
+ view.frame = frame
389
+ view.setNeedsLayout()
390
+ view.layoutIfNeeded()
391
+ }
392
+ // Fitting the ordinary platter can move UIKit's separate Search control
393
+ // outside the WebView, especially for centered or trailing source bars.
394
+ // Reject that layout so Search keeps its usable Web fallback.
395
+ let visibleBounds = surface.bounds.insetBy(dx: -0.5, dy: -0.5)
396
+ let regions = NativeUITabBar.contentViews(tabBar)
397
+ return !regions.isEmpty && regions.allSatisfy {
398
+ visibleBounds.contains(tabBar.convert($0.frame, to: surface))
399
+ }
400
+ }
401
+
402
+ func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool {
403
+ guard let configuration else { return false }
404
+ if tab === searchTab {
405
+ restingBarFrame = nil
406
+ clearPendingSelection()
407
+ activate?(configuration.trigger.id)
408
+ return true
409
+ }
410
+ if configuration.active {
411
+ if tab.identifier == selectedID { activate?(configuration.closeId) }
412
+ else { activate?(tab.identifier) }
413
+ return false
414
+ }
415
+ armPendingSelection(tab.identifier)
416
+ if selectedTab !== tab { selectedTab = tab }
417
+ activate?(tab.identifier)
418
+ return true
419
+ }
420
+
421
+ private func emit(_ phase: NativeUISearchPhase) {
422
+ guard !closing, let configuration else { return }
423
+ editingSequence = changed?(configuration.id, phase, search.searchBar.text ?? "", search.searchBar.searchTextField.markedTextRange != nil, valueVersion) ?? editingSequence
424
+ }
425
+ func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { emit(.input) }
426
+ func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
427
+ if session != .idle { session = .focused }
428
+ emit(.focus)
429
+ }
430
+ func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
431
+ if session == .focused { session = .presented }
432
+ emit(.blur)
433
+ }
434
+ func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { emit(.commit) }
435
+ func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
436
+ closing = true
437
+ if let configuration { activate?(configuration.closeId) }
438
+ }
439
+ }
@@ -0,0 +1,91 @@
1
+ import UIKit
2
+
3
+ private final class NativeUISegmentElement: UIAccessibilityElement {
4
+ var activate: (() -> Bool)?
5
+ override func accessibilityActivate() -> Bool { activate?() ?? false }
6
+ }
7
+
8
+ final class NativeUISegment: UISegmentedControl {
9
+ static let kind = NativeUIComponent.segment
10
+ var labels: [String] = []
11
+ private var items: [NativeUIItem] = []
12
+ override func layoutSubviews() {
13
+ super.layoutSubviews()
14
+ let total = (0..<numberOfSegments).reduce(CGFloat.zero) { $0 + widthForSegment(at: $1) }
15
+ var x: CGFloat = 0
16
+ accessibilityElements = labels.prefix(numberOfSegments).enumerated().map { index, label in
17
+ let width = total > 0 ? bounds.width * widthForSegment(at: index) / total : bounds.width / CGFloat(numberOfSegments)
18
+ let element = NativeUISegmentElement(accessibilityContainer: self)
19
+ element.accessibilityLabel = label
20
+ element.accessibilityTraits = [.button]
21
+ if index == selectedSegmentIndex { element.accessibilityTraits.insert(.selected) }
22
+ if !isEnabledForSegment(at: index) { element.accessibilityTraits.insert(.notEnabled) }
23
+ let origin = effectiveUserInterfaceLayoutDirection == .rightToLeft ? bounds.width - x - width : x
24
+ element.accessibilityFrameInContainerSpace = CGRect(x: origin, y: 0, width: width, height: bounds.height)
25
+ element.activate = { [weak self] in
26
+ guard let self, self.isEnabledForSegment(at: index) else { return false }
27
+ self.selectedSegmentIndex = index
28
+ self.sendActions(for: .valueChanged)
29
+ self.setNeedsLayout()
30
+ return true
31
+ }
32
+ x += width
33
+ return element
34
+ }
35
+ }
36
+
37
+ @available(iOS 26.0, *)
38
+ static func make(_ node: NativeUIControl, scale: CGFloat, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UISegmentedControl? {
39
+ guard !node.items.isEmpty else { return nil }
40
+ let control = NativeUISegment(items: [])
41
+ control.isAccessibilityElement = false
42
+ control.apportionsSegmentWidthsByContent = false
43
+ control.update(node, scale: scale, rendering: rendering)
44
+ control.addAction(UIAction { [weak control] _ in
45
+ guard let control, control.items.indices.contains(control.selectedSegmentIndex) else { return }
46
+ activate(control.items[control.selectedSegmentIndex].id)
47
+ }, for: .valueChanged)
48
+ return control
49
+ }
50
+
51
+ @available(iOS 26.0, *)
52
+ func update(_ node: NativeUIControl, scale: CGFloat, rendering: NativeUIControlRendering) {
53
+ let previous = items
54
+ let rebuilt = previous.map(\.id) != node.items.map(\.id)
55
+ if rebuilt {
56
+ removeAllSegments()
57
+ for index in node.items.indices { insertSegment(withTitle: "", at: index, animated: false) }
58
+ }
59
+ if let first = node.items.first, previous.first?.content.fontSize != first.content.fontSize {
60
+ setTitleTextAttributes([.font: UIFont.systemFont(ofSize: first.content.fontSize, weight: .medium)], for: .normal)
61
+ }
62
+ let direction: UISemanticContentAttribute = node.rtl ? .forceRightToLeft : .forceLeftToRight
63
+ if semanticContentAttribute != direction { semanticContentAttribute = direction }
64
+ // Ionic button frames exclude their margins. Distribute the full track
65
+ // width so UIKit's fixed segment widths do not shrink the projected track.
66
+ let totalItemWidth = node.items.reduce(0.0) { $0 + $1.frame.width }
67
+ let widthScale = totalItemWidth > 0 ? node.frame.width / totalItemWidth * scale : scale
68
+ for (index, item) in node.items.enumerated() {
69
+ let old = rebuilt ? nil : previous[index].content
70
+ let content = item.content
71
+ if old?.label != content.label || old?.icon != content.icon ||
72
+ old?.iconWidth != content.iconWidth || old?.iconHeight != content.iconHeight ||
73
+ old?.iconTemplate != content.iconTemplate || old?.color != content.color {
74
+ let image = rendering.image(content)
75
+ setImage(image, forSegmentAt: index)
76
+ setTitle(image == nil ? content.label : nil, forSegmentAt: index)
77
+ }
78
+ if isEnabledForSegment(at: index) == content.disabled { setEnabled(!content.disabled, forSegmentAt: index) }
79
+ let width = item.frame.width * widthScale
80
+ if widthForSegment(at: index) != width { setWidth(width, forSegmentAt: index) }
81
+ }
82
+ items = node.items
83
+ labels = items.map { $0.content.accessibilityLabel }
84
+ accessibilityIdentifier = node.id
85
+ let selected = items.firstIndex { $0.content.selected } ?? UISegmentedControl.noSegment
86
+ // A native tap has already selected this index. Reassigning it can interrupt
87
+ // UIKit's in-flight lens animation when the Web selection echoes back.
88
+ if selectedSegmentIndex != selected { selectedSegmentIndex = selected }
89
+ setNeedsLayout()
90
+ }
91
+ }