@rdlabo/capacitor-native-ui 0.0.0-stage → 2.0.0-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 (261) hide show
  1. package/LICENSE +21 -0
  2. package/Package.swift +23 -0
  3. package/README.md +66 -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 +237 -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 +197 -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 +19 -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 +776 -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/control-options.d.ts +6 -0
  129. package/dist/native/shared/control-options.d.ts.map +1 -0
  130. package/dist/native/shared/control-options.js +17 -0
  131. package/dist/native/shared/control-options.js.map +1 -0
  132. package/dist/native/shared/crossfade.d.ts +12 -0
  133. package/dist/native/shared/crossfade.d.ts.map +1 -0
  134. package/dist/native/shared/crossfade.js +67 -0
  135. package/dist/native/shared/crossfade.js.map +1 -0
  136. package/dist/native/shared/diagnostics.d.ts +4 -0
  137. package/dist/native/shared/diagnostics.d.ts.map +1 -0
  138. package/dist/native/shared/diagnostics.js +18 -0
  139. package/dist/native/shared/diagnostics.js.map +1 -0
  140. package/dist/native/shared/dom.d.ts +46 -0
  141. package/dist/native/shared/dom.d.ts.map +1 -0
  142. package/dist/native/shared/dom.js +180 -0
  143. package/dist/native/shared/dom.js.map +1 -0
  144. package/dist/native/shared/events.d.ts +15 -0
  145. package/dist/native/shared/events.d.ts.map +1 -0
  146. package/dist/native/shared/events.js +17 -0
  147. package/dist/native/shared/events.js.map +1 -0
  148. package/dist/native/shared/icons.d.ts +8 -0
  149. package/dist/native/shared/icons.d.ts.map +1 -0
  150. package/dist/native/shared/icons.js +61 -0
  151. package/dist/native/shared/icons.js.map +1 -0
  152. package/dist/native/shared/modal.d.ts +10 -0
  153. package/dist/native/shared/modal.d.ts.map +1 -0
  154. package/dist/native/shared/modal.js +160 -0
  155. package/dist/native/shared/modal.js.map +1 -0
  156. package/dist/native/shared/projection-id.d.ts +4 -0
  157. package/dist/native/shared/projection-id.d.ts.map +1 -0
  158. package/dist/native/shared/projection-id.js +7 -0
  159. package/dist/native/shared/projection-id.js.map +1 -0
  160. package/dist/native/shared/toolbar-options.d.ts +13 -0
  161. package/dist/native/shared/toolbar-options.d.ts.map +1 -0
  162. package/dist/native/shared/toolbar-options.js +28 -0
  163. package/dist/native/shared/toolbar-options.js.map +1 -0
  164. package/dist/native/shared/vertical-bars-geometry.d.ts +10 -0
  165. package/dist/native/shared/vertical-bars-geometry.d.ts.map +1 -0
  166. package/dist/native/shared/vertical-bars-geometry.js +85 -0
  167. package/dist/native/shared/vertical-bars-geometry.js.map +1 -0
  168. package/dist/native/startup.d.ts +8 -0
  169. package/dist/native/startup.d.ts.map +1 -0
  170. package/dist/native/startup.js +70 -0
  171. package/dist/native/startup.js.map +1 -0
  172. package/dist/native/vertical-bars-web-search.d.ts +10 -0
  173. package/dist/native/vertical-bars-web-search.d.ts.map +1 -0
  174. package/dist/native/vertical-bars-web-search.js +90 -0
  175. package/dist/native/vertical-bars-web-search.js.map +1 -0
  176. package/dist/native/vertical-bars-web.d.ts +5 -0
  177. package/dist/native/vertical-bars-web.d.ts.map +1 -0
  178. package/dist/native/vertical-bars-web.js +422 -0
  179. package/dist/native/vertical-bars-web.js.map +1 -0
  180. package/dist/native-integration/index.d.ts +25 -0
  181. package/dist/native-integration/index.d.ts.map +1 -0
  182. package/dist/native-integration/index.js +33 -0
  183. package/dist/native-integration/index.js.map +1 -0
  184. package/dist/vertical-bars.d.ts +6 -0
  185. package/dist/vertical-bars.js +5 -0
  186. package/docs/appearance.md +45 -0
  187. package/docs/configuration.md +219 -0
  188. package/docs/framework-independent.md +107 -0
  189. package/docs/ionic-vertical-bars.md +173 -0
  190. package/docs/migration.md +184 -0
  191. package/docs/native-ui-extensions.md +394 -0
  192. package/docs/native-ui.md +528 -0
  193. package/docs/setup.md +224 -0
  194. package/docs/vertical-bars.md +431 -0
  195. package/ios/Sources/NativeUIPlugin/Components/NativeUIButton.swift +71 -0
  196. package/ios/Sources/NativeUIPlugin/Components/NativeUIButtons.swift +24 -0
  197. package/ios/Sources/NativeUIPlugin/Components/NativeUIComponents.swift +19 -0
  198. package/ios/Sources/NativeUIPlugin/Components/NativeUIFab.swift +80 -0
  199. package/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift +439 -0
  200. package/ios/Sources/NativeUIPlugin/Components/NativeUISegment.swift +91 -0
  201. package/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift +218 -0
  202. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +590 -0
  203. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalSearch.swift +118 -0
  204. package/ios/Sources/NativeUIPlugin/Extensions/NativeUI.swift +178 -0
  205. package/ios/Sources/NativeUIPlugin/Extensions/NativeUIRendering.swift +38 -0
  206. package/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +615 -0
  207. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayController.swift +119 -0
  208. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayHost.swift +75 -0
  209. package/ios/Sources/NativeUIPlugin/Shared/NativeUIControlRendering.swift +21 -0
  210. package/ios/Sources/NativeUIPlugin/Shared/NativeUICrossfade.swift +35 -0
  211. package/ios/Sources/NativeUIPlugin/Shared/NativeUIHost.swift +8 -0
  212. package/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift +161 -0
  213. package/ios/Sources/NativeUIPlugin/Shared/NativeUIVerticalBarsGeometry.swift +52 -0
  214. package/ios/Tests/NativeUIPluginTests/NativeUIExtensionTests.swift +165 -0
  215. package/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +805 -0
  216. package/package.json +89 -4
  217. package/src/core.ts +14 -0
  218. package/src/native/activation.ts +385 -0
  219. package/src/native/components/index.ts +45 -0
  220. package/src/native/components/ion-back-button.ts +22 -0
  221. package/src/native/components/ion-button.ts +25 -0
  222. package/src/native/components/ion-buttons.ts +37 -0
  223. package/src/native/components/ion-fab.ts +151 -0
  224. package/src/native/components/ion-menu-button.ts +37 -0
  225. package/src/native/components/ion-segment.ts +27 -0
  226. package/src/native/components/ion-tab-bar.ts +50 -0
  227. package/src/native/components/searchable-tabs.ts +499 -0
  228. package/src/native/definitions.ts +274 -0
  229. package/src/native/extensions.ts +176 -0
  230. package/src/native/foldable/fold-state.ts +10 -0
  231. package/src/native/foldable/types.ts +4 -0
  232. package/src/native/html.ts +199 -0
  233. package/src/native/index.ts +32 -0
  234. package/src/native/legacy-layout.ts +21 -0
  235. package/src/native/overlays/content.ts +51 -0
  236. package/src/native/overlays/controller.ts +349 -0
  237. package/src/native/overlays/dialog-animation.ts +53 -0
  238. package/src/native/overlays/dismiss-animation.ts +15 -0
  239. package/src/native/overlays/focus.ts +64 -0
  240. package/src/native/overlays/popover.ts +120 -0
  241. package/src/native/overlays/styles.ts +181 -0
  242. package/src/native/prehide.ts +285 -0
  243. package/src/native/runtime.ts +979 -0
  244. package/src/native/shared/bridge.ts +18 -0
  245. package/src/native/shared/candidate.ts +162 -0
  246. package/src/native/shared/clipping.ts +24 -0
  247. package/src/native/shared/control-options.ts +20 -0
  248. package/src/native/shared/crossfade.ts +67 -0
  249. package/src/native/shared/diagnostics.ts +13 -0
  250. package/src/native/shared/dom.ts +304 -0
  251. package/src/native/shared/events.ts +14 -0
  252. package/src/native/shared/icons.ts +57 -0
  253. package/src/native/shared/modal.ts +157 -0
  254. package/src/native/shared/projection-id.ts +2 -0
  255. package/src/native/shared/toolbar-options.ts +49 -0
  256. package/src/native/shared/vertical-bars-geometry.ts +80 -0
  257. package/src/native/startup.ts +68 -0
  258. package/src/native/vertical-bars-web-search.ts +86 -0
  259. package/src/native/vertical-bars-web.ts +500 -0
  260. package/src/native-integration/index.ts +49 -0
  261. package/src/vertical-bars.ts +12 -0
@@ -0,0 +1,590 @@
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: CGRect]
8
+ @MainActor func attach(to owner: UIViewController, in parent: UIView)
9
+ @MainActor func apply(_ controls: [NativeUIControl], rendering: NativeUIControlRendering, edge: String, animated: Bool)
10
+ @MainActor func setAccessory(_ content: AnyView?)
11
+ var ownsKeyboardChrome: Bool { get }
12
+ func presentPopover(anchor: String, host: NativeUIOverlayHost, completion: @escaping (Bool) -> Void) -> Bool
13
+ @MainActor func detach(duration: TimeInterval)
14
+ }
15
+
16
+ @available(iOS 26.0, *)
17
+ final class NativeUIVerticalBarsModel: ObservableObject {
18
+ struct Item: Identifiable {
19
+ let id: 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 selected: Bool
30
+ }
31
+
32
+ struct Group: Identifiable {
33
+ let id: String
34
+ let items: [Item]
35
+ let slot: NativeUIControl.ToolbarSlot?
36
+ }
37
+
38
+ @Published var accessory: AnyView?
39
+ @Published var popoverAnchor: String?
40
+ var popoverHost: NativeUIOverlayHost?
41
+ var popoverPresented: ((Bool) -> Void)?
42
+ @Published var search: NativeUIVerticalSearchModel?
43
+ @Published var back: Item?
44
+ @Published var groups: [Group] = []
45
+ @Published var tabs: [Item] = []
46
+ @Published var selection = ""
47
+ var activate: (String) -> Void = { _ in }
48
+ private var domSelection = ""
49
+ private var pendingSelection: NativeUITabBar.PendingSelection?
50
+ private var pendingExpiryWork: DispatchWorkItem?
51
+
52
+ func apply(_ controls: [NativeUIControl], rendering: NativeUIControlRendering, animated: Bool = true,
53
+ now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) {
54
+ func item(_ source: NativeUIItem) -> Item {
55
+ let image = rendering.image(source.content)
56
+ return Item(id: source.id, label: source.content.label,
57
+ accessibilityLabel: source.content.accessibilityLabel,
58
+ // UIKit toolbar extraction uses the UIImage's rendering mode.
59
+ image: source.content.disabled ? image?.withRenderingMode(.alwaysTemplate) : image,
60
+ clear: source.content.buttonFill == .clear,
61
+ background: source.content.backgroundColor.map { Color(uiColor: rendering.color($0)) },
62
+ borderColor: source.content.borderColor.map { Color(uiColor: rendering.color($0)) },
63
+ borderWidth: source.content.borderWidth ?? 0,
64
+ badge: source.content.badge, disabled: source.content.disabled,
65
+ selected: source.content.selected)
66
+ }
67
+ withAnimation(animated && !UIAccessibility.isReduceMotionEnabled ? .smooth(duration: 0.3) : nil) {
68
+ back = controls.first(where: { $0.kind == .backButton })?.items.first.map(item)
69
+ groups = controls.compactMap { control in
70
+ guard [.button, .buttons, .menuButton].contains(control.kind) else { return nil }
71
+ return Group(id: control.id, items: control.items.map(item), slot: control.toolbarSlot)
72
+ }
73
+ }
74
+ tabs = controls.first(where: { $0.kind == .tabBar })?.items.map(item) ?? []
75
+ domSelection = tabs.first(where: \.selected)?.id ?? ""
76
+ reconcileSelection(now: now)
77
+ }
78
+
79
+ func select(_ id: String, now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent(), ttl: CFTimeInterval = 1) {
80
+ guard let item = tabs.first(where: { $0.id == id }), !item.disabled else { return }
81
+ pendingSelection = .init(id: id, until: now + ttl)
82
+ selection = id
83
+ pendingExpiryWork?.cancel()
84
+ let work = DispatchWorkItem { [weak self] in self?.reconcileSelection() }
85
+ pendingExpiryWork = work
86
+ DispatchQueue.main.asyncAfter(deadline: .now() + ttl + 0.02, execute: work)
87
+ activate(id)
88
+ }
89
+
90
+ private func reconcileSelection(now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) {
91
+ guard let pending = pendingSelection else { selection = domSelection; return }
92
+ let item = tabs.first(where: { $0.id == pending.id })
93
+ if item == nil || item!.disabled || now >= pending.until || domSelection == pending.id {
94
+ pendingSelection = nil
95
+ pendingExpiryWork?.cancel()
96
+ pendingExpiryWork = nil
97
+ selection = domSelection
98
+ } else {
99
+ selection = pending.id
100
+ }
101
+ }
102
+ }
103
+
104
+ @available(iOS 26.0, *)
105
+ private struct NativeUIVerticalBarsLabel: View {
106
+ let item: NativeUIVerticalBarsModel.Item
107
+
108
+ var body: some View {
109
+ if let image = item.image {
110
+ Label {
111
+ Text(item.label.isEmpty ? item.accessibilityLabel : item.label)
112
+ } icon: {
113
+ Image(uiImage: image)
114
+ }
115
+ } else {
116
+ Text(item.label)
117
+ }
118
+ }
119
+ }
120
+
121
+ @available(iOS 26.0, *)
122
+ @ViewBuilder
123
+ private func verticalBarsButton(_ item: NativeUIVerticalBarsModel.Item, model: NativeUIVerticalBarsModel) -> some View {
124
+ let button = Button { model.activate(item.id) } label: { NativeUIVerticalBarsLabel(item: item) }
125
+ .disabled(item.disabled)
126
+ .accessibilityLabel(item.accessibilityLabel)
127
+ .accessibilityIdentifier(item.id)
128
+ if let background = item.background {
129
+ button.buttonStyle(.glassProminent).tint(background)
130
+ } else {
131
+ button.overlay {
132
+ if let borderColor = item.borderColor {
133
+ Capsule().strokeBorder(borderColor, lineWidth: item.borderWidth).allowsHitTesting(false)
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ @available(iOS 26.0, *)
140
+ private struct NativeUIVerticalPopoverContent: UIViewControllerRepresentable {
141
+ let host: NativeUIOverlayHost
142
+ func makeUIViewController(context: Context) -> NativeUIOverlayHost { host }
143
+ func updateUIViewController(_ controller: NativeUIOverlayHost, context: Context) {}
144
+ }
145
+
146
+ @available(iOS 26.0, *)
147
+ private struct NativeUIVerticalBarsButton: ToolbarContent {
148
+ @ObservedObject var model: NativeUIVerticalBarsModel
149
+ let id: String
150
+ let placement: ToolbarItemPlacement
151
+
152
+ // Observe here so same-ID updates also refresh the shared toolbar background.
153
+ var body: some ToolbarContent {
154
+ if let item = model.groups.lazy.flatMap(\.items).first(where: { $0.id == id }) {
155
+ ToolbarItem(placement: placement) {
156
+ verticalBarsButton(item, model: model)
157
+ .popover(isPresented: Binding(
158
+ get: { model.popoverAnchor == item.id },
159
+ set: { shown in
160
+ if !shown && model.popoverAnchor == item.id {
161
+ model.popoverAnchor = nil
162
+ model.popoverHost?.event("dismiss")
163
+ }
164
+ }
165
+ )) {
166
+ if let host = model.popoverHost {
167
+ NativeUIVerticalPopoverContent(host: host)
168
+ .frame(width: host.preferredContentSize.width, height: host.preferredContentSize.height)
169
+ .preferredColorScheme(host.options?["dark"] as? Bool == true ? .dark : .light)
170
+ .presentationCompactAdaptation(.popover)
171
+ .interactiveDismissDisabled(host.options?["backdropDismiss"] as? Bool == false)
172
+ .onAppear {
173
+ guard model.popoverHost === host else { return }
174
+ host.anchoredVisible = true
175
+ let completion = model.popoverPresented
176
+ model.popoverPresented = nil
177
+ completion?(true)
178
+ }
179
+ .onDisappear {
180
+ if model.popoverHost === host { model.popoverHost = nil }
181
+ host.completeDismissal()
182
+ }
183
+ }
184
+ }
185
+ }
186
+ .sharedBackgroundVisibility(item.clear ? .hidden : .automatic)
187
+ }
188
+ }
189
+ }
190
+
191
+ private struct NativeUIVerticalBarGeometryProbe: UIViewRepresentable {
192
+ func makeUIView(context: Context) -> UIView {
193
+ let view = UIView()
194
+ view.isUserInteractionEnabled = false
195
+ view.accessibilityIdentifier = "BackButton"
196
+ return view
197
+ }
198
+ func updateUIView(_ uiView: UIView, context: Context) {}
199
+ }
200
+
201
+ @available(iOS 26.0, *)
202
+ private func verticalBarsBackButton(_ item: NativeUIVerticalBarsModel.Item, model: NativeUIVerticalBarsModel) -> some View {
203
+ Button { model.activate(item.id) } label: { Image(systemName: "chevron.backward") }
204
+ .disabled(item.disabled)
205
+ .accessibilityLabel(item.accessibilityLabel)
206
+ .accessibilityIdentifier("BackButton")
207
+ .background(NativeUIVerticalBarGeometryProbe())
208
+ }
209
+
210
+ @available(iOS 26.0, *)
211
+ private struct NativeUIVerticalBarsBadge: ViewModifier {
212
+ let badge: NativeUIBadge?
213
+
214
+ @ViewBuilder func body(content: Content) -> some View {
215
+ if let badge { content.badge(badge.value) }
216
+ else { content }
217
+ }
218
+ }
219
+
220
+ @available(iOS 26.0, *)
221
+ private struct NativeUIVerticalBarsView: View {
222
+ @ObservedObject var model: NativeUIVerticalBarsModel
223
+
224
+ var body: some View {
225
+ Group {
226
+ if model.tabs.isEmpty {
227
+ NativeUIVerticalBarsPage(model: model)
228
+ } else {
229
+ TabView(selection: Binding(get: { model.selection }, set: { model.select($0) })) {
230
+ ForEach(model.tabs) { item in
231
+ NativeUIVerticalBarsPage(model: model)
232
+ .tag(item.id)
233
+ .tabItem { NativeUIVerticalBarsLabel(item: item) }
234
+ .modifier(NativeUIVerticalBarsBadge(badge: item.badge))
235
+ .disabled(item.disabled)
236
+ .accessibilityLabel(item.accessibilityLabel)
237
+ .accessibilityIdentifier(item.id)
238
+ }
239
+ }
240
+ .modifier(NativeUIExtensionAccessory(content: model.accessory))
241
+ }
242
+ }
243
+ .modifier(NativeUIVerticalBarsSearch(model: model))
244
+ .modifier(NativeUIVerticalBarsCompression())
245
+ .background(Color.clear)
246
+ }
247
+ }
248
+
249
+ @available(iOS 26.0, *)
250
+ private struct NativeUIVerticalBarsPage: View {
251
+ @ObservedObject var model: NativeUIVerticalBarsModel
252
+
253
+ var body: some View {
254
+ NavigationStack {
255
+ Color.clear.modifier(NativeUIVerticalBarsToolbarAdapter(model: model))
256
+ .toolbar {
257
+ if model.search?.configuration.available == true {
258
+ // Let the system adapt bottom-bar search to the vertical rail.
259
+ DefaultToolbarItem(kind: .search, placement: .bottomBar)
260
+ }
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ @available(iOS 26.0, *)
267
+ private struct NativeUIVerticalBarsSearch: ViewModifier {
268
+ @ObservedObject var model: NativeUIVerticalBarsModel
269
+ @ViewBuilder func body(content: Content) -> some View {
270
+ if let search = model.search {
271
+ content.modifier(NativeUIVerticalSearchModifier(model: search))
272
+ } else { content }
273
+ }
274
+ }
275
+
276
+ @available(iOS 26.0, *)
277
+ private struct NativeUIVerticalBarsCompression: ViewModifier {
278
+ @ViewBuilder func body(content: Content) -> some View {
279
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
280
+ if #available(iOS 27.1, *) {
281
+ content.toolbarVerticalCompressionBehavior(.prefersToolbarItems)
282
+ } else {
283
+ content
284
+ }
285
+ #else
286
+ content
287
+ #endif
288
+ }
289
+ }
290
+
291
+ @available(iOS 26.0, *)
292
+ private struct NativeUIVerticalBarsToolbarAdapter: ViewModifier {
293
+ @ObservedObject var model: NativeUIVerticalBarsModel
294
+
295
+ @ViewBuilder func body(content: Content) -> some View {
296
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
297
+ if #available(iOS 27.1, *) {
298
+ content.modifier(NativeUIVerticalBarsToolbar(model: model))
299
+ } else {
300
+ content.modifier(NativeUIVerticalBarsLegacyToolbar(model: model))
301
+ }
302
+ #else
303
+ content.modifier(NativeUIVerticalBarsLegacyToolbar(model: model))
304
+ #endif
305
+ }
306
+ }
307
+
308
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
309
+ @available(iOS 27.1, *)
310
+ private struct NativeUIVerticalBarsToolbar: ViewModifier {
311
+ @ObservedObject var model: NativeUIVerticalBarsModel
312
+
313
+ func body(content: Content) -> some View {
314
+ content.toolbar {
315
+ if let back = model.back {
316
+ ToolbarItem(placement: .navigation) {
317
+ verticalBarsBackButton(back, model: model)
318
+ }
319
+ .axisBehavior(.verticalPreferred)
320
+ }
321
+ ForEach(model.groups.filter { $0.slot == .start }) { group in
322
+ if group.id != model.groups.first(where: { $0.slot == .start })?.id {
323
+ ToolbarSpacer(.fixed, placement: .topBarLeading)
324
+ }
325
+ ForEach(group.items) { item in
326
+ NativeUIVerticalBarsButton(model: model, id: item.id, placement: .topBarLeading)
327
+ .axisBehavior(.verticalPreferred)
328
+ }
329
+ }
330
+ ForEach(model.groups.filter { $0.slot != .start }) { group in
331
+ if group.id != model.groups.first(where: { $0.slot != .start })?.id {
332
+ ToolbarSpacer(.fixed, placement: .topBarTrailing)
333
+ }
334
+ ForEach(group.items) { item in
335
+ NativeUIVerticalBarsButton(model: model, id: item.id, placement: .topBarTrailing)
336
+ .axisBehavior(.verticalPreferred)
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+ #endif
343
+
344
+ @available(iOS 26.0, *)
345
+ private struct NativeUIVerticalBarsLegacyToolbar: ViewModifier {
346
+ @ObservedObject var model: NativeUIVerticalBarsModel
347
+
348
+ func body(content: Content) -> some View {
349
+ #if canImport(SwiftUI, _version: 8.0)
350
+ content.toolbar {
351
+ if let back = model.back {
352
+ ToolbarItem(placement: .navigation) {
353
+ verticalBarsBackButton(back, model: model)
354
+ }
355
+ }
356
+ ForEach(model.groups) { group in
357
+ if group.id != model.groups.first?.id {
358
+ ToolbarSpacer(.fixed, placement: .primaryAction)
359
+ }
360
+ ForEach(group.items) { item in
361
+ NativeUIVerticalBarsButton(model: model, id: item.id, placement: .primaryAction)
362
+ }
363
+ }
364
+ }
365
+ #else
366
+ // Older SDKs cannot use ForEach as ToolbarContent; attach each item separately.
367
+ let base = AnyView(content.toolbar {
368
+ if let back = model.back {
369
+ ToolbarItem(placement: .navigation) { verticalBarsBackButton(back, model: model) }
370
+ }
371
+ })
372
+ // Outer toolbar modifiers precede inner ones, so attach in reverse order.
373
+ return model.groups.reversed().reduce(base) { view, group in
374
+ let items = group.items.reversed().reduce(view) { view, item in
375
+ AnyView(view.toolbar {
376
+ NativeUIVerticalBarsButton(model: model, id: item.id, placement: .primaryAction)
377
+ })
378
+ }
379
+ return group.id == model.groups.first?.id ? items : AnyView(items.toolbar {
380
+ ToolbarSpacer(.fixed, placement: .primaryAction)
381
+ })
382
+ }
383
+ #endif
384
+ }
385
+ }
386
+
387
+ @available(iOS 26.0, *)
388
+ final class NativeUIVerticalBarsController: NativeUIVerticalBarsControlling {
389
+ private final class TransparentHostingController<Content: View>: UIHostingController<Content> {
390
+ var railEdge = "right"
391
+ var layoutSearch: (() -> Void)?
392
+ override func viewDidLayoutSubviews() {
393
+ super.viewDidLayoutSubviews()
394
+ makeFullSizeSurfacesTransparent(in: view)
395
+ layoutSearch?()
396
+ }
397
+
398
+ private func makeFullSizeSurfacesTransparent(in surface: UIView) {
399
+ let frame = surface.convert(surface.bounds, to: view)
400
+ guard !(surface is UIVisualEffectView) else { return }
401
+ let coversHost = frame.insetBy(dx: -1, dy: -1).contains(view.bounds)
402
+ // SwiftUI may add an opaque backing behind the rail controls, sized to
403
+ // the rail or to an expanded tab bar. Keep that backing clear without
404
+ // touching the glass controls or materials.
405
+ let coversRail = frame.minY <= 1 && frame.maxY >= view.bounds.maxY - 1 &&
406
+ (railEdge == "left" ? frame.minX <= 1 : frame.maxX >= view.bounds.maxX - 1)
407
+ if coversHost || coversRail {
408
+ surface.backgroundColor = .clear
409
+ surface.isOpaque = false
410
+ }
411
+ surface.subviews.forEach(makeFullSizeSurfacesTransparent)
412
+ }
413
+ }
414
+
415
+ private final class RailContainer: UIView {
416
+ var railEdge = "right"
417
+
418
+ private var railWidth: CGFloat {
419
+ let inset = railEdge == "left" ? safeAreaInsets.left : safeAreaInsets.right
420
+ return inset > 0 ? inset : 80
421
+ }
422
+
423
+ // The rail is not masked: expanded rail content (a widened tab bar) is
424
+ // allowed to draw over the WebView. Inside the base rail touches behave
425
+ // as before; beyond it touches are captured only where they land inside
426
+ // an actual bar surface so empty overlap still belongs to the WebView.
427
+ // The SwiftUI hosting scaffold reports a full-size hosting view for
428
+ // every point, so the hit result cannot tell bar content from empty
429
+ // space — the bar frames decide instead.
430
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
431
+ let hit = super.hitTest(point, with: event)
432
+ let inRail = railEdge == "left" ? point.x <= railWidth : point.x >= bounds.maxX - railWidth
433
+ if inRail { return hit }
434
+ // Expanded system search leaves the rail; its field and close button
435
+ // must receive touches instead of passing them through to the WebView.
436
+ var ancestor = hit
437
+ while let view = ancestor, view !== self {
438
+ if view is UIControl { return hit }
439
+ ancestor = view.superview
440
+ }
441
+ guard hit != nil, containsBarSurface(at: point) else { return nil }
442
+ return hit
443
+ }
444
+
445
+ private func containsBarSurface(at point: CGPoint) -> Bool {
446
+ containsBarSurface(in: self, at: point)
447
+ }
448
+
449
+ private func containsBarSurface(in view: UIView, at point: CGPoint) -> Bool {
450
+ guard !view.isHidden, view.alpha > 0.05 else { return false }
451
+ let name = NSStringFromClass(type(of: view))
452
+ if (name.contains("TabBar") || name.contains("Platter") || name.contains("Pocket") || name.contains("Sidebar") || view is UISearchBar || view is UINavigationBar),
453
+ convert(view.bounds, from: view).contains(point) { return true }
454
+ return view.subviews.contains { containsBarSurface(in: $0, at: point) }
455
+ }
456
+ }
457
+
458
+ private let model = NativeUIVerticalBarsModel()
459
+ private lazy var controller = TransparentHostingController(rootView: NativeUIVerticalBarsView(model: model))
460
+ private let container = RailContainer()
461
+ private weak var owner: UIViewController?
462
+ private let changed: (String, NativeUISearchPhase, String, Bool, Int) -> Int
463
+
464
+ func setAccessory(_ content: AnyView?) { model.accessory = content }
465
+
466
+ var view: UIView { container }
467
+ func measuredFrames(relativeTo webView: UIView) -> [String: CGRect] {
468
+ var frames: [String: CGRect] = [:]
469
+ let labels = Dictionary(grouping: model.tabs, by: \.accessibilityLabel)
470
+ var tabViews: [UIView] = []
471
+ func visit(_ view: UIView) {
472
+ guard !view.isHidden else { return }
473
+ if let control = view as? UIControl,
474
+ let label = control.accessibilityLabel, let items = labels[label], items.count == 1,
475
+ !control.isHidden, control.bounds.width > 0, control.bounds.height > 0 {
476
+ if frames[items[0].id] != nil { return }
477
+ frames[items[0].id] = control.convert(control.bounds, to: webView)
478
+ tabViews.append(control)
479
+ }
480
+ view.subviews.forEach(visit)
481
+ }
482
+ visit(container)
483
+ if let first = tabViews.first, tabViews.count >= model.tabs.count {
484
+ var ancestor: UIView? = first.superview
485
+ var platter: UIView?
486
+ while let current = ancestor, current !== container {
487
+ if current.bounds.width >= webView.bounds.width * 0.5 { break }
488
+ if current.bounds.width > 0, current.bounds.height > 0,
489
+ tabViews.allSatisfy({ $0.isDescendant(of: current) }) { platter = current }
490
+ ancestor = current.superview
491
+ }
492
+ if let platter { frames["__tabBar"] = platter.convert(platter.bounds, to: webView) }
493
+ }
494
+ return frames
495
+ }
496
+
497
+ var ownsKeyboardChrome: Bool { model.search?.ownsKeyboardChrome == true }
498
+
499
+ init(activate: @escaping (String) -> Void,
500
+ changed: @escaping (String, NativeUISearchPhase, String, Bool, Int) -> Int) {
501
+ self.changed = changed
502
+ model.activate = activate
503
+ container.backgroundColor = .clear
504
+ controller.view.backgroundColor = .clear
505
+ controller.view.isOpaque = false
506
+ controller.layoutSearch = { [weak self] in self?.model.search?.configureField() }
507
+ }
508
+
509
+ @MainActor func attach(to owner: UIViewController, in parent: UIView) {
510
+ guard self.owner !== owner || container.superview !== parent else { return }
511
+ detach()
512
+ self.owner = owner
513
+ owner.addChild(controller)
514
+ container.frame = parent.bounds
515
+ container.autoresizingMask = [.flexibleWidth, .flexibleHeight]
516
+ controller.view.frame = container.bounds
517
+ controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
518
+ container.addSubview(controller.view)
519
+ parent.addSubview(container)
520
+ controller.didMove(toParent: owner)
521
+ }
522
+
523
+ @MainActor func apply(_ controls: [NativeUIControl], rendering: NativeUIControlRendering, edge: String, animated: Bool = true) {
524
+ container.railEdge = edge
525
+ controller.railEdge = edge
526
+ if let search = controls.first(where: { $0.search != nil })?.search {
527
+ if model.search?.configuration.id == search.id { model.search?.apply(search) }
528
+ else { model.search = NativeUIVerticalSearchModel(search, in: container, activate: model.activate, changed: changed) }
529
+ } else { model.search = nil }
530
+ model.apply(controls, rendering: rendering, animated: animated)
531
+ controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light
532
+ }
533
+
534
+ func presentPopover(anchor: String, host: NativeUIOverlayHost, completion: @escaping (Bool) -> Void) -> Bool {
535
+ guard container.window != nil, model.popoverAnchor == nil,
536
+ model.groups.flatMap(\.items).contains(where: { $0.id == anchor && !$0.disabled }) else { return false }
537
+ model.popoverHost = host
538
+ model.popoverPresented = completion
539
+ host.anchoredVisible = false
540
+ host.cancelAnchored = { [weak self] in
541
+ // Native presentation callbacks and their deadline run on the main queue.
542
+ MainActor.assumeIsolated { self?.detach() }
543
+ }
544
+ host.dismissAnchored = { [weak self, weak host] in
545
+ guard let host else { return }
546
+ guard let self, self.model.popoverHost === host else {
547
+ host.completeDismissal()
548
+ return
549
+ }
550
+ // A backdrop tap clears the binding before SwiftUI finishes closing.
551
+ // Keep the live content and completion until onDisappear, including
552
+ // when dismissal has already started through the system binding.
553
+ if self.model.popoverAnchor != nil {
554
+ var transaction = Transaction()
555
+ transaction.disablesAnimations = host.options?["animated"] as? Bool == false
556
+ withTransaction(transaction) { self.model.popoverAnchor = nil }
557
+ }
558
+ // Before onAppear there will be no onDisappear to finish cleanup.
559
+ if !host.anchoredVisible {
560
+ let completion = self.model.popoverPresented
561
+ self.model.popoverPresented = nil
562
+ self.model.popoverHost = nil
563
+ completion?(false)
564
+ }
565
+ }
566
+ var transaction = Transaction()
567
+ transaction.disablesAnimations = host.options?["animated"] as? Bool == false
568
+ withTransaction(transaction) { model.popoverAnchor = anchor }
569
+ return true
570
+ }
571
+
572
+ @MainActor func detach(duration: TimeInterval = 0) {
573
+ let host = model.popoverHost
574
+ let presented = model.popoverPresented
575
+ model.popoverPresented = nil
576
+ model.popoverAnchor = nil
577
+ model.popoverHost = nil
578
+ if controller.parent != nil {
579
+ controller.willMove(toParent: nil)
580
+ NativeUICrossfade.retire(container, duration: duration)
581
+ controller.view.removeFromSuperview()
582
+ controller.removeFromParent()
583
+ }
584
+ owner = nil
585
+ host?.dismissAnchored = nil
586
+ host?.cancelAnchored = nil
587
+ presented?(false)
588
+ host?.completeDismissal()
589
+ }
590
+ }