@webview-native-ui/engine 1.0.1 → 1.0.2-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,696 @@
1
+ import Combine
2
+ import SwiftUI
3
+ import UIKit
4
+
5
+ protocol NativeUIVerticalBarsControlling: AnyObject {
6
+ var view: UIView { get }
7
+ func measuredFrames(relativeTo webView: UIView) -> [String: (frame: CGRect, cornerRadius: CGFloat)]
8
+ @MainActor func attach(to owner: UIViewController, in parent: UIView)
9
+ @MainActor func apply(_ controls: [NativeUIControl], rendering: NativeUIControlRendering, edge: String, animated: Bool, accessory: AnyView?, navigation: [String], navigationPhase: String?)
10
+ var ownsKeyboardChrome: Bool { get }
11
+ func presentPopover(anchor: String, host: NativeUIOverlayHost, completion: @escaping (Bool) -> Void) -> Bool
12
+ @MainActor func detach(duration: TimeInterval)
13
+ }
14
+
15
+ @available(iOS 26.0, *)
16
+ final class NativeUIVerticalBarsModel: ObservableObject {
17
+ struct Item: Identifiable, Equatable {
18
+ let id: String
19
+ let transitionID: String
20
+ let label: String
21
+ let accessibilityLabel: String
22
+ let image: UIImage?
23
+ let clear: Bool
24
+ let background: Color?
25
+ let borderColor: Color?
26
+ let borderWidth: CGFloat
27
+ let badge: NativeUIBadge?
28
+ let disabled: Bool
29
+ var isBack = false
30
+ var selected: Bool
31
+ }
32
+
33
+ struct Group: Identifiable, Equatable {
34
+ let id: String
35
+ let transitionID: String
36
+ let items: [Item]
37
+ let slot: NativeUIControl.ToolbarSlot?
38
+ }
39
+
40
+ @Published var accessory: AnyView?
41
+ @Published var popoverAnchor: String?
42
+ var popoverHost: NativeUIOverlayHost?
43
+ var popoverPresented: ((Bool) -> Void)?
44
+ @Published var search: NativeUIVerticalSearchModel?
45
+ @Published var navigation: [String] = []
46
+ private var pageGroups: [String: [Group]] = [:]
47
+ @Published var groups: [Group] = []
48
+ @Published var tabTint: String?
49
+ @Published var tabs: [Item] = []
50
+ @Published var selection = ""
51
+ var activate: (String) -> Void = { _ in }
52
+ private var pendingSelection: NativeUITabBar.PendingSelection?
53
+ private var pendingExpiryWork: DispatchWorkItem?
54
+
55
+ func apply(_ controls: [NativeUIControl], rendering: NativeUIControlRendering, animated: Bool = true,
56
+ accessory: AnyView? = nil, navigation: [String] = [], now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) {
57
+ func item(_ source: NativeUIItem, isBack: Bool = false) -> Item {
58
+ let image = rendering.image(source.content)
59
+ return Item(id: source.id, transitionID: source.content.transitionId ?? source.id, label: source.content.label,
60
+ accessibilityLabel: source.content.accessibilityLabel,
61
+ // UIKit toolbar extraction uses the UIImage's rendering mode.
62
+ image: source.content.disabled ? image?.withRenderingMode(.alwaysTemplate) : image,
63
+ clear: source.content.buttonFill == .clear,
64
+ background: source.content.backgroundColor.map { Color(uiColor: rendering.color($0)) },
65
+ borderColor: source.content.borderColor.map { Color(uiColor: rendering.color($0)) },
66
+ borderWidth: source.content.borderWidth ?? 0,
67
+ badge: source.content.badge, disabled: source.content.disabled, isBack: isBack,
68
+ selected: source.content.selected)
69
+ }
70
+ var transaction = Transaction(animation: animated && !UIAccessibility.isReduceMotionEnabled ? .default : nil)
71
+ // A newly created system tab host must not animate its selection shape.
72
+ transaction.disablesAnimations = !animated
73
+ withTransaction(transaction) {
74
+ if self.navigation != navigation {
75
+ if let page = self.navigation.last { pageGroups[page] = groups }
76
+ let retained = Set(self.navigation + navigation)
77
+ pageGroups = pageGroups.filter { retained.contains($0.key) }
78
+ self.navigation = navigation
79
+ }
80
+ if self.accessory != nil || accessory != nil { self.accessory = accessory }
81
+ let groups: [Group] = controls.compactMap { control in
82
+ guard [.button, .buttons, .menuButton, .backButton].contains(control.kind) else { return nil }
83
+ let items = control.items.map { item($0, isBack: control.kind == .backButton) }
84
+ return Group(id: control.id, transitionID: control.transitionId ?? control.id, items: items,
85
+ slot: control.toolbarSlot ?? (control.kind == .backButton ? .start : .end))
86
+ }
87
+ if self.groups != groups { self.groups = groups }
88
+ // Keep all snapshot publications in this transaction. A later unanimated
89
+ // accessory/tab publication can cancel the toolbar animation.
90
+ let tabBar = controls.first(where: { $0.kind == .tabBar })
91
+ if tabTint != tabBar?.tabTint { tabTint = tabBar?.tabTint }
92
+ let tabs = tabBar?.items.map { item($0) } ?? []
93
+ if self.tabs != tabs { self.tabs = tabs }
94
+ reconcileSelection(now: now)
95
+ }
96
+ }
97
+
98
+ func groups(on page: String?) -> [Group] {
99
+ page == navigation.last ? groups : page.flatMap { pageGroups[$0] } ?? []
100
+ }
101
+
102
+ func select(_ id: String, now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent(), ttl: CFTimeInterval = 1) {
103
+ guard let item = tabs.first(where: { $0.id == id }), !item.disabled else { return }
104
+ pendingSelection = .init(id: id, until: now + ttl)
105
+ selection = id
106
+ pendingExpiryWork?.cancel()
107
+ let work = DispatchWorkItem { [weak self] in self?.reconcileSelection() }
108
+ pendingExpiryWork = work
109
+ DispatchQueue.main.asyncAfter(deadline: .now() + ttl + 0.02, execute: work)
110
+ activate(id)
111
+ }
112
+
113
+ private func reconcileSelection(now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) {
114
+ let selection = NativeUITabBar.PendingSelection.reconcile(&pendingSelection,
115
+ domSelection: tabs.first(where: \.selected)?.id,
116
+ isAvailable: { id in tabs.contains { $0.id == id && !$0.disabled } }, now: now) ?? ""
117
+ if self.selection != selection { self.selection = selection }
118
+ if pendingSelection == nil {
119
+ pendingExpiryWork?.cancel()
120
+ pendingExpiryWork = nil
121
+ }
122
+ }
123
+ }
124
+
125
+ @available(iOS 26.0, *)
126
+ private struct NativeUIVerticalBarsLabel: View {
127
+ let item: NativeUIVerticalBarsModel.Item
128
+
129
+ var body: some View {
130
+ if let image = item.isBack ? Image(systemName: "chevron.backward") : item.image.map({ Image(uiImage: $0) }) {
131
+ Label {
132
+ // Back remains icon-only on horizontal fallback bars as well.
133
+ Text(item.isBack ? "" : item.label.isEmpty ? item.accessibilityLabel : item.label)
134
+ } icon: {
135
+ image
136
+ }
137
+ } else {
138
+ Text(item.label)
139
+ }
140
+ }
141
+ }
142
+
143
+ @available(iOS 26.0, *)
144
+ @ViewBuilder
145
+ private func verticalBarsButton(_ item: NativeUIVerticalBarsModel.Item, model: NativeUIVerticalBarsModel) -> some View {
146
+ let button = Button { model.activate(item.id) } label: { NativeUIVerticalBarsLabel(item: item) }
147
+ .disabled(item.disabled)
148
+ .accessibilityLabel(item.accessibilityLabel)
149
+ .accessibilityIdentifier(item.isBack ? "BackButton" : item.id)
150
+ .background {
151
+ if item.isBack { NativeUIVerticalBarGeometryProbe() }
152
+ }
153
+ if let background = item.background {
154
+ button.buttonStyle(.glassProminent).tint(background)
155
+ } else {
156
+ button.overlay {
157
+ if let borderColor = item.borderColor {
158
+ Capsule().strokeBorder(borderColor, lineWidth: item.borderWidth).allowsHitTesting(false)
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ @available(iOS 26.0, *)
165
+ private struct NativeUIVerticalPopoverContent: UIViewControllerRepresentable {
166
+ let host: NativeUIOverlayHost
167
+ func makeUIViewController(context: Context) -> NativeUIOverlayHost { host }
168
+ func updateUIViewController(_ controller: NativeUIOverlayHost, context: Context) {}
169
+ }
170
+
171
+ @available(iOS 26.0, *)
172
+ private struct NativeUIVerticalBarsButton: ToolbarContent {
173
+ @ObservedObject var model: NativeUIVerticalBarsModel
174
+ let page: String?
175
+ let groupID: String
176
+ let id: String
177
+ let placement: ToolbarItemPlacement
178
+
179
+ // Observe here so same-ID updates also refresh the shared toolbar background.
180
+ var body: some ToolbarContent {
181
+ if let group = model.groups(on: page).first(where: { $0.transitionID == groupID }),
182
+ let item = group.items.first(where: { $0.transitionID == id }) {
183
+ ToolbarItem(id: item.transitionID, placement: placement) {
184
+ verticalBarsButton(item, model: model)
185
+ .popover(isPresented: Binding(
186
+ get: { model.popoverAnchor == item.id },
187
+ set: { shown in
188
+ if !shown && model.popoverAnchor == item.id {
189
+ model.popoverAnchor = nil
190
+ }
191
+ }
192
+ )) {
193
+ if let host = model.popoverHost {
194
+ NativeUIVerticalPopoverContent(host: host)
195
+ .frame(width: host.preferredContentSize.width, height: host.preferredContentSize.height)
196
+ .preferredColorScheme(host.options?["dark"] as? Bool == true ? .dark : .light)
197
+ .presentationCompactAdaptation(.popover)
198
+ .interactiveDismissDisabled(host.options?["backdropDismiss"] as? Bool == false)
199
+ .onAppear {
200
+ guard model.popoverHost === host else { return }
201
+ host.anchoredVisible = true
202
+ let completion = model.popoverPresented
203
+ model.popoverPresented = nil
204
+ completion?(true)
205
+ }
206
+ .onDisappear {
207
+ if model.popoverHost === host {
208
+ model.popoverAnchor = nil
209
+ model.popoverHost = nil
210
+ }
211
+ host.presentationDidDisappear()
212
+ }
213
+ }
214
+ }
215
+ }
216
+ .sharedBackgroundVisibility(item.clear ? .hidden : .automatic)
217
+ }
218
+ }
219
+ }
220
+
221
+ private struct NativeUIVerticalBarGeometryProbe: UIViewRepresentable {
222
+ func makeUIView(context: Context) -> UIView {
223
+ let view = UIView()
224
+ view.isUserInteractionEnabled = false
225
+ view.accessibilityIdentifier = "BackButton"
226
+ return view
227
+ }
228
+ func updateUIView(_ uiView: UIView, context: Context) {}
229
+ }
230
+
231
+ @available(iOS 26.0, *)
232
+ private struct NativeUIVerticalBarsBadge: ViewModifier {
233
+ let badge: NativeUIBadge?
234
+
235
+ @ViewBuilder func body(content: Content) -> some View {
236
+ if let badge { content.badge(badge.value) } else { content }
237
+ }
238
+ }
239
+
240
+ @available(iOS 26.0, *)
241
+ private struct NativeUIVerticalBarsView: View {
242
+ @ObservedObject var model: NativeUIVerticalBarsModel
243
+
244
+ var body: some View {
245
+ Group {
246
+ if model.tabs.isEmpty {
247
+ NativeUIVerticalBarsPage(model: model)
248
+ } else {
249
+ TabView(selection: Binding(get: { model.selection }, set: { model.select($0) })) {
250
+ ForEach(model.tabs) { item in
251
+ NativeUIVerticalBarsPage(model: model)
252
+ // Keep toolbar and search accents independent of the tab selection.
253
+ .tint(nil)
254
+ .tag(item.id)
255
+ .tabItem { NativeUIVerticalBarsLabel(item: item) }
256
+ .modifier(NativeUIVerticalBarsBadge(badge: item.badge))
257
+ .disabled(item.disabled)
258
+ .accessibilityLabel(item.accessibilityLabel)
259
+ .accessibilityIdentifier(item.id)
260
+ }
261
+ }
262
+ .tint(model.tabTint.map { Color(uiColor: NativeUIRendering.color($0)) })
263
+ .modifier(NativeUIExtensionAccessory(content: model.accessory))
264
+ }
265
+ }
266
+ .modifier(NativeUIVerticalBarsSearch(model: model))
267
+ .modifier(NativeUIVerticalBarsCompression())
268
+ .background(Color.clear)
269
+ }
270
+ }
271
+
272
+ @available(iOS 26.0, *)
273
+ private struct NativeUIVerticalBarsPage: View {
274
+ @ObservedObject var model: NativeUIVerticalBarsModel
275
+
276
+ var body: some View {
277
+ NavigationStack(path: .constant(Array(model.navigation.dropFirst()))) {
278
+ page(model.navigation.first)
279
+ .navigationDestination(for: String.self) { page($0) }
280
+ }
281
+ .id(model.navigation.first)
282
+ }
283
+
284
+ private func page(_ id: String?) -> some View {
285
+ NativeUIVerticalBarsContent().modifier(NativeUIVerticalBarsToolbarAdapter(model: model, page: id))
286
+ .navigationBarBackButtonHidden(true)
287
+ .toolbar {
288
+ if model.search?.configuration.available == true {
289
+ DefaultToolbarItem(kind: .search, placement: .bottomBar)
290
+ }
291
+ }
292
+ }
293
+ }
294
+
295
+ // Only this library-owned empty page suppresses UIKit's content slide.
296
+ // App-local extension views may contain their own, fully visible navigation stacks.
297
+ @available(iOS 26.0, *)
298
+ private struct NativeUIVerticalBarsContent: UIViewControllerRepresentable {
299
+ final class Controller: UIViewController {
300
+ override func viewDidLayoutSubviews() {
301
+ super.viewDidLayoutSubviews()
302
+ suppressContentTransition()
303
+ }
304
+ override func viewWillAppear(_ animated: Bool) {
305
+ super.viewWillAppear(animated)
306
+ suppressContentTransition()
307
+ }
308
+ override func viewWillDisappear(_ animated: Bool) {
309
+ super.viewWillDisappear(animated)
310
+ suppressContentTransition()
311
+ }
312
+ private func suppressContentTransition() {
313
+ guard let transition = transitionCoordinator,
314
+ transition.presentationStyle == .none,
315
+ !transition.containerView.isHidden else { return }
316
+ let content = transition.containerView
317
+ content.isHidden = true
318
+ if !transition.animate(alongsideTransition: nil, completion: { _ in content.isHidden = false }) {
319
+ content.isHidden = false
320
+ }
321
+ }
322
+ }
323
+ func makeUIViewController(context: Context) -> Controller {
324
+ let controller = Controller()
325
+ controller.view.backgroundColor = .clear
326
+ return controller
327
+ }
328
+ func updateUIViewController(_ controller: Controller, context: Context) {}
329
+ }
330
+
331
+ @available(iOS 26.0, *)
332
+ private struct NativeUIVerticalBarsSearch: ViewModifier {
333
+ @ObservedObject var model: NativeUIVerticalBarsModel
334
+ @ViewBuilder func body(content: Content) -> some View {
335
+ if let search = model.search {
336
+ content.modifier(NativeUIVerticalSearchModifier(model: search))
337
+ } else { content }
338
+ }
339
+ }
340
+
341
+ @available(iOS 26.0, *)
342
+ private struct NativeUIVerticalBarsCompression: ViewModifier {
343
+ @ViewBuilder func body(content: Content) -> some View {
344
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
345
+ if #available(iOS 27.1, *) {
346
+ content.toolbarVerticalCompressionBehavior(.prefersToolbarItems)
347
+ } else {
348
+ content
349
+ }
350
+ #else
351
+ content
352
+ #endif
353
+ }
354
+ }
355
+
356
+ @available(iOS 26.0, *)
357
+ private struct NativeUIVerticalBarsToolbarAdapter: ViewModifier {
358
+ @ObservedObject var model: NativeUIVerticalBarsModel
359
+ let page: String?
360
+
361
+ @ViewBuilder func body(content: Content) -> some View {
362
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
363
+ if #available(iOS 27.1, *) {
364
+ content.modifier(NativeUIVerticalBarsToolbar(model: model, page: page))
365
+ } else {
366
+ content.modifier(NativeUIVerticalBarsLegacyToolbar(model: model, page: page))
367
+ }
368
+ #else
369
+ content.modifier(NativeUIVerticalBarsLegacyToolbar(model: model, page: page))
370
+ #endif
371
+ }
372
+ }
373
+
374
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
375
+ @available(iOS 27.1, *)
376
+ private struct NativeUIVerticalBarsToolbar: ViewModifier {
377
+ @ObservedObject var model: NativeUIVerticalBarsModel
378
+ let page: String?
379
+
380
+ func body(content: Content) -> some View {
381
+ content.toolbar {
382
+ ForEach(model.groups(on: page).filter { $0.slot == .start }, id: \.transitionID) { group in
383
+ if group.id != model.groups(on: page).first(where: { $0.slot == .start })?.id {
384
+ ToolbarSpacer(.fixed, placement: .topBarLeading)
385
+ }
386
+ ForEach(group.items, id: \.transitionID) { item in
387
+ NativeUIVerticalBarsButton(model: model, page: page, groupID: group.transitionID, id: item.transitionID, placement: .topBarLeading)
388
+ .axisBehavior(.verticalPreferred)
389
+ }
390
+ }
391
+ ForEach(model.groups(on: page).filter { $0.slot != .start }, id: \.transitionID) { group in
392
+ if group.id != model.groups(on: page).first(where: { $0.slot != .start })?.id {
393
+ ToolbarSpacer(.fixed, placement: .topBarTrailing)
394
+ }
395
+ ForEach(group.items, id: \.transitionID) { item in
396
+ NativeUIVerticalBarsButton(model: model, page: page, groupID: group.transitionID, id: item.transitionID, placement: .topBarTrailing)
397
+ .axisBehavior(.verticalPreferred)
398
+ }
399
+ }
400
+ }
401
+ }
402
+ }
403
+ #endif
404
+
405
+ @available(iOS 26.0, *)
406
+ private struct NativeUIVerticalBarsLegacyToolbar: ViewModifier {
407
+ @ObservedObject var model: NativeUIVerticalBarsModel
408
+ let page: String?
409
+
410
+ func body(content: Content) -> some View {
411
+ #if canImport(SwiftUI, _version: 8.0)
412
+ content.toolbar {
413
+ ForEach(model.groups(on: page), id: \.transitionID) { group in
414
+ if group.id != model.groups(on: page).first(where: { $0.slot == group.slot })?.id {
415
+ ToolbarSpacer(.fixed, placement: group.slot == .start ? .topBarLeading : .topBarTrailing)
416
+ }
417
+ ForEach(group.items, id: \.transitionID) { item in
418
+ NativeUIVerticalBarsButton(model: model, page: page, groupID: group.transitionID, id: item.transitionID, placement: group.slot == .start ? .topBarLeading : .topBarTrailing)
419
+ }
420
+ }
421
+ }
422
+ #else
423
+ // Older SDKs cannot use ForEach as ToolbarContent; attach each item separately.
424
+ let base = AnyView(content)
425
+ // Outer toolbar modifiers precede inner ones, so attach in reverse order.
426
+ return model.groups(on: page).reversed().reduce(base) { view, group in
427
+ let items = group.items.reversed().reduce(view) { view, item in
428
+ AnyView(view.toolbar {
429
+ NativeUIVerticalBarsButton(model: model, page: page, groupID: group.transitionID, id: item.transitionID, placement: group.slot == .start ? .topBarLeading : .topBarTrailing)
430
+ })
431
+ }
432
+ return group.id == model.groups(on: page).first(where: { $0.slot == group.slot })?.id ? items : AnyView(items.toolbar {
433
+ ToolbarSpacer(.fixed, placement: group.slot == .start ? .topBarLeading : .topBarTrailing)
434
+ })
435
+ }
436
+ #endif
437
+ }
438
+ }
439
+
440
+ @available(iOS 26.0, *)
441
+ final class NativeUIVerticalBarsController: NativeUIVerticalBarsControlling {
442
+ private final class TransparentHostingController<Content: View>: UIHostingController<Content> {
443
+ var railEdge = "right"
444
+ var layoutSearch: (() -> Void)?
445
+ var layoutTabs: (() -> Void)?
446
+ override func viewDidLayoutSubviews() {
447
+ super.viewDidLayoutSubviews()
448
+ makeFullSizeSurfacesTransparent(in: view)
449
+ layoutSearch?()
450
+ layoutTabs?()
451
+ }
452
+
453
+ private func makeFullSizeSurfacesTransparent(in surface: UIView) {
454
+ let frame = surface.convert(surface.bounds, to: view)
455
+ guard !(surface is UIVisualEffectView) else { return }
456
+ let coversHost = frame.insetBy(dx: -1, dy: -1).contains(view.bounds)
457
+ // SwiftUI may add an opaque backing behind the rail controls, sized to
458
+ // the rail or to an expanded tab bar. Keep that backing clear without
459
+ // touching the glass controls or materials.
460
+ let coversRail = frame.minY <= 1 && frame.maxY >= view.bounds.maxY - 1 &&
461
+ (railEdge == "left" ? frame.minX <= 1 : frame.maxX >= view.bounds.maxX - 1)
462
+ if coversHost || coversRail {
463
+ surface.backgroundColor = .clear
464
+ surface.isOpaque = false
465
+ }
466
+ surface.subviews.forEach(makeFullSizeSurfacesTransparent)
467
+ }
468
+ }
469
+
470
+ private final class RailContainer: UIView {
471
+ var railEdge = "right"
472
+
473
+ private var railWidth: CGFloat {
474
+ let inset = railEdge == "left" ? safeAreaInsets.left : safeAreaInsets.right
475
+ return inset > 0 ? inset : 80
476
+ }
477
+
478
+ // The rail is not masked: expanded rail content (a widened tab bar) is
479
+ // allowed to draw over the WebView. Inside the base rail touches behave
480
+ // as before; beyond it touches are captured only where they land inside
481
+ // an actual bar surface so empty overlap still belongs to the WebView.
482
+ // The SwiftUI hosting scaffold reports a full-size hosting view for
483
+ // every point, so the hit result cannot tell bar content from empty
484
+ // space — the bar frames decide instead.
485
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
486
+ let hit = super.hitTest(point, with: event)
487
+ let inRail = railEdge == "left" ? point.x <= railWidth : point.x >= bounds.maxX - railWidth
488
+ if inRail { return hit }
489
+ // Expanded system search leaves the rail; its field and close button
490
+ // must receive touches instead of passing them through to the WebView.
491
+ var ancestor = hit
492
+ while let view = ancestor, view !== self {
493
+ if view is UIControl { return hit }
494
+ ancestor = view.superview
495
+ }
496
+ guard hit != nil, containsBarSurface(at: point) else { return nil }
497
+ return hit
498
+ }
499
+
500
+ private func containsBarSurface(at point: CGPoint) -> Bool {
501
+ containsBarSurface(in: self, at: point)
502
+ }
503
+
504
+ private func containsBarSurface(in view: UIView, at point: CGPoint) -> Bool {
505
+ guard !view.isHidden, view.alpha > 0.05 else { return false }
506
+ let name = NSStringFromClass(type(of: view))
507
+ if name.contains("TabBar") || name.contains("Platter") || name.contains("Pocket") || name.contains("Sidebar") || view is UISearchBar || view is UINavigationBar,
508
+ convert(view.bounds, from: view).contains(point) { return true }
509
+ return view.subviews.contains { containsBarSurface(in: $0, at: point) }
510
+ }
511
+ }
512
+
513
+ private let model = NativeUIVerticalBarsModel()
514
+ private lazy var controller = TransparentHostingController(rootView: NativeUIVerticalBarsView(model: model))
515
+ private let container = RailContainer()
516
+ private weak var owner: UIViewController?
517
+ private let changed: (String, NativeUISearchPhase, String, Bool, Int) -> Int
518
+
519
+ var view: UIView { container }
520
+ func measuredFrames(relativeTo webView: UIView) -> [String: (frame: CGRect, cornerRadius: CGFloat)] {
521
+ var frames: [String: (frame: CGRect, cornerRadius: CGFloat)] = [:]
522
+ let labels = Dictionary(grouping: model.tabs, by: \.accessibilityLabel)
523
+ var tabViews: [UIView] = []
524
+ func visit(_ view: UIView) {
525
+ guard !view.isHidden else { return }
526
+ if let control = view as? UIControl,
527
+ let label = control.accessibilityLabel, let items = labels[label], items.count == 1,
528
+ !control.isHidden, control.bounds.width > 0, control.bounds.height > 0 {
529
+ if frames[items[0].id] != nil { return }
530
+ frames[items[0].id] = (control.convert(control.bounds, to: webView), control.effectiveRadius(corner: .allCorners))
531
+ tabViews.append(control)
532
+ }
533
+ view.subviews.forEach(visit)
534
+ }
535
+ visit(container)
536
+ if let first = tabViews.first, tabViews.count >= model.tabs.count {
537
+ var ancestor: UIView? = first.superview
538
+ var platter: UIView?
539
+ var cornerRadius: CGFloat = 0
540
+ while let current = ancestor, current !== container {
541
+ if current.bounds.width >= webView.bounds.width * 0.5 { break }
542
+ if current.bounds.width > 0, current.bounds.height > 0,
543
+ tabViews.allSatisfy({ $0.isDescendant(of: current) }) {
544
+ platter = current
545
+ // Layout wrappers can enclose the shaped surface without owning corners.
546
+ let radius = current.effectiveRadius(corner: .allCorners)
547
+ if radius > 0 { cornerRadius = radius }
548
+ }
549
+ ancestor = current.superview
550
+ }
551
+ if let platter { frames["__tabBar"] = (platter.convert(platter.bounds, to: webView), cornerRadius) }
552
+ }
553
+ return frames
554
+ }
555
+
556
+ var ownsKeyboardChrome: Bool { model.search?.ownsKeyboardChrome == true }
557
+
558
+ init(activate: @escaping (String) -> Void,
559
+ changed: @escaping (String, NativeUISearchPhase, String, Bool, Int) -> Int) {
560
+ self.changed = changed
561
+ model.activate = activate
562
+ container.backgroundColor = .clear
563
+ controller.view.backgroundColor = .clear
564
+ controller.view.isOpaque = false
565
+ controller.layoutSearch = { [weak self] in self?.model.search?.configureField() }
566
+ controller.layoutTabs = { [weak self] in self?.applyTabTint() }
567
+ }
568
+
569
+ private weak var tintedTabBar: UITabBar?
570
+ private var appliedTabTint: String?
571
+
572
+ private func applyTabTint() {
573
+ let tint = model.tabTint.map { NativeUIRendering.color($0) }
574
+ func visit(_ owner: UIViewController) {
575
+ if let tabs = owner as? UITabBarController {
576
+ // Scope the accent to tabs, without recoloring toolbars or search.
577
+ if tintedTabBar !== tabs.tabBar || appliedTabTint != model.tabTint || (tint != nil && tabs.tabBar.tintColor != tint) {
578
+ tabs.tabBar.tintColor = tint
579
+ tintedTabBar = tabs.tabBar
580
+ appliedTabTint = model.tabTint
581
+ }
582
+ return
583
+ }
584
+ owner.children.forEach(visit)
585
+ }
586
+ visit(controller)
587
+ }
588
+
589
+ @MainActor func attach(to owner: UIViewController, in parent: UIView) {
590
+ guard self.owner !== owner || container.superview !== parent else { return }
591
+ detach()
592
+ self.owner = owner
593
+ owner.addChild(controller)
594
+ container.frame = parent.bounds
595
+ container.autoresizingMask = [.flexibleWidth, .flexibleHeight]
596
+ controller.view.frame = container.bounds
597
+ controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
598
+ container.addSubview(controller.view)
599
+ parent.addSubview(container)
600
+ controller.didMove(toParent: owner)
601
+ }
602
+
603
+ @MainActor func apply(
604
+ _ controls: [NativeUIControl], rendering: NativeUIControlRendering,
605
+ edge: String, animated: Bool = true, accessory: AnyView? = nil, navigation: [String] = [],
606
+ navigationPhase: String? = nil
607
+ ) {
608
+ // Native vertical toolbars retain their source throughout a back swipe.
609
+ // Begin when the finger commits; keep input locked until Web settles.
610
+ container.isUserInteractionEnabled = navigationPhase == nil
611
+ guard navigationPhase != "tracking" else { return }
612
+ container.railEdge = edge
613
+ controller.railEdge = edge
614
+ // A presentation identity can survive source replacement, but an open
615
+ // popover still belongs to its original action. Retire it before the
616
+ // toolbar binding starts observing the replacement item's action ID.
617
+ if let anchor = model.popoverAnchor,
618
+ !controls.contains(where: { [.button, .buttons, .menuButton].contains($0.kind) && $0.items.contains(where: { $0.id == anchor }) }) {
619
+ model.popoverHost?.dismissAnchored?()
620
+ model.popoverAnchor = nil
621
+ }
622
+ if let search = controls.first(where: { $0.search != nil })?.search {
623
+ if model.search?.configuration.id == search.id { model.search?.apply(search) } else { model.search = NativeUIVerticalSearchModel(search,
624
+ in: container, activate: model.activate, changed: changed) }
625
+ } else if model.search != nil { model.search = nil }
626
+ if animated {
627
+ model.apply(controls, rendering: rendering, animated: true, accessory: accessory, navigation: navigation)
628
+ } else {
629
+ // Resolve UIKit layout in the same unanimated handoff as SwiftUI.
630
+ UIView.performWithoutAnimation {
631
+ model.apply(controls, rendering: rendering, animated: false, accessory: accessory, navigation: navigation)
632
+ controller.view.setNeedsLayout()
633
+ controller.view.layoutIfNeeded()
634
+ }
635
+ }
636
+ controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light
637
+ }
638
+
639
+ func presentPopover(anchor: String, host: NativeUIOverlayHost, completion: @escaping (Bool) -> Void) -> Bool {
640
+ guard container.window != nil, model.popoverAnchor == nil,
641
+ model.groups.flatMap(\.items).contains(where: { $0.id == anchor }) else { return false }
642
+ model.popoverHost = host
643
+ model.popoverPresented = completion
644
+ host.anchoredVisible = false
645
+ host.disappeared = false
646
+ host.cancelAnchored = { [weak self] in
647
+ // Native presentation callbacks and their deadline run on the main queue.
648
+ MainActor.assumeIsolated { self?.detach() }
649
+ }
650
+ host.dismissAnchored = { [weak self, weak host] in
651
+ guard let host else { return }
652
+ guard let self, self.model.popoverHost === host else {
653
+ host.completeDismissal()
654
+ return
655
+ }
656
+ // A backdrop tap clears the binding before SwiftUI finishes closing.
657
+ // Keep the live content and completion until onDisappear, including
658
+ // when dismissal has already started through the system binding.
659
+ if self.model.popoverAnchor != nil {
660
+ var transaction = Transaction()
661
+ transaction.disablesAnimations = host.options?["animated"] as? Bool == false
662
+ withTransaction(transaction) { self.model.popoverAnchor = nil }
663
+ }
664
+ // Before onAppear there will be no onDisappear to finish cleanup.
665
+ if !host.anchoredVisible {
666
+ let completion = self.model.popoverPresented
667
+ self.model.popoverPresented = nil
668
+ self.model.popoverHost = nil
669
+ completion?(false)
670
+ }
671
+ }
672
+ var transaction = Transaction()
673
+ transaction.disablesAnimations = host.options?["animated"] as? Bool == false
674
+ withTransaction(transaction) { model.popoverAnchor = anchor }
675
+ return true
676
+ }
677
+
678
+ @MainActor func detach(duration: TimeInterval = 0) {
679
+ let host = model.popoverHost
680
+ let presented = model.popoverPresented
681
+ model.popoverPresented = nil
682
+ model.popoverAnchor = nil
683
+ model.popoverHost = nil
684
+ if controller.parent != nil {
685
+ controller.willMove(toParent: nil)
686
+ NativeUICrossfade.retire(container, duration: duration)
687
+ controller.view.removeFromSuperview()
688
+ controller.removeFromParent()
689
+ }
690
+ owner = nil
691
+ host?.dismissAnchored = nil
692
+ host?.cancelAnchored = nil
693
+ presented?(false)
694
+ host?.completeDismissal()
695
+ }
696
+ }