@webview-native-ui/engine 1.0.1 → 1.0.2

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 (230) hide show
  1. package/README.md +14 -0
  2. package/WebViewNativeUIControls.podspec +17 -0
  3. package/dist/controls/activation.d.ts +15 -0
  4. package/dist/controls/activation.js +370 -0
  5. package/dist/controls/components/index.d.ts +16 -0
  6. package/dist/controls/components/index.js +38 -0
  7. package/dist/controls/components/ion-back-button.d.ts +4 -0
  8. package/dist/controls/components/ion-back-button.js +16 -0
  9. package/dist/controls/components/ion-button.d.ts +4 -0
  10. package/dist/controls/components/ion-button.js +26 -0
  11. package/dist/controls/components/ion-buttons.d.ts +5 -0
  12. package/dist/controls/components/ion-buttons.js +34 -0
  13. package/dist/controls/components/ion-fab.d.ts +4 -0
  14. package/dist/controls/components/ion-fab.js +164 -0
  15. package/dist/controls/components/ion-menu-button.d.ts +7 -0
  16. package/dist/controls/components/ion-menu-button.js +41 -0
  17. package/dist/controls/components/ion-segment.d.ts +5 -0
  18. package/dist/controls/components/ion-segment.js +24 -0
  19. package/dist/controls/components/ion-tab-bar.d.ts +5 -0
  20. package/dist/controls/components/ion-tab-bar.js +49 -0
  21. package/dist/controls/components/searchable-tabs.d.ts +20 -0
  22. package/dist/controls/components/searchable-tabs.js +525 -0
  23. package/dist/controls/definitions.d.ts +360 -0
  24. package/dist/controls/definitions.js +1 -0
  25. package/dist/controls/extensions.d.ts +44 -0
  26. package/dist/controls/extensions.js +164 -0
  27. package/dist/controls/foldable/fold-state.d.ts +4 -0
  28. package/dist/controls/foldable/fold-state.js +8 -0
  29. package/dist/controls/foldable/types.d.ts +9 -0
  30. package/dist/controls/foldable/types.js +1 -0
  31. package/dist/controls/html.d.ts +8 -0
  32. package/dist/controls/html.js +260 -0
  33. package/dist/controls/index.d.ts +7 -0
  34. package/dist/controls/index.js +4 -0
  35. package/dist/controls/ionic.d.ts +1 -0
  36. package/dist/controls/ionic.js +1 -0
  37. package/dist/controls/legacy-layout.d.ts +7 -0
  38. package/dist/controls/legacy-layout.js +23 -0
  39. package/dist/controls/overlays/content.d.ts +2 -0
  40. package/dist/controls/overlays/content.js +55 -0
  41. package/dist/controls/overlays/controller.d.ts +7 -0
  42. package/dist/controls/overlays/controller.js +369 -0
  43. package/dist/controls/overlays/covered-overlay.d.ts +4 -0
  44. package/dist/controls/overlays/covered-overlay.js +9 -0
  45. package/dist/controls/overlays/dialog-animation.d.ts +7 -0
  46. package/dist/controls/overlays/dialog-animation.js +49 -0
  47. package/dist/controls/overlays/dismiss-animation.d.ts +2 -0
  48. package/dist/controls/overlays/dismiss-animation.js +14 -0
  49. package/dist/controls/overlays/focus.d.ts +2 -0
  50. package/dist/controls/overlays/focus.js +67 -0
  51. package/dist/controls/overlays/popover.d.ts +10 -0
  52. package/dist/controls/overlays/popover.js +117 -0
  53. package/dist/controls/overlays/styles.d.ts +9 -0
  54. package/dist/controls/overlays/styles.js +194 -0
  55. package/dist/controls/prehide.d.ts +6 -0
  56. package/dist/controls/prehide.js +280 -0
  57. package/dist/controls/runtime.d.ts +5 -0
  58. package/dist/controls/runtime.js +1685 -0
  59. package/dist/controls/shared/bridge.d.ts +4 -0
  60. package/dist/controls/shared/bridge.js +15 -0
  61. package/dist/controls/shared/candidate.d.ts +23 -0
  62. package/dist/controls/shared/candidate.js +128 -0
  63. package/dist/controls/shared/clipping.d.ts +1 -0
  64. package/dist/controls/shared/clipping.js +1 -0
  65. package/dist/controls/shared/connection.d.ts +21 -0
  66. package/dist/controls/shared/connection.js +112 -0
  67. package/dist/controls/shared/control-options.d.ts +4 -0
  68. package/dist/controls/shared/control-options.js +14 -0
  69. package/dist/controls/shared/covered-snapshot.d.ts +35 -0
  70. package/dist/controls/shared/covered-snapshot.js +41 -0
  71. package/dist/controls/shared/covered-source.d.ts +22 -0
  72. package/dist/controls/shared/covered-source.js +72 -0
  73. package/dist/controls/shared/crossfade.d.ts +11 -0
  74. package/dist/controls/shared/crossfade.js +71 -0
  75. package/dist/controls/shared/diagnostics.d.ts +2 -0
  76. package/dist/controls/shared/diagnostics.js +16 -0
  77. package/dist/controls/shared/dom.d.ts +45 -0
  78. package/dist/controls/shared/dom.js +258 -0
  79. package/dist/controls/shared/events.d.ts +12 -0
  80. package/dist/controls/shared/events.js +13 -0
  81. package/dist/controls/shared/horizontal-navigation.d.ts +36 -0
  82. package/dist/controls/shared/horizontal-navigation.js +90 -0
  83. package/dist/controls/shared/icons.d.ts +5 -0
  84. package/dist/controls/shared/icons.js +66 -0
  85. package/dist/controls/shared/modal-animation.d.ts +21 -0
  86. package/dist/controls/shared/modal-animation.js +97 -0
  87. package/dist/controls/shared/modal.d.ts +26 -0
  88. package/dist/controls/shared/modal.js +261 -0
  89. package/dist/controls/shared/projection-id.d.ts +2 -0
  90. package/dist/controls/shared/projection-id.js +2 -0
  91. package/dist/controls/shared/scroll-placement.d.ts +1 -0
  92. package/dist/controls/shared/scroll-placement.js +1 -0
  93. package/dist/controls/shared/tab-bar-width.d.ts +7 -0
  94. package/dist/controls/shared/tab-bar-width.js +72 -0
  95. package/dist/controls/shared/toolbar-options.d.ts +10 -0
  96. package/dist/controls/shared/toolbar-options.js +38 -0
  97. package/dist/controls/shared/transition-identity.d.ts +3 -0
  98. package/dist/controls/shared/transition-identity.js +64 -0
  99. package/dist/controls/shared/vertical-bars-geometry.d.ts +8 -0
  100. package/dist/controls/shared/vertical-bars-geometry.js +82 -0
  101. package/dist/controls/shared/vertical-navigation.d.ts +5 -0
  102. package/dist/controls/shared/vertical-navigation.js +39 -0
  103. package/dist/controls/startup.d.ts +7 -0
  104. package/dist/controls/startup.js +72 -0
  105. package/dist/controls/transport.d.ts +10 -0
  106. package/dist/controls/transport.js +1 -0
  107. package/dist/controls/vertical-bars-web-search.d.ts +8 -0
  108. package/dist/controls/vertical-bars-web-search.js +83 -0
  109. package/dist/controls/vertical-bars-web.d.ts +2 -0
  110. package/dist/controls/vertical-bars-web.js +609 -0
  111. package/dist/index.d.ts +1 -0
  112. package/dist/index.js +1 -0
  113. package/dist/integration/index.d.ts +36 -0
  114. package/dist/integration/index.js +25 -0
  115. package/dist/native/session.js +14 -8
  116. package/dist/projection/pause.d.ts +10 -0
  117. package/dist/projection/pause.js +58 -0
  118. package/dist/projection/styles.d.ts +1 -1
  119. package/dist/projection/styles.js +0 -1
  120. package/dist/projection/surfaces.js +2 -1
  121. package/dist/projection/web.js +39 -5
  122. package/dist/types.d.ts +5 -1
  123. package/docs/README.md +1 -0
  124. package/docs/configuration/README.md +3 -1
  125. package/docs/configuration/ionic-overlays.md +36 -2
  126. package/docs/configuration/pause-resume.md +51 -0
  127. package/docs/controls.md +39 -0
  128. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift +98 -0
  129. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift +24 -0
  130. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIComponents.swift +19 -0
  131. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift +80 -0
  132. package/ios/Sources/WebViewNativeUIControls/Components/NativeUISearchController.swift +449 -0
  133. package/ios/Sources/WebViewNativeUIControls/Components/NativeUISegment.swift +91 -0
  134. package/ios/Sources/WebViewNativeUIControls/Components/NativeUITabBar.swift +213 -0
  135. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIToolbarTransition.swift +186 -0
  136. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalBars.swift +696 -0
  137. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalSearch.swift +117 -0
  138. package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUI.swift +180 -0
  139. package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUIRendering.swift +38 -0
  140. package/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +801 -0
  141. package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayController.swift +131 -0
  142. package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayHost.swift +85 -0
  143. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIControlRendering.swift +21 -0
  144. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICoveredSnapshots.swift +14 -0
  145. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICrossfade.swift +35 -0
  146. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIHost.swift +8 -0
  147. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIScrollTracker.swift +2 -0
  148. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUISnapshot.swift +206 -0
  149. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIVerticalBarsGeometry.swift +93 -0
  150. package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +2 -2
  151. package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +1 -1
  152. package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +3 -4
  153. package/package.json +30 -5
  154. package/src/controls/activation.ts +427 -0
  155. package/src/controls/components/index.ts +45 -0
  156. package/src/controls/components/ion-back-button.ts +22 -0
  157. package/src/controls/components/ion-button.ts +28 -0
  158. package/src/controls/components/ion-buttons.ts +37 -0
  159. package/src/controls/components/ion-fab.ts +151 -0
  160. package/src/controls/components/ion-menu-button.ts +37 -0
  161. package/src/controls/components/ion-segment.ts +27 -0
  162. package/src/controls/components/ion-tab-bar.ts +55 -0
  163. package/src/controls/components/searchable-tabs.ts +534 -0
  164. package/src/controls/definitions.ts +338 -0
  165. package/src/controls/extensions.ts +198 -0
  166. package/src/controls/foldable/fold-state.ts +10 -0
  167. package/src/controls/foldable/types.ts +4 -0
  168. package/src/controls/html.ts +239 -0
  169. package/src/controls/index.ts +7 -0
  170. package/src/controls/ionic.ts +1 -0
  171. package/src/controls/legacy-layout.ts +21 -0
  172. package/src/controls/overlays/content.ts +51 -0
  173. package/src/controls/overlays/controller.ts +394 -0
  174. package/src/controls/overlays/covered-overlay.ts +11 -0
  175. package/src/controls/overlays/dialog-animation.ts +53 -0
  176. package/src/controls/overlays/dismiss-animation.ts +15 -0
  177. package/src/controls/overlays/focus.ts +64 -0
  178. package/src/controls/overlays/popover.ts +116 -0
  179. package/src/controls/overlays/styles.ts +195 -0
  180. package/src/controls/prehide.ts +285 -0
  181. package/src/controls/runtime.ts +1731 -0
  182. package/src/controls/shared/bridge.ts +18 -0
  183. package/src/controls/shared/candidate.ts +163 -0
  184. package/src/controls/shared/clipping.ts +1 -0
  185. package/src/controls/shared/connection.ts +105 -0
  186. package/src/controls/shared/control-options.ts +20 -0
  187. package/src/controls/shared/covered-snapshot.ts +52 -0
  188. package/src/controls/shared/covered-source.ts +78 -0
  189. package/src/controls/shared/crossfade.ts +70 -0
  190. package/src/controls/shared/diagnostics.ts +13 -0
  191. package/src/controls/shared/dom.ts +310 -0
  192. package/src/controls/shared/events.ts +14 -0
  193. package/src/controls/shared/horizontal-navigation.ts +106 -0
  194. package/src/controls/shared/icons.ts +64 -0
  195. package/src/controls/shared/modal-animation.ts +109 -0
  196. package/src/controls/shared/modal.ts +286 -0
  197. package/src/controls/shared/projection-id.ts +2 -0
  198. package/src/controls/shared/scroll-placement.ts +1 -0
  199. package/src/controls/shared/tab-bar-width.ts +66 -0
  200. package/src/controls/shared/toolbar-options.ts +51 -0
  201. package/src/controls/shared/transition-identity.ts +67 -0
  202. package/src/controls/shared/vertical-bars-geometry.ts +92 -0
  203. package/src/controls/shared/vertical-navigation.ts +34 -0
  204. package/src/controls/startup.ts +74 -0
  205. package/src/controls/transport.ts +11 -0
  206. package/src/controls/vertical-bars-web-search.ts +86 -0
  207. package/src/controls/vertical-bars-web.ts +674 -0
  208. package/src/dom/geometry.ts +24 -0
  209. package/src/dom/overlays.ts +196 -0
  210. package/src/dom/scroll-bindings.ts +66 -0
  211. package/src/dom/scroll-placement.ts +92 -0
  212. package/src/dom/visibility.ts +29 -0
  213. package/src/index.ts +19 -0
  214. package/src/integration/index.ts +64 -0
  215. package/src/ionic.ts +34 -0
  216. package/src/native/bootstrap.ts +132 -0
  217. package/src/native/bridge.ts +18 -0
  218. package/src/native/resource-url.ts +11 -0
  219. package/src/native/session.ts +708 -0
  220. package/src/native/updates.ts +103 -0
  221. package/src/native/values.ts +29 -0
  222. package/src/projection/events.ts +58 -0
  223. package/src/projection/pause.ts +58 -0
  224. package/src/projection/placement.ts +34 -0
  225. package/src/projection/scope.ts +13 -0
  226. package/src/projection/styles.ts +26 -0
  227. package/src/projection/surface.ts +88 -0
  228. package/src/projection/surfaces.ts +119 -0
  229. package/src/projection/web.ts +1013 -0
  230. package/src/types.ts +58 -0
@@ -0,0 +1,19 @@
1
+ import UIKit
2
+
3
+ // Static composition of supported wire kinds. Component files own their names and rendering.
4
+ enum NativeUIComponents {
5
+ static let supported = Set(NativeUIButton.kinds + [NativeUIButtons.kind, NativeUITabBar.kind, NativeUISegment.kind, NativeUIFab.kind])
6
+
7
+ @available(iOS 26.0, *)
8
+ static func make(_ node: NativeUIControl, scale: CGFloat, rendering: NativeUIControlRendering,
9
+ tabDelegate: UITabBarDelegate, activate: @escaping (String) -> Void) -> UIView? {
10
+ switch node.kind {
11
+ case .button, .backButton, .menuButton: return NativeUIButton.make(node, rendering: rendering, activate: activate)
12
+ case .buttons: return NativeUIButtons.make(node, scale: scale, rendering: rendering, activate: activate)
13
+ case .tabBar: return NativeUITabBar.make(node, rendering: rendering, delegate: tabDelegate)
14
+ case .segment: return NativeUISegment.make(node, scale: scale, rendering: rendering, activate: activate)
15
+ case .fab: return NativeUIFab()
16
+ case .extension: return nil // App-local SwiftUI content is hosted by the shell.
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,80 @@
1
+ import UIKit
2
+
3
+ final class NativeUIFab: UIView {
4
+ static let kind = NativeUIComponent.fab
5
+ var buttons: [String: UIButton] = [:]
6
+ var content: [String: NativeUIItemContent] = [:]
7
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
8
+ let hit = super.hitTest(point, with: event)
9
+ return hit === self ? nil : hit
10
+ }
11
+
12
+ @available(iOS 26.0, *)
13
+ func apply(_ node: NativeUIControl, scale: CGFloat, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) {
14
+ let items = node.items
15
+ let retained = Set(items.map(\.id))
16
+ var lostFocus = false
17
+ for id in Array(self.buttons.keys) where !retained.contains(id) {
18
+ lostFocus = lostFocus || self.buttons[id]?.accessibilityElementIsFocused() == true
19
+ self.buttons.removeValue(forKey: id)?.removeFromSuperview()
20
+ self.content.removeValue(forKey: id)
21
+ }
22
+ self.accessibilityIdentifier = node.id
23
+ for item in items {
24
+ let id = item.id
25
+ let artwork = item.content.fabArtwork
26
+ let previous = self.buttons[id]
27
+ let button = previous ?? NativeUIButton.render(artwork, glass: true, rendering: rendering, activate: activate)
28
+ if previous == nil {
29
+ self.buttons[id] = button
30
+ self.addSubview(button)
31
+ } else if self.content[id] != artwork {
32
+ let changedIcon = self.content[id]?.selected != item.content.selected
33
+ let duration = UIAccessibility.isReduceMotionEnabled ? 0 : min(item.content.iconTransition ?? 0, 1)
34
+ if changedIcon, let imageView = button.imageView, duration > 0 {
35
+ UIView.transition(with: imageView, duration: duration,
36
+ options: [.transitionCrossDissolve, .beginFromCurrentState, .allowUserInteraction]) {
37
+ _ = NativeUIButton.render(artwork, glass: true, existing: button, rendering: rendering, activate: activate)
38
+ }
39
+ } else {
40
+ UIView.performWithoutAnimation { _ = NativeUIButton.render(artwork, glass: true, existing: button, rendering: rendering, activate: activate) }
41
+ }
42
+ }
43
+ self.content[id] = artwork
44
+ button.accessibilityTraits.remove(.selected)
45
+ if id == items.first?.id && items.count > 1 {
46
+ button.accessibilityValue = item.content.selected
47
+ ? NSLocalizedString("Expanded", comment: "FAB accessibility state")
48
+ : NSLocalizedString("Collapsed", comment: "FAB accessibility state")
49
+ } else {
50
+ button.accessibilityValue = nil
51
+ }
52
+ let hidden = item.visible == false
53
+ if hidden && !button.isHidden && button.accessibilityElementIsFocused() { lostFocus = true }
54
+ button.isHidden = hidden
55
+ button.isUserInteractionEnabled = button.isEnabled && !hidden
56
+ button.semanticContentAttribute = node.rtl ? .forceRightToLeft : .forceLeftToRight
57
+ let rect = item.frame.rect
58
+ let frame = CGRect(x: rect.minX * scale, y: rect.minY * scale, width: rect.width * scale, height: rect.height * scale)
59
+ if button.frame != frame { button.frame = frame }
60
+ }
61
+ let ordered = items.compactMap { self.buttons[$0.id] }.filter { !$0.isHidden }
62
+ self.accessibilityElements = ordered
63
+ if lostFocus, let main = ordered.first, main.isEnabled, self.window != nil {
64
+ UIAccessibility.post(notification: .layoutChanged, argument: main)
65
+ }
66
+ }
67
+ }
68
+
69
+ extension NativeUIItemContent {
70
+ var fabArtwork: Self {
71
+ var artwork = self
72
+ if selected {
73
+ artwork.icon = closeIcon
74
+ artwork.iconWidth = closeIconWidth
75
+ artwork.iconHeight = closeIconHeight
76
+ artwork.label = ""
77
+ }
78
+ return artwork
79
+ }
80
+ }
@@ -0,0 +1,449 @@
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
+ // swiftlint:disable:next implicitly_unwrapped_optional
42
+ override func responds(to selector: Selector!) -> Bool {
43
+ super.responds(to: selector) || original?.responds(to: selector) == true
44
+ }
45
+ // swiftlint:disable:next implicitly_unwrapped_optional
46
+ override func forwardingTarget(for selector: Selector!) -> Any? { original }
47
+ func textFieldShouldClear(_ textField: UITextField) -> Bool { clear?(); return false }
48
+ }
49
+
50
+ // Lets the plugin registry retain iOS 26-gated search controllers without
51
+ // availability-gated stored properties, mirroring NativeUIVerticalBarsControlling.
52
+ protocol NativeUISearchControlling: AnyObject {
53
+ var surface: NativeUISearchHost { get }
54
+ var ownsKeyboard: Bool { get }
55
+ var restingTabWidth: CGFloat? { get }
56
+ var ownsKeyboardChrome: Bool { get }
57
+ var transitionCoordinator: UIViewControllerTransitionCoordinator? { get }
58
+ var activate: ((String) -> Void)? { get set }
59
+ var changed: ((String, NativeUISearchPhase, String, Bool, Int) -> Int)? { get set }
60
+ func attach(to parent: UIViewController, in container: UIView)
61
+ func detach()
62
+ func apply(_ snapshot: NativeUIControl, webFrame: CGRect, barFrame: CGRect, triggerFrame: CGRect, rendering: NativeUIControlRendering) -> Bool
63
+ }
64
+
65
+ @available(iOS 26.0, *)
66
+ final class NativeUISearchController: UITabBarController, UITabBarControllerDelegate, UISearchBarDelegate, NativeUISearchControlling {
67
+ // Wire stays active+focused; local session drives chrome (idle / presented / focused).
68
+ private enum Session: Equatable { case idle, presented, focused }
69
+
70
+ let surface = NativeUISearchHost()
71
+ private var restingBarFrame: CGRect?
72
+ private var restingBarAnchor: NativeUITabBar.Anchor?
73
+ private var fittingTabBar = false
74
+ private var measuredTabWidth: CGFloat?
75
+ var restingTabWidth: CGFloat? { session == .idle ? measuredTabWidth : nil }
76
+ private let search = UISearchController(searchResultsController: nil)
77
+ private let inputDelegate = NativeUISearchInputDelegate()
78
+ // swiftlint:disable:next implicitly_unwrapped_optional
79
+ private var searchTab: UISearchTab!
80
+ private var ordinary: [String: UITab] = [:]
81
+ private var configuration: NativeUISearch?
82
+ private var selectedID = ""
83
+ private var closing = false
84
+ private var editingSequence = 0
85
+ private var valueVersion = -1
86
+ private var lockedWebFrame: CGRect? // frozen while search is active (width changes re-lock)
87
+ private var session: Session = .idle
88
+ private var focusWork: DispatchWorkItem?
89
+ private var pendingSelection: NativeUITabBar.PendingSelection? // optimistic ordinary tab
90
+ private var pendingExpiryWork: DispatchWorkItem?
91
+ var ownsKeyboard: Bool { search.searchBar.searchTextField.isFirstResponder }
92
+ var ownsKeyboardChrome: Bool { session == .focused || ownsKeyboard }
93
+ var activate: ((String) -> Void)?
94
+ var changed: ((String, NativeUISearchPhase, String, Bool, Int) -> Int)?
95
+
96
+ override func viewDidLoad() {
97
+ super.viewDidLoad()
98
+ view.backgroundColor = .clear
99
+ view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
100
+ surface.backgroundColor = .clear
101
+ surface.autoresizingMask = [.flexibleWidth, .flexibleHeight]
102
+ delegate = self
103
+ mode = .tabBar
104
+ NativeUITabBar.configureLayout(tabBar)
105
+ search.obscuresBackgroundDuringPresentation = false
106
+ search.hidesNavigationBarDuringPresentation = false
107
+ search.searchBar.delegate = self
108
+ search.searchBar.autocapitalizationType = .none
109
+ search.searchBar.autocorrectionType = .no
110
+ search.searchBar.spellCheckingType = .no
111
+ search.searchBar.searchTextField.clearButtonMode = .always
112
+ searchTab = UISearchTab { [weak self] _ in
113
+ let child = UIViewController()
114
+ child.view.backgroundColor = .clear
115
+ child.definesPresentationContext = true
116
+ child.navigationItem.searchController = self?.search
117
+ child.navigationItem.hidesSearchBarWhenScrolling = false
118
+ child.navigationItem.preferredSearchBarPlacement = .integrated
119
+ let navigation = UINavigationController(rootViewController: child)
120
+ navigation.view.backgroundColor = .clear
121
+ return navigation
122
+ }
123
+ searchTab.automaticallyActivatesSearch = false
124
+ inputDelegate.clear = { [weak self] in self?.emit(.clear) }
125
+ }
126
+
127
+ deinit {
128
+ focusWork?.cancel()
129
+ pendingExpiryWork?.cancel()
130
+ }
131
+
132
+ private func wantedSession(active: Bool, focused: Bool) -> Session {
133
+ if !active { return .idle }
134
+ return focused ? .focused : .presented
135
+ }
136
+
137
+ private func endEditing() {
138
+ focusWork?.cancel()
139
+ focusWork = nil
140
+ if search.searchBar.searchTextField.isFirstResponder {
141
+ search.searchBar.searchTextField.resignFirstResponder()
142
+ }
143
+ if search.isActive { search.isActive = false }
144
+ }
145
+
146
+ private func applySession(_ wanted: Session, selectingSearchTab: Bool) {
147
+ if wanted == session && !selectingSearchTab { return }
148
+ if wanted == .idle {
149
+ endEditing()
150
+ session = .idle
151
+ return
152
+ }
153
+ if selectingSearchTab || selectedTab !== searchTab { selectedTab = searchTab }
154
+ if wanted == .presented {
155
+ if session == .focused { endEditing() }
156
+ session = .presented
157
+ return
158
+ }
159
+ session = .focused
160
+ if !search.isActive { search.isActive = true }
161
+ guard !search.searchBar.searchTextField.isFirstResponder, focusWork == nil else { return }
162
+ let work = DispatchWorkItem { [weak self] in
163
+ guard let self else { return }
164
+ self.focusWork = nil
165
+ guard self.session == .focused, !self.search.searchBar.searchTextField.isFirstResponder else { return }
166
+ if !self.search.isActive { self.search.isActive = true }
167
+ _ = self.search.searchBar.searchTextField.becomeFirstResponder()
168
+ }
169
+ focusWork = work
170
+ DispatchQueue.main.async(execute: work)
171
+ }
172
+
173
+ private func armPendingSelection(_ id: String) {
174
+ pendingSelection = .start(id)
175
+ schedulePendingExpiry()
176
+ }
177
+
178
+ private func clearPendingSelection() {
179
+ pendingExpiryWork?.cancel()
180
+ pendingExpiryWork = nil
181
+ pendingSelection = nil
182
+ }
183
+
184
+ private func schedulePendingExpiry() {
185
+ pendingExpiryWork?.cancel()
186
+ pendingExpiryWork = nil
187
+ guard let pending = pendingSelection else { return }
188
+ let delay = max(0, pending.until - CFAbsoluteTimeGetCurrent()) + 0.02
189
+ let work = DispatchWorkItem { [weak self] in
190
+ guard let self else { return }
191
+ self.pendingExpiryWork = nil
192
+ guard let pending = self.pendingSelection, CFAbsoluteTimeGetCurrent() >= pending.until else { return }
193
+ self.pendingSelection = nil
194
+ // Revert to the last DOM selection if Ionic did not accept the action.
195
+ if let selected = self.ordinary[self.selectedID], self.selectedTab !== selected {
196
+ self.selectedTab = selected
197
+ }
198
+ }
199
+ pendingExpiryWork = work
200
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
201
+ }
202
+
203
+ private func resolveOrdinarySelection(_ items: [NativeUIItem], fallback: UITab?) {
204
+ let domSelected = ordinary[selectedID] ?? fallback
205
+ guard let pending = pendingSelection else {
206
+ pendingExpiryWork?.cancel()
207
+ pendingExpiryWork = nil
208
+ if selectedTab !== domSelected { selectedTab = domSelected }
209
+ return
210
+ }
211
+ let pendingTab = ordinary[pending.id]
212
+ let expired = CFAbsoluteTimeGetCurrent() >= pending.until
213
+ let unavailable = pendingTab == nil || (items.first { $0.id == pending.id }?.content.disabled ?? true)
214
+ if unavailable || expired {
215
+ clearPendingSelection()
216
+ selectedTab = domSelected
217
+ } else if selectedID == pending.id {
218
+ clearPendingSelection()
219
+ if selectedTab !== domSelected { selectedTab = domSelected }
220
+ } else if selectedTab !== pendingTab {
221
+ selectedTab = pendingTab
222
+ }
223
+ }
224
+
225
+ func attach(to parent: UIViewController, in container: UIView) {
226
+ parent.addChild(self)
227
+ container.addSubview(surface)
228
+ surface.addSubview(view)
229
+ didMove(toParent: parent)
230
+ }
231
+
232
+ func detach() {
233
+ closing = true
234
+ lockedWebFrame = nil
235
+ clearPendingSelection()
236
+ applySession(.idle, selectingSearchTab: false)
237
+ willMove(toParent: nil)
238
+ restingBarFrame = nil
239
+ surface.removeFromSuperview()
240
+ view.removeFromSuperview()
241
+ removeFromParent()
242
+ }
243
+
244
+ func apply(_ snapshot: NativeUIControl, webFrame: CGRect, barFrame: CGRect, triggerFrame: CGRect,
245
+ rendering: NativeUIControlRendering) -> Bool {
246
+ loadViewIfNeeded()
247
+ guard let configuration = snapshot.search else { return false }
248
+ if self.configuration?.id != configuration.id {
249
+ editingSequence = 0
250
+ valueVersion = -1
251
+ }
252
+ let wasActive = self.configuration.map { $0.available && $0.active } ?? false
253
+ self.configuration = configuration
254
+ let available = configuration.available
255
+ let active = available && configuration.active
256
+ let wanted = wantedSession(active: active, focused: configuration.focused)
257
+ tabBar.tintColor = snapshot.tabTint.map { rendering.color($0) }
258
+ let items = snapshot.items
259
+ let ids = items.map(\.id)
260
+ for id in Array(ordinary.keys) where !ids.contains(id) { ordinary.removeValue(forKey: id) }
261
+ for item in items {
262
+ let id = item.id
263
+ let tab = ordinary[id] ?? UITab(title: "", image: nil, identifier: id) { _ in
264
+ let child = UIViewController()
265
+ child.view.backgroundColor = .clear
266
+ return child
267
+ }
268
+ tab.accessibilityIdentifier = id
269
+ tab.title = item.content.label
270
+ tab.image = rendering.image(item.content)
271
+ tab.badgeValue = item.content.badge?.value
272
+ tab.isEnabled = !item.content.disabled
273
+ ordinary[id] = tab
274
+ if item.content.selected { selectedID = id }
275
+ }
276
+ let trigger = configuration.trigger
277
+ searchTab.accessibilityIdentifier = trigger.id
278
+ searchTab.image = rendering.image(trigger.content)
279
+ searchTab.isEnabled = !configuration.disabled
280
+ // swiftlint:disable:next force_unwrapping
281
+ let requested = ids.compactMap { ordinary[$0] } + (available ? [searchTab!] : [])
282
+ if !active, tabs.isEmpty || tabs.map(\.identifier) != requested.map(\.identifier) {
283
+ tabs = requested
284
+ }
285
+ for item in items {
286
+ if let nativeItem = ordinary[item.id]?.viewController?.tabBarItem {
287
+ nativeItem.accessibilityLabel = item.content.accessibilityLabel
288
+ NativeUITabBar.applyTypography(item.content, to: nativeItem)
289
+ NativeUITabBar.applyBadge(item.content.badge, to: nativeItem, rendering: rendering)
290
+ }
291
+ }
292
+ searchTab.viewController?.tabBarItem.accessibilityLabel = trigger.content.accessibilityLabel
293
+ let prominent = NSSelectorFromString("setProminentTabIdentifier:")
294
+ if responds(to: prominent) { setValue(available ? searchTab.identifier : nil, forKey: "prominentTabIdentifier") }
295
+ let field = configuration.field
296
+ search.searchBar.searchTextField.accessibilityIdentifier = configuration.id
297
+ search.searchBar.searchTextField.accessibilityLabel = field.accessibilityLabel
298
+ search.searchBar.setImage(rendering.image(field), for: .search, state: .normal)
299
+ search.searchBar.placeholder = configuration.placeholder
300
+ search.searchBar.searchTextField.isEnabled = !configuration.disabled
301
+ let nextValueVersion = configuration.valueVersion
302
+ if nextValueVersion != valueVersion || configuration.editSequence >= editingSequence {
303
+ valueVersion = nextValueVersion
304
+ let value = configuration.value
305
+ if search.searchBar.text != value { search.searchBar.text = value }
306
+ }
307
+ if search.searchBar.searchTextField.delegate !== inputDelegate {
308
+ inputDelegate.original = search.searchBar.searchTextField.delegate
309
+ search.searchBar.searchTextField.delegate = inputDelegate
310
+ }
311
+ // Resting keeps closing=true so hidden UISearchBar noise cannot emit; clear it while active.
312
+ closing = !active
313
+ surface.isHidden = false
314
+ surface.overrideUserInterfaceStyle = snapshot.dark ? .dark : .light
315
+ if active {
316
+ // UISearchTab session owns the bar + keyboard; freeze Web layout for the session.
317
+ if tabs.isEmpty || tabs.map(\.identifier) != requested.map(\.identifier) {
318
+ tabs = requested
319
+ }
320
+ restingBarFrame = nil
321
+ view.frame = surface.bounds
322
+ let nextLock = surface.bounds.isEmpty ? webFrame : surface.frame
323
+ if let locked = lockedWebFrame, abs(locked.width - webFrame.width) > 0.5 {
324
+ // Rotation / size-class change: adopt the new width while still ignoring keyboard shrink.
325
+ lockedWebFrame = webFrame
326
+ } else if lockedWebFrame == nil {
327
+ lockedWebFrame = nextLock
328
+ }
329
+ if let lockedWebFrame, surface.frame != lockedWebFrame { surface.frame = lockedWebFrame }
330
+ clearPendingSelection()
331
+ applySession(wanted, selectingSearchTab: !wasActive)
332
+ return true
333
+ }
334
+
335
+ surface.frame = webFrame
336
+ view.semanticContentAttribute = snapshot.rtl ? .forceRightToLeft : .forceLeftToRight
337
+ tabBar.semanticContentAttribute = snapshot.rtl ? .forceRightToLeft : .forceLeftToRight
338
+ tabBar.accessibilityIdentifier = snapshot.id
339
+ if lockedWebFrame != nil {
340
+ lockedWebFrame = nil
341
+ }
342
+ applySession(.idle, selectingSearchTab: false)
343
+
344
+ view.frame = surface.bounds
345
+ resolveOrdinarySelection(items, fallback: requested.first)
346
+ restingBarFrame = surface.convert(barFrame, from: surface.superview)
347
+ restingBarAnchor = snapshot.tabBarAnchor
348
+ view.setNeedsLayout()
349
+ view.layoutIfNeeded()
350
+ return fitRestingTabBar()
351
+ }
352
+
353
+ override func viewDidLayoutSubviews() {
354
+ super.viewDidLayoutSubviews()
355
+ if session == .idle { _ = fitRestingTabBar() }
356
+ }
357
+
358
+ /// Match the ordinary tab platter to the source ion-tab-bar by resizing
359
+ /// the controller's view, so UIKit can keep managing its tab bar through
360
+ /// search expansion and dismissal.
361
+ private func fitRestingTabBar() -> Bool {
362
+ guard let bounds = restingBarFrame, !fittingTabBar else { return true }
363
+ fittingTabBar = true
364
+ defer { fittingTabBar = false }
365
+ tabBar.layoutIfNeeded()
366
+ weak var ordinaryRegion: UIView?
367
+ for _ in 0..<2 {
368
+ let regions = NativeUITabBar.contentViews(tabBar)
369
+ func controlCount(_ view: UIView) -> Int {
370
+ (view is UIControl ? 1 : 0) + view.subviews.reduce(0) { $0 + controlCount($1) }
371
+ }
372
+ // Ordinary tabs contain their item controls; Search contains one.
373
+ // For a single tab, UIKit places ordinary tabs at the leading edge.
374
+ let rtl = tabBar.effectiveUserInterfaceLayoutDirection == .rightToLeft
375
+ guard let region = regions.max(by: {
376
+ let lhs = controlCount($0), rhs = controlCount($1)
377
+ if lhs != rhs { return lhs < rhs }
378
+ return rtl ? $0.frame.midX < $1.frame.midX : $0.frame.midX > $1.frame.midX
379
+ }) else { return false }
380
+ let content = region.frame
381
+ ordinaryRegion = region
382
+ let contents = regions.map { $0.frame }
383
+ let measured = tabBar.convert(content, to: surface)
384
+ let width = bounds.width - measured.width
385
+ let x = bounds.minX + (bounds.width - measured.width) * (restingBarAnchor?.x ?? 0) - measured.minX
386
+ let y = bounds.minY + (bounds.height - measured.height) * (restingBarAnchor?.y ?? 0) - measured.minY
387
+ // UIKit rounds the platter frame; a subpoint difference must not
388
+ // keep invalidating its layout during search transitions.
389
+ if abs(width) <= 0.5 && abs(x) <= 0.5 && abs(y) <= 0.5 { break }
390
+ var frame = view.frame
391
+ // The controller also contains the separate search button. Include
392
+ // that space in its width limit without enlarging capped tab groups.
393
+ let searchWidth = contents.filter { $0 != content }.reduce(CGFloat(0)) { $0 + $1.width }
394
+ frame.size.width = min(frame.width + width, surface.bounds.width + searchWidth)
395
+ frame.origin.x += x
396
+ frame.origin.y += y
397
+ if abs(frame.width - view.frame.width) <= 0.5 && abs(x) <= 0.5 && abs(y) <= 0.5 { break }
398
+ view.frame = frame
399
+ view.setNeedsLayout()
400
+ view.layoutIfNeeded()
401
+ }
402
+ measuredTabWidth = ordinaryRegion?.frame.width
403
+ // Fitting the ordinary platter can move UIKit's separate Search control
404
+ // outside the WebView, especially for centered or trailing source bars.
405
+ // Reject that layout so Search keeps its usable Web fallback.
406
+ let visibleBounds = surface.bounds.insetBy(dx: -0.5, dy: -0.5)
407
+ let regions = NativeUITabBar.contentViews(tabBar)
408
+ return !regions.isEmpty && regions.allSatisfy {
409
+ visibleBounds.contains(tabBar.convert($0.frame, to: surface))
410
+ }
411
+ }
412
+
413
+ func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool {
414
+ guard let configuration else { return false }
415
+ if tab === searchTab {
416
+ restingBarFrame = nil
417
+ clearPendingSelection()
418
+ activate?(configuration.trigger.id)
419
+ return true
420
+ }
421
+ if configuration.active {
422
+ if tab.identifier == selectedID { activate?(configuration.closeId) } else { activate?(tab.identifier) }
423
+ return false
424
+ }
425
+ armPendingSelection(tab.identifier)
426
+ if selectedTab !== tab { selectedTab = tab }
427
+ activate?(tab.identifier)
428
+ return true
429
+ }
430
+
431
+ private func emit(_ phase: NativeUISearchPhase) {
432
+ guard !closing, let configuration else { return }
433
+ editingSequence = changed?(configuration.id, phase, search.searchBar.text ?? "", search.searchBar.searchTextField.markedTextRange != nil, valueVersion) ?? editingSequence
434
+ }
435
+ func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { emit(.input) }
436
+ func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
437
+ if session != .idle { session = .focused }
438
+ emit(.focus)
439
+ }
440
+ func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
441
+ if session == .focused { session = .presented }
442
+ emit(.blur)
443
+ }
444
+ func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { emit(.commit) }
445
+ func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
446
+ closing = true
447
+ if let configuration { activate?(configuration.closeId) }
448
+ }
449
+ }
@@ -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
+ }