@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,57 @@
1
+ export const iconSource = (svg: SVGElement, mirrored?: boolean): string | undefined => {
2
+ // A small, local SVG subset. Never load external content while projecting UI.
3
+ if (
4
+ svg.querySelector('script, foreignObject, image, use, animate, animateTransform, set, style, text') ||
5
+ /url\((?!["']?#)/.test(svg.outerHTML)
6
+ )
7
+ return undefined;
8
+ // Ionicons flips the wrapper, not the SVG, for directional icons in RTL.
9
+ if (mirrored === undefined && svg.parentElement?.matches('.icon-inner')) {
10
+ const transform = new DOMMatrixReadOnly(getComputedStyle(svg.parentElement).transform);
11
+ mirrored = transform.is2D && transform.a === -1 && transform.d === 1 && !transform.b && !transform.c && !transform.e && !transform.f;
12
+ }
13
+ const copy = svg.cloneNode(true) as SVGElement;
14
+ const originals = [svg, ...Array.from(svg.querySelectorAll('*'))];
15
+ const copies = [copy, ...Array.from(copy.querySelectorAll('*'))];
16
+ originals.forEach((node, index) => {
17
+ const style = getComputedStyle(node);
18
+ for (const property of ['fill', 'stroke', 'stroke-width', 'opacity', 'fill-opacity', 'stroke-opacity', 'color']) {
19
+ (copies[index] as SVGElement).style.setProperty(property, style.getPropertyValue(property));
20
+ }
21
+ });
22
+ copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
23
+ if (mirrored) {
24
+ copy.style.transform = 'scaleX(-1)';
25
+ copy.style.transformOrigin = 'center';
26
+ }
27
+ return new XMLSerializer().serializeToString(copy);
28
+ };
29
+
30
+ export const createIconRenderer = () => {
31
+ const cache = new Map<string, string>();
32
+ return {
33
+ clear: () => cache.clear(),
34
+ async render(source: string, width: number, height: number): Promise<string> {
35
+ const scale = devicePixelRatio || 1;
36
+ const key = `${width}:${height}:${scale}:${source}`;
37
+ const cached = cache.get(key);
38
+ if (cached) return cached;
39
+ const url = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml' }));
40
+ try {
41
+ const image = new Image();
42
+ image.src = url;
43
+ await image.decode();
44
+ const canvas = document.createElement('canvas');
45
+ canvas.width = Math.ceil(width * scale);
46
+ canvas.height = Math.ceil(height * scale);
47
+ canvas.getContext('2d')!.drawImage(image, 0, 0, canvas.width, canvas.height);
48
+ const data = canvas.toDataURL('image/png').split(',')[1];
49
+ if (cache.size >= 128) cache.delete(cache.keys().next().value!);
50
+ cache.set(key, data);
51
+ return data;
52
+ } finally {
53
+ URL.revokeObjectURL(url);
54
+ }
55
+ },
56
+ };
57
+ };
@@ -0,0 +1,157 @@
1
+ import type { Frame } from '../definitions';
2
+
3
+ /** Modal hosts cover the viewport even when their visible dialog is centered. */
4
+ export const modalRailClass = 'ios-theme-vertical-bars-modal';
5
+ const states = new WeakMap<Document, { top?: HTMLIonModalElement }>();
6
+
7
+ export const topModal = (doc: Document): HTMLIonModalElement | undefined => states.get(doc)?.top;
8
+ export const modalUsesVerticalBars = (element: Element): boolean => {
9
+ const modal = element.closest('ion-modal');
10
+ return !modal || modal.classList.contains(modalRailClass);
11
+ };
12
+
13
+ /** Only the foreground modal may contribute controls; never the covered page. */
14
+ export const inVerticalBarsSurface = (element: Element): boolean => {
15
+ const modal = topModal(element.ownerDocument);
16
+ return modal ? element.closest('ion-modal') === modal && modalUsesVerticalBars(element) : !element.closest('ion-modal');
17
+ };
18
+
19
+ export const observeVerticalBarsModals = (doc: Document): (() => void) => {
20
+ const win = doc.defaultView!;
21
+ const state: { top?: HTMLIonModalElement } = {};
22
+ states.set(doc, state);
23
+ const presenting = new Set<HTMLIonModalElement>();
24
+ const dismissed = new WeakSet<HTMLIonModalElement>();
25
+ const observed = new Set<Element>();
26
+ const listeners = new AbortController();
27
+ let stopped = false;
28
+ let frame = 0;
29
+ let pointerActive = false;
30
+ let bounds = '';
31
+ const toolbarTop = '--ios-theme-vertical-bars-modal-offset-top';
32
+ const refresh = () => {
33
+ win.cancelAnimationFrame(frame);
34
+ frame = 0;
35
+ if (stopped) return;
36
+ const app = doc.querySelector('ion-app.ios-theme-vertical-bars');
37
+ const modals = Array.from(doc.querySelectorAll<HTMLIonModalElement>('ion-modal'));
38
+ const active = modals.filter(
39
+ (modal) => !dismissed.has(modal) && ((modal as HTMLIonModalElement & { presented?: boolean }).presented || presenting.has(modal)),
40
+ );
41
+ for (const modal of presenting) if (!modal.isConnected) presenting.delete(modal);
42
+ const top = active.sort((a, b) => a.overlayIndex - b.overlayIndex).slice(-1)[0];
43
+ let changed = state.top !== top;
44
+ state.top = top;
45
+ const nextBounds = top ? JSON.stringify(modalVerticalBarFrame(top)) : '';
46
+ changed ||= bounds !== nextBounds;
47
+ bounds = nextBounds;
48
+ const wanted = new Set<Element>();
49
+ for (const modal of modals) {
50
+ const content = modal.shadowRoot?.querySelector<HTMLElement>('[part~="content"]');
51
+ if (content) wanted.add(content);
52
+ // offsetWidth is unaffected by Ionic's presentation transforms.
53
+ const fullWidth = !!content && Math.abs(content.offsetWidth - win.innerWidth) <= 1 && Math.abs(content.offsetLeft) <= 1;
54
+ const enabled = !!app?.contains(modal) && active.includes(modal) && fullWidth;
55
+ if (enabled) {
56
+ const top = modalVerticalBarFrame(modal)?.y ?? 0;
57
+ const offset = `${top}px`;
58
+ if (modal.style.getPropertyValue(toolbarTop) !== offset) {
59
+ modal.style.setProperty(toolbarTop, offset);
60
+ changed = true;
61
+ }
62
+ } else modal.style.removeProperty(toolbarTop);
63
+ if (modal.classList.contains(modalRailClass) !== enabled) {
64
+ modal.classList.toggle(modalRailClass, enabled);
65
+ changed = true;
66
+ }
67
+ }
68
+ if (wanted.size !== observed.size || [...wanted].some((element) => !observed.has(element))) {
69
+ resize?.disconnect();
70
+ observer.disconnect();
71
+ observer.observe(doc.documentElement, observation);
72
+ observed.clear();
73
+ for (const element of wanted) {
74
+ resize?.observe(element);
75
+ observer.observe(element, { attributes: true, attributeFilter: ['style', 'class'] });
76
+ observed.add(element);
77
+ }
78
+ }
79
+ if (changed) win.dispatchEvent(new Event('nativeUIRefresh'));
80
+ if (
81
+ top &&
82
+ (pointerActive ||
83
+ Array.from(wanted).some((element) => element.getAnimations().some((animation) => animation.playState === 'running')))
84
+ )
85
+ schedule();
86
+ };
87
+ const schedule = () => {
88
+ if (!stopped && !frame) frame = win.requestAnimationFrame(refresh);
89
+ };
90
+ const resize = win.ResizeObserver ? new win.ResizeObserver(schedule) : undefined;
91
+ const observer = new MutationObserver(schedule);
92
+ const observation = { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'style'] };
93
+ observer.observe(doc.documentElement, observation);
94
+ for (const name of ['ionModalWillPresent', 'ionModalDidPresent', 'ionModalWillDismiss', 'ionModalDidDismiss', 'ionBreakpointDidChange']) {
95
+ doc.addEventListener(
96
+ name,
97
+ (event) => {
98
+ const modal = event.target as HTMLIonModalElement;
99
+ if (modal.localName !== 'ion-modal') return;
100
+ if (name === 'ionModalDidDismiss') {
101
+ presenting.delete(modal);
102
+ dismissed.add(modal);
103
+ } else {
104
+ presenting.add(modal);
105
+ dismissed.delete(modal);
106
+ }
107
+ refresh();
108
+ },
109
+ { capture: true, signal: listeners.signal },
110
+ );
111
+ }
112
+ doc.addEventListener(
113
+ 'pointerdown',
114
+ (event) => {
115
+ if ((event.target as Element).closest('ion-modal')) {
116
+ pointerActive = true;
117
+ schedule();
118
+ }
119
+ },
120
+ { capture: true, signal: listeners.signal },
121
+ );
122
+ for (const name of ['pointerup', 'pointercancel'])
123
+ doc.addEventListener(
124
+ name,
125
+ () => {
126
+ pointerActive = false;
127
+ schedule();
128
+ },
129
+ { capture: true, signal: listeners.signal },
130
+ );
131
+ win.addEventListener('resize', schedule, { signal: listeners.signal });
132
+ win.addEventListener('nativeUIRefresh', schedule, { signal: listeners.signal });
133
+ refresh();
134
+ return () => {
135
+ stopped = true;
136
+ win.cancelAnimationFrame(frame);
137
+ observer.disconnect();
138
+ resize?.disconnect();
139
+ listeners.abort();
140
+ if (states.get(doc) !== state) return;
141
+ states.delete(doc);
142
+ doc.querySelectorAll<HTMLElement>(`.${modalRailClass}`).forEach((modal) => {
143
+ modal.classList.remove(modalRailClass);
144
+ modal.style.removeProperty(toolbarTop);
145
+ });
146
+ };
147
+ };
148
+
149
+ /** Visible modal bounds, clipped at the viewport for Ionic's translated sheets. */
150
+ export const modalVerticalBarFrame = (modal: Element): Frame | undefined => {
151
+ const rect = modal.shadowRoot?.querySelector('[part~="content"]')?.getBoundingClientRect();
152
+ const win = modal.ownerDocument.defaultView!;
153
+ if (!rect) return;
154
+ const y = Math.max(0, rect.top);
155
+ const height = Math.min(win.innerHeight, rect.bottom) - y;
156
+ return height > 0 ? { x: 0, y, width: win.innerWidth, height } : undefined;
157
+ };
@@ -0,0 +1,2 @@
1
+ /** Internal identity used to anchor overlays to their native control. */
2
+ export const projectionIds = new WeakMap<Element, string>();
@@ -0,0 +1,49 @@
1
+ import { modalUsesVerticalBars } from './modal';
2
+ import type { VerticalControlAreaOptions } from '../definitions';
3
+ import { controlDefault } from './control-options';
4
+ import { childElements, inFixedToolbar, isExcluded, isNativeUIDisabled } from './dom';
5
+
6
+ export const toolbarOptions = (options: VerticalControlAreaOptions, vertical: boolean) => ({
7
+ buttonGroup: controlDefault(options, 'buttonGroup', vertical),
8
+ button: controlDefault(options, 'button', vertical),
9
+ backButton: controlDefault(options, 'backButton', vertical),
10
+ });
11
+
12
+ export const localToolbarOption = (element: HTMLElement, vertical: boolean): boolean | undefined => {
13
+ const value = element.getAttribute(vertical ? 'data-vertical-bars' : 'data-horizontal-bars');
14
+ return value === 'true' ? true : value === 'false' ? false : undefined;
15
+ };
16
+
17
+ export const horizontalSelected = (element: HTMLElement, options: VerticalControlAreaOptions): boolean => {
18
+ const group = element.parentElement;
19
+ if (group?.matches('ion-buttons') && isNativeUIDisabled(group)) return false;
20
+ return localToolbarOption(element, false) ?? toolbarOptions(options, false)[element.matches('ion-back-button') ? 'backButton' : 'button'];
21
+ };
22
+
23
+ // Group only if all original children qualify; otherwise selected actions remain individual.
24
+ // Back navigation never joins an ordinary action group.
25
+ export const horizontalToolbarGroup = (element: HTMLElement, options: VerticalControlAreaOptions): boolean => {
26
+ if (
27
+ !element.matches('ion-buttons.ios') ||
28
+ !(localToolbarOption(element, false) ?? toolbarOptions(options, false).buttonGroup) ||
29
+ !inFixedToolbar(element) ||
30
+ isExcluded(element) ||
31
+ isNativeUIDisabled(element)
32
+ )
33
+ return false;
34
+ const children = childElements(element);
35
+ return (
36
+ children.length > 0 &&
37
+ children.every(
38
+ (child) =>
39
+ horizontalSelected(child, options) &&
40
+ !isExcluded(child) &&
41
+ !isNativeUIDisabled(child) &&
42
+ (child.matches('ion-menu-button.ios') ||
43
+ (children.length > 1 && child.matches('ion-button.ios.button-clear') && (child as HTMLIonButtonElement).fill === 'clear')),
44
+ )
45
+ );
46
+ };
47
+
48
+ export const inVerticalToolbarLayout = (element: HTMLElement): boolean =>
49
+ !element.closest('ion-menu, ion-popover') && modalUsesVerticalBars(element) && !!element.closest('ion-app.ios-theme-vertical-bars');
@@ -0,0 +1,80 @@
1
+ import type { Frame } from '../definitions';
2
+
3
+ const measurements = new WeakMap<Element, { frame: Frame; viewport: string }>();
4
+ const viewport = (element: Element) => {
5
+ const win = element.ownerDocument.defaultView!;
6
+ const classes = element.closest('ion-app')?.classList;
7
+ return `${win.innerWidth}:${win.innerHeight}:${classes?.contains('ios-theme-vertical-bars')}:${classes?.contains('ios-theme-vertical-bars-left')}`;
8
+ };
9
+
10
+ export const validVerticalBarFrame = (frame: Frame) =>
11
+ [frame.x, frame.y, frame.width, frame.height].every(Number.isFinite) && frame.width > 0 && frame.height > 0;
12
+
13
+ export const forgetVerticalBarFrame = (element: Element) => measurements.delete(element);
14
+
15
+ export const rememberVerticalBarFrame = (element: Element, frame: Frame) => {
16
+ if (validVerticalBarFrame(frame)) measurements.set(element, { frame, viewport: viewport(element) });
17
+ };
18
+
19
+ export const verticalBarFrame = (element: Element): Frame | undefined => {
20
+ const saved = measurements.get(element);
21
+ return saved?.viewport === viewport(element) ? saved.frame : undefined;
22
+ };
23
+
24
+ /** Convert the native viewport rectangle into the clone's positioning container. */
25
+ export const alignVerticalBarClone = (clone: HTMLElement, source: Element, parent: HTMLElement) => {
26
+ const frame = verticalBarFrame(source);
27
+ if (!frame) return;
28
+ const origin = parent.getBoundingClientRect();
29
+ Object.assign(clone.style, {
30
+ position: 'absolute',
31
+ left: `${frame.x - origin.left}px`,
32
+ top: `${frame.y - origin.top}px`,
33
+ right: 'auto',
34
+ bottom: 'auto',
35
+ width: `${frame.width}px`,
36
+ height: `${frame.height}px`,
37
+ minWidth: `${frame.width}px`,
38
+ maxWidth: `${frame.width}px`,
39
+ minHeight: `${frame.height}px`,
40
+ maxHeight: `${frame.height}px`,
41
+ margin: '0',
42
+ boxSizing: 'border-box',
43
+ });
44
+ };
45
+
46
+ export const alignVerticalTabBar = (tabBar: HTMLElement, frame: Frame, items: Map<HTMLElement, Frame>): (() => void) => {
47
+ const doc = tabBar.ownerDocument;
48
+ const style = doc.createElement('style');
49
+ const token = `rail-${Math.random().toString(36).slice(2)}`;
50
+ const selector = `[data-native-rail-geometry="${token}"]`;
51
+ const origin = (tabBar.offsetParent as HTMLElement | null)?.getBoundingClientRect() ?? { left: 0, top: 0 };
52
+ tabBar.setAttribute('data-native-rail-geometry', token);
53
+ const box = (rect: Frame, x: number, y: number) =>
54
+ `position:absolute!important;left:${rect.x - x}px!important;top:${rect.y - y}px!important;right:auto!important;bottom:auto!important;width:${rect.width}px!important;min-width:${rect.width}px!important;max-width:${rect.width}px!important;height:${rect.height}px!important;min-height:${rect.height}px!important;max-height:${rect.height}px!important;margin:0!important;padding:0!important;box-sizing:border-box!important;`;
55
+ style.textContent =
56
+ `${selector}{${box(frame, origin.left, origin.top)}}` +
57
+ [...items]
58
+ .map(([element, rect]) => {
59
+ const index = Array.from(tabBar.children).indexOf(element) + 1;
60
+ return `${selector}>:nth-child(${index}){${box(rect, frame.x, frame.y)}}${selector}>:nth-child(${index})::part(native){min-height:0!important}`;
61
+ })
62
+ .join('');
63
+ doc.head.append(style);
64
+ const win = doc.defaultView!;
65
+ const measuredViewport = viewport(tabBar);
66
+ const stop = () => {
67
+ observer.disconnect();
68
+ win.removeEventListener('resize', stop);
69
+ style.remove();
70
+ if (tabBar.getAttribute('data-native-rail-geometry') === token) tabBar.removeAttribute('data-native-rail-geometry');
71
+ };
72
+ const observer = new MutationObserver((records) => {
73
+ if (viewport(tabBar) !== measuredViewport || records.some((record) => record.type === 'childList')) stop();
74
+ });
75
+ observer.observe(tabBar, { childList: true });
76
+ const app = tabBar.closest('ion-app');
77
+ if (app) observer.observe(app, { attributes: true, attributeFilter: ['class'] });
78
+ win.addEventListener('resize', stop);
79
+ return stop;
80
+ };
@@ -0,0 +1,68 @@
1
+ import { enableNativeUI as activate } from './activation';
2
+ import type { NativeUIRuntimeOptions, NativeUIHandle, NativeUIOptions, NativeUIRuntimeHandle } from './definitions';
3
+
4
+ type OverlayFactory = Parameters<typeof activate>[1];
5
+ const handles = new WeakMap<NativeUIRuntimeHandle, NativeUIHandle>();
6
+
7
+ /** Start one shell. Partial options override defaults; markup owns individual placement. */
8
+ export const startNativeUI = async (input: NativeUIOptions = {}, overlayFactory?: OverlayFactory): Promise<NativeUIHandle> => {
9
+ const root = input.root ?? (typeof document === 'undefined' ? undefined : document.body);
10
+ const bars = { enabled: true, ...input.verticalBars };
11
+ const host = bars.placement?.webHost;
12
+ if (root && (!root.isConnected || root.ownerDocument !== document))
13
+ throw new Error('Native UI requires a connected root in the current document');
14
+ if (host && (!host.isConnected || host.ownerDocument !== document || root?.contains(host) || host.contains(root!)))
15
+ throw new Error('The Web rail host must be connected in the current document and outside the source root');
16
+ const options: NativeUIRuntimeOptions = {
17
+ ...input,
18
+ root,
19
+ source: 'auto',
20
+ verticalBars: bars,
21
+ rail: host ? { host, edge: bars.placement?.edge === 'leading' ? 'left' : 'right' } : undefined,
22
+ };
23
+ const runtime = await activate(options, overlayFactory);
24
+ const existing = handles.get(runtime);
25
+ if (existing) return existing;
26
+ let stopped = false;
27
+ let queue = Promise.resolve();
28
+ const change = (enabled: boolean) => {
29
+ const operation = queue.then(async () => {
30
+ if (stopped) throw new Error('Native UI has been destroyed');
31
+ if (bars.enabled === enabled) return;
32
+ // Retire both surfaces before changing ownership. Resume also waits for native reconciliation.
33
+ const lease = await runtime.suspend();
34
+ try {
35
+ if (stopped) throw new Error('Native UI has been destroyed');
36
+ bars.enabled = enabled;
37
+ root?.ownerDocument.defaultView?.dispatchEvent(new Event('nativeUIRefresh'));
38
+ } finally {
39
+ await lease.resume();
40
+ }
41
+ });
42
+ queue = operation.catch(() => {});
43
+ return operation;
44
+ };
45
+ let destruction: Promise<void> | undefined;
46
+ const destroy = runtime.destroy.bind(runtime);
47
+ const handle: NativeUIHandle = {
48
+ markForCheck: runtime.markForCheck.bind(runtime),
49
+ getStatus: runtime.getStatus.bind(runtime),
50
+ suspend: runtime.suspend.bind(runtime),
51
+ enableVerticalControlArea: () => change(true),
52
+ disableVerticalControlArea: () => change(false),
53
+ destroy() {
54
+ if (!destruction) {
55
+ stopped = true;
56
+ // Reserve native teardown immediately, preserving the shared startup queue.
57
+ destruction = destroy();
58
+ }
59
+ return destruction;
60
+ },
61
+ };
62
+ const managed = Object.assign(runtime, handle);
63
+ handles.set(runtime, managed);
64
+ return managed;
65
+ };
66
+
67
+ /** Enable the shared standard shell without loading the optional Ionic overlay adapter. */
68
+ export const enableNativeUI = (options: NativeUIOptions = {}): Promise<NativeUIHandle> => startNativeUI(options);
@@ -0,0 +1,86 @@
1
+ import { getNativeSearchBindings, type NativeSearchBinding } from '../native-integration';
2
+ import { activateProjectedElement, marker, unprojected } from './shared/dom';
3
+
4
+ export const searchProjectionClass = 'ios-theme-vertical-bars-search-projection';
5
+
6
+ /** Web counterpart of the system search button above the vertical tabs, or at the rail bottom without visible tabs. */
7
+ export const createVerticalBarsWebSearch = (doc: Document, eligible: (element: HTMLElement) => boolean, refresh: () => void) => {
8
+ let binding: NativeSearchBinding | undefined;
9
+ let projection: HTMLIonFabButtonElement | undefined;
10
+ let container: HTMLIonFabElement | undefined;
11
+ const iconObserver = new MutationObserver(refresh);
12
+ let previousAriaHidden: string | null = null;
13
+ const restore = (retire?: (surface: HTMLElement) => void) => {
14
+ iconObserver.disconnect();
15
+ if (container) {
16
+ if (retire) retire(container);
17
+ else container.remove();
18
+ }
19
+ container = undefined;
20
+ projection = undefined;
21
+ if (binding) {
22
+ binding.trigger.removeAttribute(marker);
23
+ if (previousAriaHidden === null) binding.trigger.removeAttribute('aria-hidden');
24
+ else binding.trigger.setAttribute('aria-hidden', previousAriaHidden);
25
+ }
26
+ binding = undefined;
27
+ };
28
+ return {
29
+ restore,
30
+ get surface() {
31
+ return container;
32
+ },
33
+ get source() {
34
+ return binding?.trigger;
35
+ },
36
+ update(root: HTMLElement) {
37
+ const next = unprojected(binding ? [binding.trigger] : [], () =>
38
+ getNativeSearchBindings(doc).find(({ trigger, footer }) => eligible(trigger) && eligible(footer)),
39
+ );
40
+ if (next !== binding || container?.parentElement !== root) restore();
41
+ if (!next) return;
42
+ if (!projection) {
43
+ binding = next;
44
+ previousAriaHidden = next.trigger.getAttribute('aria-hidden');
45
+ projection = next.trigger.cloneNode(true) as HTMLIonFabButtonElement;
46
+ for (const name of ['id', 'slot', marker, 'aria-hidden', 'style']) projection.removeAttribute(name);
47
+ container = doc.createElement('ion-fab');
48
+ container.setAttribute('mode', (next.trigger as HTMLIonFabButtonElement).mode ?? 'ios');
49
+ container.classList.add(searchProjectionClass, 'ion-cloned-element');
50
+ projection.addEventListener(
51
+ 'click',
52
+ (event) => {
53
+ event.preventDefault();
54
+ event.stopImmediatePropagation();
55
+ activateProjectedElement(next.trigger);
56
+ },
57
+ { capture: true },
58
+ );
59
+ container.append(projection);
60
+ root.append(container);
61
+ next.trigger.setAttribute(marker, '');
62
+ next.trigger.setAttribute('aria-hidden', 'true');
63
+ }
64
+ projection.setAttribute(
65
+ 'aria-label',
66
+ next.trigger.getAttribute('aria-label') ?? next.footer.querySelector('ion-searchbar')?.getAttribute('aria-label') ?? 'Search',
67
+ );
68
+ const sourceIcon = next.trigger.querySelector('ion-icon');
69
+ const icon = projection.querySelector('ion-icon');
70
+ if (sourceIcon?.shadowRoot) iconObserver.observe(sourceIcon.shadowRoot, { subtree: true, childList: true, attributes: true });
71
+ if (sourceIcon && icon) {
72
+ icon.icon = sourceIcon.icon;
73
+ icon.name = sourceIcon.name;
74
+ icon.src = sourceIcon.src;
75
+ }
76
+ container!.hidden = next.trigger.style.pointerEvents === 'none';
77
+ projection.disabled = (next.trigger as HTMLIonFabButtonElement).disabled;
78
+ const bounds = next.tabBar.getBoundingClientRect();
79
+ const tabs = bounds.width > 0 && bounds.height > 0 && getComputedStyle(next.tabBar).visibility === 'visible' ? bounds : undefined;
80
+ container!.style.left = tabs ? `${tabs.left + (tabs.width - 46) / 2}px` : '';
81
+ container!.style.top = tabs ? `${tabs.top - 56}px` : '';
82
+ container!.style.right = tabs ? 'auto' : '';
83
+ container!.style.bottom = tabs ? 'auto' : '';
84
+ },
85
+ };
86
+ };