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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +48 -35
  2. package/WebViewNativeUIControls.podspec +17 -0
  3. package/dist/controls/activation.d.ts +15 -0
  4. package/dist/controls/activation.js +370 -0
  5. package/dist/controls/components/index.d.ts +16 -0
  6. package/dist/controls/components/index.js +38 -0
  7. package/dist/controls/components/ion-back-button.d.ts +4 -0
  8. package/dist/controls/components/ion-back-button.js +16 -0
  9. package/dist/controls/components/ion-button.d.ts +4 -0
  10. package/dist/controls/components/ion-button.js +26 -0
  11. package/dist/controls/components/ion-buttons.d.ts +5 -0
  12. package/dist/controls/components/ion-buttons.js +34 -0
  13. package/dist/controls/components/ion-fab.d.ts +4 -0
  14. package/dist/controls/components/ion-fab.js +164 -0
  15. package/dist/controls/components/ion-menu-button.d.ts +7 -0
  16. package/dist/controls/components/ion-menu-button.js +41 -0
  17. package/dist/controls/components/ion-segment.d.ts +5 -0
  18. package/dist/controls/components/ion-segment.js +24 -0
  19. package/dist/controls/components/ion-tab-bar.d.ts +5 -0
  20. package/dist/controls/components/ion-tab-bar.js +49 -0
  21. package/dist/controls/components/searchable-tabs.d.ts +20 -0
  22. package/dist/controls/components/searchable-tabs.js +525 -0
  23. package/dist/controls/definitions.d.ts +360 -0
  24. package/dist/controls/definitions.js +1 -0
  25. package/dist/controls/extensions.d.ts +44 -0
  26. package/dist/controls/extensions.js +164 -0
  27. package/dist/controls/foldable/fold-state.d.ts +4 -0
  28. package/dist/controls/foldable/fold-state.js +8 -0
  29. package/dist/controls/foldable/types.d.ts +9 -0
  30. package/dist/controls/foldable/types.js +1 -0
  31. package/dist/controls/html.d.ts +8 -0
  32. package/dist/controls/html.js +260 -0
  33. package/dist/controls/index.d.ts +7 -0
  34. package/dist/controls/index.js +4 -0
  35. package/dist/controls/ionic.d.ts +1 -0
  36. package/dist/controls/ionic.js +1 -0
  37. package/dist/controls/legacy-layout.d.ts +7 -0
  38. package/dist/controls/legacy-layout.js +23 -0
  39. package/dist/controls/overlays/content.d.ts +2 -0
  40. package/dist/controls/overlays/content.js +55 -0
  41. package/dist/controls/overlays/controller.d.ts +7 -0
  42. package/dist/controls/overlays/controller.js +369 -0
  43. package/dist/controls/overlays/covered-overlay.d.ts +4 -0
  44. package/dist/controls/overlays/covered-overlay.js +9 -0
  45. package/dist/controls/overlays/dialog-animation.d.ts +7 -0
  46. package/dist/controls/overlays/dialog-animation.js +49 -0
  47. package/dist/controls/overlays/dismiss-animation.d.ts +2 -0
  48. package/dist/controls/overlays/dismiss-animation.js +14 -0
  49. package/dist/controls/overlays/focus.d.ts +2 -0
  50. package/dist/controls/overlays/focus.js +67 -0
  51. package/dist/controls/overlays/popover.d.ts +10 -0
  52. package/dist/controls/overlays/popover.js +117 -0
  53. package/dist/controls/overlays/styles.d.ts +9 -0
  54. package/dist/controls/overlays/styles.js +194 -0
  55. package/dist/controls/prehide.d.ts +6 -0
  56. package/dist/controls/prehide.js +280 -0
  57. package/dist/controls/runtime.d.ts +5 -0
  58. package/dist/controls/runtime.js +1685 -0
  59. package/dist/controls/shared/bridge.d.ts +4 -0
  60. package/dist/controls/shared/bridge.js +15 -0
  61. package/dist/controls/shared/candidate.d.ts +23 -0
  62. package/dist/controls/shared/candidate.js +128 -0
  63. package/dist/controls/shared/clipping.d.ts +1 -0
  64. package/dist/controls/shared/clipping.js +1 -0
  65. package/dist/controls/shared/connection.d.ts +21 -0
  66. package/dist/controls/shared/connection.js +112 -0
  67. package/dist/controls/shared/control-options.d.ts +4 -0
  68. package/dist/controls/shared/control-options.js +14 -0
  69. package/dist/controls/shared/covered-snapshot.d.ts +35 -0
  70. package/dist/controls/shared/covered-snapshot.js +41 -0
  71. package/dist/controls/shared/covered-source.d.ts +22 -0
  72. package/dist/controls/shared/covered-source.js +72 -0
  73. package/dist/controls/shared/crossfade.d.ts +11 -0
  74. package/dist/controls/shared/crossfade.js +71 -0
  75. package/dist/controls/shared/diagnostics.d.ts +2 -0
  76. package/dist/controls/shared/diagnostics.js +16 -0
  77. package/dist/controls/shared/dom.d.ts +45 -0
  78. package/dist/controls/shared/dom.js +258 -0
  79. package/dist/controls/shared/events.d.ts +12 -0
  80. package/dist/controls/shared/events.js +13 -0
  81. package/dist/controls/shared/horizontal-navigation.d.ts +36 -0
  82. package/dist/controls/shared/horizontal-navigation.js +90 -0
  83. package/dist/controls/shared/icons.d.ts +5 -0
  84. package/dist/controls/shared/icons.js +66 -0
  85. package/dist/controls/shared/modal-animation.d.ts +21 -0
  86. package/dist/controls/shared/modal-animation.js +97 -0
  87. package/dist/controls/shared/modal.d.ts +26 -0
  88. package/dist/controls/shared/modal.js +261 -0
  89. package/dist/controls/shared/projection-id.d.ts +2 -0
  90. package/dist/controls/shared/projection-id.js +2 -0
  91. package/dist/controls/shared/scroll-placement.d.ts +1 -0
  92. package/dist/controls/shared/scroll-placement.js +1 -0
  93. package/dist/controls/shared/tab-bar-width.d.ts +7 -0
  94. package/dist/controls/shared/tab-bar-width.js +72 -0
  95. package/dist/controls/shared/toolbar-options.d.ts +10 -0
  96. package/dist/controls/shared/toolbar-options.js +38 -0
  97. package/dist/controls/shared/transition-identity.d.ts +3 -0
  98. package/dist/controls/shared/transition-identity.js +64 -0
  99. package/dist/controls/shared/vertical-bars-geometry.d.ts +8 -0
  100. package/dist/controls/shared/vertical-bars-geometry.js +82 -0
  101. package/dist/controls/shared/vertical-navigation.d.ts +5 -0
  102. package/dist/controls/shared/vertical-navigation.js +39 -0
  103. package/dist/controls/startup.d.ts +7 -0
  104. package/dist/controls/startup.js +72 -0
  105. package/dist/controls/transport.d.ts +10 -0
  106. package/dist/controls/transport.js +1 -0
  107. package/dist/controls/vertical-bars-web-search.d.ts +8 -0
  108. package/dist/controls/vertical-bars-web-search.js +83 -0
  109. package/dist/controls/vertical-bars-web.d.ts +2 -0
  110. package/dist/controls/vertical-bars-web.js +609 -0
  111. package/dist/index.d.ts +1 -0
  112. package/dist/index.js +1 -0
  113. package/dist/integration/index.d.ts +36 -0
  114. package/dist/integration/index.js +25 -0
  115. package/dist/native/bootstrap.js +1 -1
  116. package/dist/native/session.js +14 -8
  117. package/dist/projection/pause.d.ts +10 -0
  118. package/dist/projection/pause.js +58 -0
  119. package/dist/projection/styles.d.ts +1 -1
  120. package/dist/projection/styles.js +0 -1
  121. package/dist/projection/surfaces.js +11 -8
  122. package/dist/projection/web.js +48 -13
  123. package/dist/types.d.ts +5 -1
  124. package/docs/README.md +17 -10
  125. package/docs/concepts.md +39 -14
  126. package/docs/configuration/README.md +4 -2
  127. package/docs/configuration/ionic-overlays.md +36 -2
  128. package/docs/configuration/pause-resume.md +51 -0
  129. package/docs/controls.md +39 -0
  130. package/docs/getting-started/capacitor.md +3 -3
  131. package/docs/getting-started/cordova.md +3 -1
  132. package/docs/getting-started/initialization.md +18 -2
  133. package/docs/reference/platforms.md +2 -2
  134. package/docs/reference/projection.md +1 -1
  135. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift +98 -0
  136. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift +24 -0
  137. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIComponents.swift +19 -0
  138. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift +80 -0
  139. package/ios/Sources/WebViewNativeUIControls/Components/NativeUISearchController.swift +449 -0
  140. package/ios/Sources/WebViewNativeUIControls/Components/NativeUISegment.swift +91 -0
  141. package/ios/Sources/WebViewNativeUIControls/Components/NativeUITabBar.swift +213 -0
  142. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIToolbarTransition.swift +186 -0
  143. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalBars.swift +696 -0
  144. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalSearch.swift +117 -0
  145. package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUI.swift +180 -0
  146. package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUIRendering.swift +38 -0
  147. package/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +801 -0
  148. package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayController.swift +131 -0
  149. package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayHost.swift +85 -0
  150. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIControlRendering.swift +21 -0
  151. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICoveredSnapshots.swift +14 -0
  152. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICrossfade.swift +35 -0
  153. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIHost.swift +8 -0
  154. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIScrollTracker.swift +2 -0
  155. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUISnapshot.swift +206 -0
  156. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIVerticalBarsGeometry.swift +93 -0
  157. package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +2 -2
  158. package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +6 -4
  159. package/ios/Sources/WebViewNativeUIEngine/ProjectionOrigins.swift +31 -0
  160. package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +3 -4
  161. package/package.json +30 -5
  162. package/src/controls/activation.ts +427 -0
  163. package/src/controls/components/index.ts +45 -0
  164. package/src/controls/components/ion-back-button.ts +22 -0
  165. package/src/controls/components/ion-button.ts +28 -0
  166. package/src/controls/components/ion-buttons.ts +37 -0
  167. package/src/controls/components/ion-fab.ts +151 -0
  168. package/src/controls/components/ion-menu-button.ts +37 -0
  169. package/src/controls/components/ion-segment.ts +27 -0
  170. package/src/controls/components/ion-tab-bar.ts +55 -0
  171. package/src/controls/components/searchable-tabs.ts +534 -0
  172. package/src/controls/definitions.ts +338 -0
  173. package/src/controls/extensions.ts +198 -0
  174. package/src/controls/foldable/fold-state.ts +10 -0
  175. package/src/controls/foldable/types.ts +4 -0
  176. package/src/controls/html.ts +239 -0
  177. package/src/controls/index.ts +7 -0
  178. package/src/controls/ionic.ts +1 -0
  179. package/src/controls/legacy-layout.ts +21 -0
  180. package/src/controls/overlays/content.ts +51 -0
  181. package/src/controls/overlays/controller.ts +394 -0
  182. package/src/controls/overlays/covered-overlay.ts +11 -0
  183. package/src/controls/overlays/dialog-animation.ts +53 -0
  184. package/src/controls/overlays/dismiss-animation.ts +15 -0
  185. package/src/controls/overlays/focus.ts +64 -0
  186. package/src/controls/overlays/popover.ts +116 -0
  187. package/src/controls/overlays/styles.ts +195 -0
  188. package/src/controls/prehide.ts +285 -0
  189. package/src/controls/runtime.ts +1731 -0
  190. package/src/controls/shared/bridge.ts +18 -0
  191. package/src/controls/shared/candidate.ts +163 -0
  192. package/src/controls/shared/clipping.ts +1 -0
  193. package/src/controls/shared/connection.ts +105 -0
  194. package/src/controls/shared/control-options.ts +20 -0
  195. package/src/controls/shared/covered-snapshot.ts +52 -0
  196. package/src/controls/shared/covered-source.ts +78 -0
  197. package/src/controls/shared/crossfade.ts +70 -0
  198. package/src/controls/shared/diagnostics.ts +13 -0
  199. package/src/controls/shared/dom.ts +310 -0
  200. package/src/controls/shared/events.ts +14 -0
  201. package/src/controls/shared/horizontal-navigation.ts +106 -0
  202. package/src/controls/shared/icons.ts +64 -0
  203. package/src/controls/shared/modal-animation.ts +109 -0
  204. package/src/controls/shared/modal.ts +286 -0
  205. package/src/controls/shared/projection-id.ts +2 -0
  206. package/src/controls/shared/scroll-placement.ts +1 -0
  207. package/src/controls/shared/tab-bar-width.ts +66 -0
  208. package/src/controls/shared/toolbar-options.ts +51 -0
  209. package/src/controls/shared/transition-identity.ts +67 -0
  210. package/src/controls/shared/vertical-bars-geometry.ts +92 -0
  211. package/src/controls/shared/vertical-navigation.ts +34 -0
  212. package/src/controls/startup.ts +74 -0
  213. package/src/controls/transport.ts +11 -0
  214. package/src/controls/vertical-bars-web-search.ts +86 -0
  215. package/src/controls/vertical-bars-web.ts +674 -0
  216. package/src/dom/geometry.ts +24 -0
  217. package/src/dom/overlays.ts +196 -0
  218. package/src/dom/scroll-bindings.ts +66 -0
  219. package/src/dom/scroll-placement.ts +92 -0
  220. package/src/dom/visibility.ts +29 -0
  221. package/src/index.ts +19 -0
  222. package/src/integration/index.ts +64 -0
  223. package/src/ionic.ts +34 -0
  224. package/src/native/bootstrap.ts +132 -0
  225. package/src/native/bridge.ts +18 -0
  226. package/src/native/resource-url.ts +11 -0
  227. package/src/native/session.ts +708 -0
  228. package/src/native/updates.ts +103 -0
  229. package/src/native/values.ts +29 -0
  230. package/src/projection/events.ts +58 -0
  231. package/src/projection/pause.ts +58 -0
  232. package/src/projection/placement.ts +34 -0
  233. package/src/projection/scope.ts +13 -0
  234. package/src/projection/styles.ts +26 -0
  235. package/src/projection/surface.ts +88 -0
  236. package/src/projection/surfaces.ts +119 -0
  237. package/src/projection/web.ts +1013 -0
  238. package/src/types.ts +58 -0
@@ -0,0 +1,310 @@
1
+ import { getUnprojectedVisibility } from '../../index.js';
2
+
3
+ import { fadeMarker } from './crossfade.js';
4
+ import { modalUsesVerticalBars } from './modal.js';
5
+
6
+ import type { Frame } from '../definitions.js';
7
+
8
+ export const marker = 'data-native-ui-projected';
9
+ // Scoped runtimes (Stencil slot emulation) patch child accessors on upgraded
10
+ // elements and may report none before hydration; the prototype getter still
11
+ // reports the real light DOM.
12
+ const childNodesGet = typeof Node === 'undefined' ? undefined : Object.getOwnPropertyDescriptor(Node.prototype, 'childNodes')?.get;
13
+ export const childNodesOf = (element: Element): Node[] =>
14
+ childNodesGet ? Array.from(childNodesGet.call(element)) : Array.from(element.childNodes);
15
+ export const childElements = (element: Element): HTMLElement[] =>
16
+ childNodesOf(element).filter(
17
+ (node) => node.nodeType === 1 && !(node as Element).hasAttribute('data-native-ui-projection-node'),
18
+ ) as HTMLElement[];
19
+ export const prehiddenClass = 'native-ui-prehidden';
20
+ export const prehideRootClass = 'native-ui-prehide';
21
+ export const rejectedClass = 'native-ui-rejected';
22
+ export const verticalBarsBackWebClass = 'ios-theme-vertical-bars-back-web-owned';
23
+ const prehideClasses = new Set([prehiddenClass, prehideRootClass]);
24
+ export const prehideOnlyMutation = (record: MutationRecord): boolean => {
25
+ if (record.attributeName !== 'class' || record.oldValue === null) return false;
26
+ const withoutPrehide = (value: string) =>
27
+ value
28
+ .split(/\s+/)
29
+ .filter((name) => name && !prehideClasses.has(name))
30
+ .join(' ');
31
+ return withoutPrehide(record.oldValue) === withoutPrehide((record.target as Element).getAttribute('class') ?? '');
32
+ };
33
+
34
+ export const withoutPrehide = <T>(element: HTMLElement, read: () => T): T => {
35
+ const changed: HTMLElement[] = [];
36
+ for (let current: HTMLElement | null = element; current; current = current.parentElement) {
37
+ if (current.classList.contains(prehiddenClass)) {
38
+ current.classList.remove(prehiddenClass);
39
+ changed.push(current);
40
+ }
41
+ }
42
+ const root = element.ownerDocument.documentElement;
43
+ if (element.matches('ion-back-button') && root.classList.contains(prehideRootClass)) {
44
+ root.classList.remove(prehideRootClass);
45
+ changed.push(root);
46
+ }
47
+ try {
48
+ return read();
49
+ } finally {
50
+ changed.forEach((current) => current.classList.add(current === root ? prehideRootClass : prehiddenClass));
51
+ }
52
+ };
53
+ export const isDark = (style: CSSStyleDeclaration): boolean => {
54
+ const themeScheme = style.getPropertyValue('--ios27-color-scheme').trim();
55
+ if (themeScheme) return themeScheme === 'dark';
56
+ const background = style.getPropertyValue('--ion-background-color-rgb').match(/\d+/g)?.slice(0, 3).map(Number);
57
+ if (background?.length === 3) return background[0] * 0.2126 + background[1] * 0.7152 + background[2] * 0.0722 < 128;
58
+ const schemes = (style.colorScheme ?? '').split(/\s+/);
59
+ if (schemes.includes('dark') && !schemes.includes('light')) return true;
60
+ if (schemes.includes('light') && !schemes.includes('dark')) return false;
61
+ return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches;
62
+ };
63
+ const permanentlyExcluded = '.ios-theme-disabled, .ios26-disabled, .ion-cloned-element, [hidden], [inert]';
64
+ export const excluded = `${permanentlyExcluded}, .ion-page-hidden, .ion-page-invisible`;
65
+ const enteringPages = new WeakSet<HTMLElement>();
66
+ export const setVerticalBarsEnteringPage = (page: HTMLElement, entering: boolean): void => {
67
+ if (entering) enteringPages.add(page);
68
+ else enteringPages.delete(page);
69
+ };
70
+ export const verticalBarsEnteringPage = (element: HTMLElement): HTMLElement | undefined => {
71
+ const page = element.closest<HTMLElement>('.ion-page-invisible');
72
+ return page && enteringPages.has(page) && page.closest('ion-app.ios-theme-vertical-bars') ? page : undefined;
73
+ };
74
+ export const createVerticalBarsPageState = () => {
75
+ const departed = new WeakSet<HTMLElement>();
76
+ return {
77
+ isDeparted(element: HTMLElement, includeHorizontal = false): boolean {
78
+ const page = element.closest<HTMLElement>('.ion-page');
79
+ return !!page && departed.has(page) && (includeHorizontal || !!element.closest('ion-app.ios-theme-vertical-bars'));
80
+ },
81
+ lifecycle(event: Event): void {
82
+ // Avoid instanceof so stale listeners stay safe after global teardown.
83
+ const page = event.target && (event.target as Node).nodeType === 1 ? (event.target as HTMLElement) : undefined;
84
+ if (!page?.matches('.ion-page')) return;
85
+ const entering = event.type === 'ionViewWillEnter';
86
+ if (entering || event.type === 'ionViewDidEnter') departed.delete(page);
87
+ else if (event.type === 'ionViewWillLeave' || event.type === 'ionViewDidLeave') departed.add(page);
88
+ setVerticalBarsEnteringPage(page, entering);
89
+ },
90
+ cancel(entering?: HTMLElement, leaving?: HTMLElement): void {
91
+ if (entering) {
92
+ departed.add(entering);
93
+ setVerticalBarsEnteringPage(entering, false);
94
+ }
95
+ if (leaving) departed.delete(leaving);
96
+ },
97
+ };
98
+ };
99
+ const disabledButtonGroup = 'ion-buttons:is(.ios-theme-disabled, .ios26-disabled)';
100
+ const nativeUIDisabledSelector = '.native-ui-disabled, [data-native-ui="disabled"]';
101
+
102
+ export const isDisabledButtonGroupChild = (element: HTMLElement): boolean =>
103
+ element.matches('ion-button') && element.parentElement?.matches(disabledButtonGroup) === true;
104
+
105
+ const verticalBarsTags = new Set(['ion-button', 'ion-back-button', 'ion-buttons', 'ion-menu-button', 'ion-tab-bar']);
106
+
107
+ export const isVerticalBarsSource = (element: HTMLElement): boolean =>
108
+ !element.closest('[data-native-ui-placement="element"]') &&
109
+ (element.matches(
110
+ '[data-native-ui-placement="rail"][data-native-ui="button"], [data-native-ui-placement="rail"][data-native-ui="back-button"]',
111
+ ) ||
112
+ (verticalBarsTags.has(element.localName) &&
113
+ (element.matches('ion-tab-bar') || verticalBarsOwned(element)) &&
114
+ !element.closest('ion-menu, ion-popover') &&
115
+ modalUsesVerticalBars(element) &&
116
+ !!element.closest('ion-app.ios-theme-vertical-bars')));
117
+
118
+ // Condense headers are the scrolling duplicate of a page's fixed header.
119
+ // Keep this exclusion aligned with the entering-back prehide rule in vertical-bars.scss.
120
+ const nonFixedToolbarSurface =
121
+ 'ion-header[collapse="condense"], ion-header.header-collapse-condense, ion-content ion-header, ion-content ion-footer';
122
+
123
+ export const isVerticalBarsBackPosition = (element: HTMLElement): boolean => {
124
+ if (element.closest(nonFixedToolbarSurface)) return false;
125
+ const page = element.closest('.ion-page');
126
+ if (!page) return true;
127
+ const candidates = Array.from(page.querySelectorAll<HTMLElement>('ion-back-button')).filter(
128
+ (back) =>
129
+ back.closest('.ion-page') === page &&
130
+ !back.matches('.ion-cloned-element') &&
131
+ !back.closest(`${nonFixedToolbarSurface}, ion-menu, ion-popover`) &&
132
+ modalUsesVerticalBars(back),
133
+ );
134
+ const rank = (back: HTMLElement) =>
135
+ inFixedToolbar(back) ? 0 : back.closest('ion-header ion-toolbar, ion-footer ion-toolbar') ? 1 : back.closest('ion-toolbar') ? 3 : 2;
136
+ const best = candidates.reduce<HTMLElement | undefined>(
137
+ (winner, back) => (!winner || rank(back) < rank(winner) ? back : winner),
138
+ undefined,
139
+ );
140
+ return best === element;
141
+ };
142
+
143
+ export const preferredVerticalBarsBack = <T extends HTMLElement>(elements: T[], doc: Document): T | undefined => {
144
+ const pages = Array.from(doc.querySelectorAll('.ion-page'));
145
+ const pageOrder = (element: Element) => pages.indexOf(element.closest('.ion-page')!);
146
+ return elements.sort((a, b) => pageOrder(b) - pageOrder(a) || Number(!!b.closest('ion-header')) - Number(!!a.closest('ion-header')))[0];
147
+ };
148
+
149
+ const excludedBy = (element: HTMLElement, selector: string): boolean => {
150
+ const owner = element.closest<HTMLElement>(selector);
151
+ return !!owner && !(element.parentElement === owner && isDisabledButtonGroupChild(element));
152
+ };
153
+
154
+ export const isPermanentlyExcluded = (element: HTMLElement): boolean => excludedBy(element, permanentlyExcluded);
155
+ export const isExcluded = (element: HTMLElement, enteringPage?: HTMLElement): boolean =>
156
+ excludedBy(element, `${permanentlyExcluded}, .ion-page-hidden`) || (!!element.closest('.ion-page-invisible') && !enteringPage);
157
+
158
+ // A shared native surface must not cover an opted-out descendant either.
159
+ export const isNativeUIDisabled = (element: Element): boolean =>
160
+ !!element.closest(nativeUIDisabledSelector) || !!element.querySelector(nativeUIDisabledSelector);
161
+
162
+ export const isVerticalBarsToolbarActionShape = (element: HTMLElement): boolean =>
163
+ element.matches('ion-menu-button') ||
164
+ (element.matches('ion-button') && !!element.querySelector('ion-icon[slot="icon-only"], svg[slot="icon-only"]'));
165
+
166
+ // Placement belongs to the DOM identity for one routed-page epoch. Changes to
167
+ // content/disabled state affect rendering, never its chosen surface.
168
+ const verticalBarsPlacement = new WeakMap<HTMLElement, boolean>();
169
+ export const setVerticalBarsPlacement = (element: HTMLElement, rail: boolean): void => {
170
+ verticalBarsPlacement.set(element, rail);
171
+ };
172
+ export const clearVerticalBarsPlacement = (element: HTMLElement): void => {
173
+ verticalBarsPlacement.delete(element);
174
+ };
175
+ // Explicit Web placement remains authoritative even when added after initial capture.
176
+ export const verticalBarsOwned = (element: HTMLElement): boolean =>
177
+ verticalBarsPlacement.get(element) === true &&
178
+ !element.closest('ion-buttons.ios-theme-horizontal-only, ion-button.ios-theme-horizontal-only');
179
+
180
+ export const isVerticalBarsToolbarAction = (element: HTMLElement): boolean =>
181
+ verticalBarsOwned(element) && !isExcluded(element, verticalBarsEnteringPage(element)) && !isNativeUIDisabled(element);
182
+
183
+ const overlays = 'ion-menu, ion-popover';
184
+
185
+ /** Shared eligibility for a toolbar action candidate, before placement is decided (prehide) or consumed (projection). */
186
+ export const verticalBarsActionCandidate = (element: HTMLElement): boolean =>
187
+ !element.matches('[data-native-ui="button"], [data-native-ui="back-button"]') &&
188
+ !element.querySelector('[data-native-ui="button"], [data-native-ui="back-button"]') &&
189
+ inFixedToolbar(element) &&
190
+ !element.closest('ion-buttons.ios-theme-horizontal-only, ion-button.ios-theme-horizontal-only') &&
191
+ !isPermanentlyExcluded(element) &&
192
+ !isNativeUIDisabled(element) &&
193
+ !element.closest(overlays) &&
194
+ modalUsesVerticalBars(element);
195
+
196
+ /** Shared eligibility for a back-button candidate; the back button may sit outside a fixed toolbar. */
197
+ export const verticalBarsBackCandidate = (element: HTMLElement): boolean =>
198
+ !element.hasAttribute('data-native-ui') &&
199
+ isVerticalBarsBackPosition(element) &&
200
+ !element.closest('ion-buttons.ios-theme-horizontal-only') &&
201
+ !isPermanentlyExcluded(element) &&
202
+ !isNativeUIDisabled(element) &&
203
+ !element.closest(overlays) &&
204
+ modalUsesVerticalBars(element);
205
+
206
+ export const verticalBarsToolbarActions = (element: HTMLElement): HTMLElement[] =>
207
+ // Back buttons use the dedicated rail slot; they are never toolbar actions.
208
+ childElements(element).filter((child) => !child.matches('ion-back-button') && isVerticalBarsToolbarAction(child));
209
+
210
+ export const isVerticalBarsToolbarGroup = (element: HTMLElement): boolean => {
211
+ return (
212
+ element.matches('ion-buttons') &&
213
+ verticalBarsOwned(element) &&
214
+ !element.matches('.ios-theme-disabled, .ios26-disabled') &&
215
+ !isNativeUIDisabled(element)
216
+ );
217
+ };
218
+
219
+ export const toolbarTextMarker = 'data-native-ui-toolbar-text';
220
+
221
+ export const syncToolbarText = (doc: Document): void => {
222
+ for (const toolbar of Array.from(
223
+ doc.querySelectorAll<HTMLElement>(`ion-app.ios-theme-vertical-bars ion-toolbar, ion-toolbar[${toolbarTextMarker}]`),
224
+ )) {
225
+ toolbar.toggleAttribute(
226
+ toolbarTextMarker,
227
+ !!toolbar.closest('ion-app.ios-theme-vertical-bars') &&
228
+ childNodesOf(toolbar).some((node) => node.nodeType === Node.TEXT_NODE && !!node.textContent?.trim()),
229
+ );
230
+ }
231
+ };
232
+
233
+ export const activateProjectedElement = (element: HTMLElement): void => {
234
+ const target = element.matches('ion-button, ion-back-button, ion-menu-button')
235
+ ? element.shadowRoot?.querySelector<HTMLElement>('[part~="native"]')
236
+ : undefined;
237
+ (target ?? element).click();
238
+ };
239
+
240
+ export const unprojected = <T>(elements: Iterable<HTMLElement>, read: () => T): T => {
241
+ const list = Array.from(elements);
242
+ const hidden = list.filter((element) => element.hasAttribute(marker));
243
+ const snapshotHidden = list.filter((element) => element.hasAttribute('data-native-ui-snapshot-source'));
244
+ const webHidden = list.filter((element) => element.hasAttribute('data-native-ui-web-source'));
245
+ hidden.forEach((element) => element.removeAttribute(marker));
246
+ snapshotHidden.forEach((element) => element.removeAttribute('data-native-ui-snapshot-source'));
247
+ webHidden.forEach((element) => element.removeAttribute('data-native-ui-web-source'));
248
+ try {
249
+ return read();
250
+ } finally {
251
+ hidden.forEach((element) => element.setAttribute(marker, ''));
252
+ snapshotHidden.forEach((element) => element.setAttribute('data-native-ui-snapshot-source', ''));
253
+ webHidden.forEach((element) => element.setAttribute('data-native-ui-web-source', ''));
254
+ }
255
+ };
256
+
257
+ const readVisible = (element: HTMLElement, allowOutsideViewport: boolean): boolean => {
258
+ const enteringPage = isVerticalBarsSource(element) ? verticalBarsEnteringPage(element) : undefined;
259
+ if (!element.isConnected || isExcluded(element, enteringPage) || isNativeUIDisabled(element)) return false;
260
+ for (let current: HTMLElement | null = element; current; current = current.parentElement) {
261
+ const style = getComputedStyle(current);
262
+ if (
263
+ style.display === 'none' ||
264
+ (style.visibility !== 'visible' && getUnprojectedVisibility(current) !== 'visible') ||
265
+ (Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker) && current !== enteringPage)
266
+ )
267
+ return false;
268
+ // Ordinary controls on moving/collapsing/custom transformed surfaces stay in Web coordinates.
269
+ // VerticalBars rail controls are placed independently of their Web coordinates and must remain
270
+ // owned while Ionic transforms the content behind an open menu.
271
+ if (
272
+ !allowOutsideViewport &&
273
+ style.transform !== 'none' &&
274
+ !new DOMMatrixReadOnly(style.transform).isIdentity &&
275
+ current !== element &&
276
+ !current.matches('ion-tab-bar')
277
+ )
278
+ return false;
279
+ }
280
+ const rect = element.getBoundingClientRect();
281
+ return (
282
+ rect.width > 0 &&
283
+ rect.height > 0 &&
284
+ (allowOutsideViewport || (rect.left >= -1 && rect.top >= -1 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1))
285
+ );
286
+ };
287
+
288
+ export const visible = (element: HTMLElement, allowOutsideViewport = false): boolean =>
289
+ withoutPrehide(element, () => readVisible(element, allowOutsideViewport));
290
+
291
+ export const frame = (rect: DOMRect, origin?: DOMRect): Frame => ({
292
+ x: rect.x - (origin?.x ?? 0),
293
+ y: rect.y - (origin?.y ?? 0),
294
+ width: rect.width,
295
+ height: rect.height,
296
+ });
297
+
298
+ export const text = (element: Element): string => {
299
+ if (element.matches('ion-icon, svg, ion-badge, .ios27-segment-lens, .ion-cloned-element')) return '';
300
+ return childNodesOf(element)
301
+ .map((node) => (node.nodeType === 3 ? node.textContent : node.nodeType === 1 ? text(node as Element) : ''))
302
+ .join('')
303
+ .replace(/\s+/g, ' ')
304
+ .trim();
305
+ };
306
+
307
+ export const inFixedToolbar = (element: Element): boolean => {
308
+ const edge = element.closest('ion-toolbar')?.parentElement;
309
+ return !!edge?.matches('ion-header, ion-footer') && !element.closest('ion-content') && !element.closest(nonFixedToolbarSurface);
310
+ };
@@ -0,0 +1,14 @@
1
+ export const CSS_MOTION_EVENTS = {
2
+ transitionRun: 'transitionrun',
3
+ transitionEnd: 'transitionend',
4
+ transitionCancel: 'transitioncancel',
5
+ animationStart: 'animationstart',
6
+ animationEnd: 'animationend',
7
+ animationCancel: 'animationcancel',
8
+ } as const;
9
+
10
+ // Ionic adapter event names. Generic DOM sources do not require this lifecycle.
11
+ export const LIFECYCLE_WILL_ENTER = 'ionViewWillEnter';
12
+ export const LIFECYCLE_WILL_LEAVE = 'ionViewWillLeave';
13
+ export const LIFECYCLE_DID_ENTER = 'ionViewDidEnter';
14
+ export const LIFECYCLE_DID_LEAVE = 'ionViewDidLeave';
@@ -0,0 +1,106 @@
1
+ import type { Candidate } from './candidate.js';
2
+ import { inFixedToolbar } from './dom.js';
3
+
4
+ export const navigationMarker = 'data-native-ui-navigation-source';
5
+ export const horizontalToolbar = (candidate: Candidate): boolean =>
6
+ candidate.control.placement !== 'vertical-bars' &&
7
+ ['button', 'button-group', 'back-button', 'menu-button'].includes(candidate.control.kind) &&
8
+ inFixedToolbar(candidate.element);
9
+
10
+ /** Prepare stationary toolbar endpoints before playback. Native presentation follows
11
+ * the page clock; source actions change only when navigation commits.
12
+ */
13
+ export const createHorizontalNavigation = () => {
14
+ let sequence = 0;
15
+ let current:
16
+ | {
17
+ id: number;
18
+ destination: Candidate[];
19
+ prepared: boolean;
20
+ native: boolean;
21
+ scopes: Set<HTMLElement>;
22
+ frozen: Candidate[];
23
+ phase: 'playing' | 'settling' | 'committing';
24
+ masked: Set<HTMLElement>;
25
+ leased: boolean;
26
+ }
27
+ | undefined;
28
+ const clear = () => {
29
+ current?.masked.forEach((element) => element.removeAttribute(navigationMarker));
30
+ current = undefined;
31
+ };
32
+ return {
33
+ get current() {
34
+ return current;
35
+ },
36
+ begin(scopes: HTMLElement[], acknowledged: Candidate[], leased = false) {
37
+ if (current && ((leased && current.leased) || current.phase !== 'playing')) clear();
38
+ if (!current)
39
+ current = {
40
+ id: ++sequence,
41
+ destination: [],
42
+ prepared: false,
43
+ native: false,
44
+ scopes: new Set(),
45
+ frozen: acknowledged
46
+ .filter((candidate) => horizontalToolbar(candidate) && scopes.some((scope) => scope.contains(candidate.element)))
47
+ .map((candidate) => ({ ...candidate, icons: [] })),
48
+ phase: 'playing',
49
+ masked: new Set(),
50
+ leased,
51
+ };
52
+ scopes.forEach((scope) => current!.scopes.add(scope));
53
+ current.leased ||= leased;
54
+ return current;
55
+ },
56
+ mask(candidates: Candidate[], all = false) {
57
+ if (!current || current.phase !== 'playing') return;
58
+ for (const candidate of candidates.filter(horizontalToolbar)) {
59
+ // Only hide a destination that already has a retained native counterpart.
60
+ // New controls stay Web until native acknowledges their preparation.
61
+ if (!all && !current.frozen.some((source) => source.control.transitionId === candidate.control.transitionId)) continue;
62
+ for (const source of candidate.sources ?? [candidate.element]) {
63
+ source.setAttribute(navigationMarker, '');
64
+ current.masked.add(source);
65
+ }
66
+ }
67
+ },
68
+ unmask(candidates: Candidate[]) {
69
+ for (const candidate of candidates) {
70
+ for (const source of candidate.sources ?? [candidate.element]) {
71
+ source.removeAttribute(navigationMarker);
72
+ current?.masked.delete(source);
73
+ }
74
+ }
75
+ },
76
+ measure<T>(read: () => T): T {
77
+ const masked = [...(current?.masked ?? [])];
78
+ masked.forEach((element) => element.removeAttribute(navigationMarker));
79
+ try {
80
+ return read();
81
+ } finally {
82
+ masked.forEach((element) => element.setAttribute(navigationMarker, ''));
83
+ }
84
+ },
85
+ retain(candidates: Candidate[]): Candidate[] {
86
+ if (!current || current.phase === 'committing') return candidates;
87
+ const frozen = new Set(current.frozen.map((candidate) => candidate.element));
88
+ return [
89
+ ...candidates.filter(
90
+ (candidate) =>
91
+ !frozen.has(candidate.element) &&
92
+ !(horizontalToolbar(candidate) && [...current!.scopes].some((scope) => scope.contains(candidate.element))),
93
+ ),
94
+ ...current.frozen,
95
+ ];
96
+ },
97
+ owns(element: HTMLElement): boolean {
98
+ return (
99
+ !!current &&
100
+ (current.frozen.some((candidate) => candidate.element === element || candidate.element.contains(element)) ||
101
+ ([...current.scopes].some((scope) => scope.contains(element)) && inFixedToolbar(element)))
102
+ );
103
+ },
104
+ clear,
105
+ };
106
+ };
@@ -0,0 +1,64 @@
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, Promise<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 rendering = (async () => {
40
+ const url = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml' }));
41
+ try {
42
+ const image = new Image();
43
+ image.src = url;
44
+ await image.decode();
45
+ const canvas = document.createElement('canvas');
46
+ canvas.width = Math.ceil(width * scale);
47
+ canvas.height = Math.ceil(height * scale);
48
+ canvas.getContext('2d')!.drawImage(image, 0, 0, canvas.width, canvas.height);
49
+ return canvas.toDataURL('image/png').split(',')[1];
50
+ } finally {
51
+ URL.revokeObjectURL(url);
52
+ }
53
+ })();
54
+ if (cache.size >= 128) cache.delete(cache.keys().next().value!);
55
+ cache.set(key, rendering);
56
+ try {
57
+ return await rendering;
58
+ } catch (error) {
59
+ if (cache.get(key) === rendering) cache.delete(key);
60
+ throw error;
61
+ }
62
+ },
63
+ };
64
+ };
@@ -0,0 +1,109 @@
1
+ import type { Frame } from '../definitions.js';
2
+
3
+ /** One immutable presentation, played by Core Animation on the same elapsed clock. */
4
+ export interface ModalAnimation {
5
+ id: number;
6
+ startedAt: number;
7
+ duration: number;
8
+ fromY: number;
9
+ opacity: number;
10
+ easing: number[];
11
+ }
12
+
13
+ let sequence = 0;
14
+ export const readModalAnimation = (content: HTMLElement): { animation: ModalAnimation; frame: Frame } | undefined => {
15
+ const win = content.ownerDocument.defaultView!;
16
+ if (Math.abs(content.offsetWidth - win.innerWidth) > 1 || Math.abs(content.offsetLeft) > 1) return;
17
+ const animation = content
18
+ .getAnimations()
19
+ .find((item) => item.playState === 'running' && (item.effect as KeyframeEffect)?.getKeyframes().some((frame) => frame['transform']));
20
+ if (!animation || animation.playbackRate !== 1) return;
21
+ const effect = animation.effect as KeyframeEffect;
22
+ const timing = effect.getTiming();
23
+ const frames = effect.getKeyframes();
24
+ if (
25
+ frames.length !== 2 ||
26
+ typeof timing.duration !== 'number' ||
27
+ timing.duration <= 0 ||
28
+ timing.duration > 60_000 ||
29
+ !Number.isFinite(timing.duration) ||
30
+ frames.some((frame) => frame.easing !== 'linear') ||
31
+ Number(frames[1]['opacity'] ?? 1) !== 1 ||
32
+ timing.iterations !== 1 ||
33
+ timing.direction !== 'normal' ||
34
+ timing.delay !== 0
35
+ )
36
+ return;
37
+ const translate = (value: unknown) => {
38
+ const match = /^translateY\((-?[\d.]+)(px|%|vh)\)$/.exec(String(value));
39
+ if (!match) return;
40
+ const factor = match[2] === '%' ? content.offsetHeight / 100 : match[2] === 'vh' ? win.innerHeight / 100 : 1;
41
+ return Number(match[1]) * factor;
42
+ };
43
+ const from = translate(frames[0]['transform']);
44
+ const to = translate(frames[1]['transform']);
45
+ const curve = /^cubic-bezier\(([^)]+)\)$/.exec(timing.easing ?? 'linear');
46
+ const easing = (timing.easing ?? 'linear') === 'linear' ? [0, 0, 1, 1] : curve?.[1].split(',').map(Number);
47
+ if (from === undefined || to === undefined || !easing || easing.length !== 4 || !easing.every(Number.isFinite)) return;
48
+ const opacity = Number(frames[0]['opacity'] ?? 1);
49
+ if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1 || easing[0] < 0 || easing[0] > 1 || easing[2] < 0 || easing[2] > 1) return;
50
+ const matrix = new DOMMatrixReadOnly(win.getComputedStyle(content).transform);
51
+ if (matrix.a !== 1 || matrix.d !== 1 || matrix.b !== 0 || matrix.c !== 0 || matrix.m41 !== 0) return;
52
+ const rect = content.getBoundingClientRect();
53
+ const scale = win.devicePixelRatio || 1;
54
+ const pixel = (value: number) => Math.round(value * scale) / scale;
55
+ const y = Math.max(0, pixel(rect.top - matrix.m42 + to));
56
+ const height = Math.min(pixel(rect.height), win.innerHeight - y);
57
+ if (height <= 0) return;
58
+ return {
59
+ animation: {
60
+ id: ++sequence,
61
+ startedAt: Date.now() - Number(animation.currentTime ?? 0),
62
+ duration: timing.duration,
63
+ fromY: from - to,
64
+ opacity,
65
+ easing,
66
+ },
67
+ frame: { x: 0, y, width: win.innerWidth, height },
68
+ };
69
+ };
70
+
71
+ /** Keep layout at the destination size; the wrapper transform reveals the sheet.
72
+ * Only Ionic's standard sheet-entry height animation is changed. Its final size,
73
+ * duration, wrapper motion and later breakpoint animations remain Ionic-owned.
74
+ */
75
+ export const prepareSheetLayout = (modal: HTMLElement & { initialBreakpoint?: number }): void => {
76
+ if (!modal.matches('.modal-sheet.modal-no-expand-scroll')) return;
77
+ const breakpoint = modal.initialBreakpoint;
78
+ if (breakpoint === undefined || !Number.isFinite(breakpoint) || breakpoint <= 0 || breakpoint > 1) return;
79
+ const page = modal.querySelector<HTMLElement>(':scope > .ion-page');
80
+ for (const animation of page?.getAnimations?.() ?? []) {
81
+ const effect = animation.effect;
82
+ if (animation.playState !== 'running' || !(effect instanceof KeyframeEffect) || effect.target !== page) continue;
83
+ const frames = effect.getKeyframes();
84
+ if (
85
+ frames.length !== 2 ||
86
+ !frames.every(
87
+ (frame, index) =>
88
+ frame.offset === index &&
89
+ frame.easing === 'linear' &&
90
+ Number(frame['opacity']) === 1 &&
91
+ typeof frame['maxHeight'] === 'string' &&
92
+ /^\d+(?:\.\d+)?%$/.test(frame['maxHeight']) &&
93
+ Math.abs(parseFloat(frame['maxHeight']) - (index === 0 ? 1 - breakpoint : breakpoint) * 100) < 0.000001 &&
94
+ (frame.composite === 'auto' || frame.composite === 'replace') &&
95
+ Object.keys(frame).every((key) => ['offset', 'computedOffset', 'easing', 'composite', 'opacity', 'maxHeight'].includes(key)),
96
+ )
97
+ )
98
+ continue;
99
+ effect.setKeyframes(
100
+ frames.map((frame) => ({
101
+ offset: frame.offset,
102
+ easing: frame.easing,
103
+ composite: frame.composite,
104
+ opacity: frame['opacity'],
105
+ maxHeight: frames[1]['maxHeight'],
106
+ })),
107
+ );
108
+ }
109
+ };