@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,118 @@
1
+ import Combine
2
+ import SwiftUI
3
+ import UIKit
4
+
5
+ @available(iOS 26.0, *)
6
+ final class NativeUIVerticalSearchModel: ObservableObject {
7
+ @Published private(set) var configuration: NativeUISearch
8
+ @Published var text: String
9
+ @Published var presented: Bool
10
+ @Published var focused: Bool
11
+ private weak var railView: UIView?
12
+ private let activate: (String) -> Void
13
+ private let changed: (String, NativeUISearchPhase, String, Bool, Int) -> Int
14
+ private let inputDelegate = NativeUISearchInputDelegate()
15
+ private var editingSequence = 0
16
+ private var valueVersion: Int
17
+
18
+ init(_ configuration: NativeUISearch, in railView: UIView,
19
+ activate: @escaping (String) -> Void,
20
+ changed: @escaping (String, NativeUISearchPhase, String, Bool, Int) -> Int) {
21
+ self.configuration = configuration
22
+ self.railView = railView
23
+ self.activate = activate
24
+ self.changed = changed
25
+ text = configuration.value
26
+ presented = configuration.available && configuration.active
27
+ focused = configuration.focused
28
+ valueVersion = configuration.valueVersion
29
+ inputDelegate.clear = { [weak self] in self?.emit(.clear) }
30
+ }
31
+
32
+ var ownsKeyboardChrome: Bool { presented && (focused || textField?.isFirstResponder == true) }
33
+
34
+ private var textField: UISearchTextField? {
35
+ func find(_ view: UIView) -> UISearchTextField? {
36
+ guard !view.isHidden, view.alpha > 0.01 else { return nil }
37
+ if let field = view as? UISearchTextField { return field }
38
+ for child in view.subviews {
39
+ if let field = find(child) { return field }
40
+ }
41
+ return nil
42
+ }
43
+ return railView.flatMap(find)
44
+ }
45
+
46
+ func apply(_ next: NativeUISearch) {
47
+ let previous = configuration
48
+ configuration = next
49
+ if next.valueVersion != valueVersion || next.editSequence >= editingSequence {
50
+ valueVersion = next.valueVersion
51
+ text = next.value
52
+ }
53
+ presented = next.available && next.active
54
+ // The first Web acknowledgement still carries the old focus state.
55
+ if !next.active { focused = false }
56
+ else if previous.focused != next.focused { focused = next.focused }
57
+ }
58
+
59
+ func input(_ value: String) {
60
+ text = value
61
+ emit(.input)
62
+ }
63
+
64
+ func present(_ value: Bool) {
65
+ presented = value
66
+ if value != configuration.active {
67
+ activate(value ? configuration.trigger.id : configuration.closeId)
68
+ }
69
+ }
70
+
71
+ func focus(_ value: Bool) {
72
+ focused = value
73
+ emit(value ? .focus : .blur)
74
+ }
75
+
76
+ func commit() { emit(.commit) }
77
+
78
+ func configureField() {
79
+ guard let field = textField else { return }
80
+ field.accessibilityIdentifier = configuration.id
81
+ field.accessibilityLabel = configuration.field.accessibilityLabel
82
+ field.isEnabled = !configuration.disabled
83
+ field.autocapitalizationType = .none
84
+ field.autocorrectionType = .no
85
+ field.spellCheckingType = .no
86
+ field.clearButtonMode = .always
87
+ if field.delegate !== inputDelegate {
88
+ inputDelegate.original = field.delegate
89
+ field.delegate = inputDelegate
90
+ }
91
+ }
92
+
93
+ private func emit(_ phase: NativeUISearchPhase) {
94
+ editingSequence = changed(configuration.id, phase, text,
95
+ textField?.markedTextRange != nil, valueVersion)
96
+ }
97
+ }
98
+
99
+ @available(iOS 26.0, *)
100
+ struct NativeUIVerticalSearchModifier: ViewModifier {
101
+ @ObservedObject var model: NativeUIVerticalSearchModel
102
+ @FocusState private var focused: Bool
103
+
104
+ @ViewBuilder func body(content: Content) -> some View {
105
+ if model.configuration.available {
106
+ content
107
+ .searchable(text: Binding(get: { model.text }, set: model.input),
108
+ isPresented: Binding(get: { model.presented }, set: model.present),
109
+ placement: .toolbar, prompt: Text(model.configuration.placeholder))
110
+ .searchToolbarBehavior(.minimize)
111
+ .searchPresentationToolbarBehavior(.avoidHidingContent)
112
+ .searchFocused($focused)
113
+ .onSubmit(of: .search, model.commit)
114
+ .onChange(of: focused) { _, value in model.focus(value) }
115
+ .onChange(of: model.focused) { _, value in focused = value }
116
+ } else { content }
117
+ }
118
+ }
@@ -0,0 +1,178 @@
1
+ import SwiftUI
2
+ import UIKit
3
+
4
+ /// Experimental API: registration interfaces and behavior may change in future releases.
5
+ /// App-local SwiftUI renderers. Register once before enabling the shell from JavaScript.
6
+ @MainActor public enum NativeUI {
7
+ private static var renderers: [String: (String, NativeUIActions, NativeUIContext) throws -> AnyView] = [:]
8
+
9
+ /// The name must match defineNativeUI in the application's TypeScript source.
10
+ public static func register<Props: Decodable, Content: View>(
11
+ _ name: String, @ViewBuilder content: @escaping (Props, NativeUIActions) -> Content
12
+ ) throws {
13
+ try register(name) { (props: Props, actions: NativeUIActions, _: NativeUIContext) in
14
+ content(props, actions)
15
+ }
16
+ }
17
+
18
+ /// Receives source geometry and presentation state on every accepted snapshot.
19
+ public static func register<Props: Decodable, Content: View>(
20
+ _ name: String, @ViewBuilder content: @escaping (Props, NativeUIActions, NativeUIContext) -> Content
21
+ ) throws {
22
+ guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, renderers[name] == nil else {
23
+ throw NativeUIError.duplicateRegistration
24
+ }
25
+ renderers[name] = { json, actions, context in
26
+ let props = try JSONDecoder().decode(Props.self, from: Data(json.utf8))
27
+ return AnyView(content(props, actions, context))
28
+ }
29
+ }
30
+
31
+ static var names: [String] { renderers.keys.sorted() }
32
+
33
+ static func content(_ data: NativeUIExtensionContent, actions: NativeUIActions, context: NativeUIContext) throws -> AnyView {
34
+ guard let renderer = renderers[data.name] else { throw NativeUIError.unavailable }
35
+ return try renderer(data.props, actions, context)
36
+ }
37
+ }
38
+
39
+ /// Read-only metadata for a projected Web source. Shell still owns its placement.
40
+ public struct NativeUIContext {
41
+ public enum Placement: String { case element, tabAccessory = "tab-accessory" }
42
+
43
+ /// Opaque identity stable for this source's current lifetime; not its HTML id.
44
+ public let sourceID: String
45
+ /// getBoundingClientRect(), in CSS pixels relative to the Web viewport.
46
+ public let sourceFrame: CGRect
47
+ /// The same source rectangle in WKWebView-local UIKit points, not screen coordinates.
48
+ public let sourceFrameInWebView: CGRect
49
+ /// CSS viewport width used to convert the snapshot into native coordinates.
50
+ public let viewportWidth: CGFloat
51
+ /// UIKit points per CSS pixel. This is not the device's pixel density.
52
+ public let pointsPerCSSPixel: CGFloat
53
+ public let placement: Placement
54
+ public let colorScheme: ColorScheme
55
+ public let layoutDirection: LayoutDirection
56
+
57
+ init(node: NativeUIControl, viewportWidth: CGFloat, pointsPerCSSPixel: CGFloat) {
58
+ sourceID = node.id
59
+ sourceFrame = node.frame.rect
60
+ sourceFrameInWebView = node.frame.rect.applying(
61
+ CGAffineTransform(scaleX: pointsPerCSSPixel, y: pointsPerCSSPixel))
62
+ self.viewportWidth = viewportWidth
63
+ self.pointsPerCSSPixel = pointsPerCSSPixel
64
+ placement = node.extensionContent?.tabBarId == nil ? .element : .tabAccessory
65
+ colorScheme = node.dark ? .dark : .light
66
+ layoutDirection = node.rtl ? .rightToLeft : .leftToRight
67
+ }
68
+ }
69
+
70
+ public enum NativeUIError: Error {
71
+ case duplicateRegistration, unavailable, rejected, timedOut
72
+ }
73
+
74
+ /// Completion means the original Web element received its click, not that the
75
+ /// application's asynchronous work triggered by that click has finished.
76
+ @MainActor public struct NativeUIActions {
77
+ let dispatch: (String) async throws -> Void
78
+ /// Names of currently visible, enabled DOM targets supplied by the reader.
79
+ public let availableNames: Set<String>
80
+
81
+ init(availableNames: Set<String> = [], dispatch: @escaping (String) async throws -> Void) {
82
+ self.availableNames = availableNames
83
+ self.dispatch = dispatch
84
+ }
85
+
86
+ public func isAvailable(_ name: String) -> Bool { availableNames.contains(name) }
87
+
88
+ public func activate(_ name: String) async throws {
89
+ try Task.checkCancellation()
90
+ try await dispatch(name)
91
+ }
92
+ }
93
+
94
+ struct NativeUIExtensionContent: Decodable, Equatable {
95
+ let name: String
96
+ let props: String
97
+ let actions: [String: String]
98
+ let tabBarId: String?
99
+
100
+ var isValid: Bool {
101
+ !name.isEmpty && props.utf8.count <= 262144 && actions.count <= 30 &&
102
+ actions.allSatisfy { !$0.key.isEmpty && !$0.value.isEmpty } &&
103
+ (tabBarId.map { !$0.isEmpty } ?? true) &&
104
+ (try? JSONSerialization.jsonObject(with: Data(props.utf8))) is [String: Any]
105
+ }
106
+ }
107
+
108
+ /// Owns pending native -> Web calls independently of the View's lifetime.
109
+ @MainActor final class NativeUIExtensionActions {
110
+ private struct Pending {
111
+ let owner: String
112
+ let continuation: CheckedContinuation<Void, Error>
113
+ let deadline: DispatchWorkItem
114
+ }
115
+ private var pending: [String: Pending] = [:]
116
+
117
+ func perform(owner: String, timeout: TimeInterval = 4, send: (String) -> Void) async throws {
118
+ let request = UUID().uuidString
119
+ try await withTaskCancellationHandler {
120
+ try Task.checkCancellation()
121
+ try await withCheckedThrowingContinuation { continuation in
122
+ let deadline = DispatchWorkItem { [weak self] in self?.finish(request, error: NativeUIError.timedOut) }
123
+ pending[request] = Pending(owner: owner, continuation: continuation, deadline: deadline)
124
+ DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: deadline)
125
+ send(request)
126
+ }
127
+ } onCancel: {
128
+ Task { @MainActor [weak self] in self?.finish(request, error: CancellationError()) }
129
+ }
130
+ }
131
+
132
+ func finish(_ request: String, error: Error? = nil) {
133
+ guard let call = pending.removeValue(forKey: request) else { return }
134
+ call.deadline.cancel()
135
+ if let error { call.continuation.resume(throwing: error) }
136
+ else { call.continuation.resume() }
137
+ }
138
+
139
+ func cancel(owner: String? = nil) {
140
+ for (request, call) in pending where owner == nil || call.owner == owner {
141
+ finish(request, error: NativeUIError.unavailable)
142
+ }
143
+ }
144
+ }
145
+
146
+ /// Normal UIViewController containment keeps SwiftUI appearance and teardown intact.
147
+ @MainActor final class NativeUIExtensionController {
148
+ let controller: UIHostingController<AnyView>
149
+ var view: UIView { controller.view }
150
+
151
+ init(content: AnyView, owner: UIViewController) {
152
+ controller = UIHostingController(rootView: content)
153
+ controller.view.backgroundColor = .clear
154
+ if #available(iOS 16.4, *) { controller.safeAreaRegions = [] }
155
+ owner.addChild(controller)
156
+ // Capacitor can use WKWebView as its controller's root view. A sibling
157
+ // shell surface is outside that controller's containment hierarchy.
158
+ owner.view.addSubview(controller.view)
159
+ controller.didMove(toParent: owner)
160
+ }
161
+
162
+ func update(_ content: AnyView) { controller.rootView = content }
163
+ func detach(duration: TimeInterval) {
164
+ controller.willMove(toParent: nil)
165
+ NativeUICrossfade.retire(controller.view, duration: duration)
166
+ controller.removeFromParent()
167
+ }
168
+ }
169
+
170
+ @available(iOS 26.0, *)
171
+ struct NativeUIExtensionAccessory: ViewModifier {
172
+ let content: AnyView?
173
+ func body(content base: Content) -> some View {
174
+ if #available(iOS 26.1, *) {
175
+ base.tabViewBottomAccessory(isEnabled: content != nil) { content }
176
+ } else { base }
177
+ }
178
+ }
@@ -0,0 +1,38 @@
1
+ import UIKit
2
+
3
+ /// Image returned by the TypeScript read context's image() helper.
4
+ public struct NativeUIImage: Decodable {
5
+ public let data: String
6
+ public let width: Double
7
+ public let height: Double
8
+ public let template: Bool
9
+
10
+ public init(data: String, width: Double, height: Double, template: Bool = false) {
11
+ self.data = data
12
+ self.width = width
13
+ self.height = height
14
+ self.template = template
15
+ }
16
+ }
17
+
18
+ /// Conversion primitives shared by standard controls and app-local extensions.
19
+ public enum NativeUIRendering {
20
+ /// Decodes base64 PNG artwork at its declared CSS size. Returns nil for invalid data.
21
+ public static func image(_ image: NativeUIImage) -> UIImage? {
22
+ guard image.width.isFinite, image.height.isFinite, image.width > 0, image.height > 0,
23
+ let bytes = Data(base64Encoded: image.data), let raw = UIImage(data: bytes), let cg = raw.cgImage else { return nil }
24
+ return UIImage(cgImage: cg, scale: CGFloat(cg.width) / image.width, orientation: .up)
25
+ .withRenderingMode(image.template ? .alwaysTemplate : .alwaysOriginal)
26
+ }
27
+
28
+ /// Converts computed rgb()/rgba() colors used by standard readers. Other CSS formats use the fallback.
29
+ public static func color(_ css: String?, fallback: UIColor = .label) -> UIColor {
30
+ guard let css, css.hasPrefix("rgb(") || css.hasPrefix("rgba("), !css.contains("%") else { return fallback }
31
+ let numbers = css.components(separatedBy: CharacterSet(charactersIn: "0123456789.-").inverted).compactMap(Double.init)
32
+ guard (numbers.count == 3 || numbers.count == 4), numbers.allSatisfy(\.isFinite) else { return fallback }
33
+ return UIColor(red: min(255, max(0, numbers[0])) / 255,
34
+ green: min(255, max(0, numbers[1])) / 255,
35
+ blue: min(255, max(0, numbers[2])) / 255,
36
+ alpha: numbers.count == 4 ? min(1, max(0, numbers[3])) : 1)
37
+ }
38
+ }