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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/README.md +14 -0
  2. package/WebViewNativeUIControls.podspec +17 -0
  3. package/dist/controls/activation.d.ts +15 -0
  4. package/dist/controls/activation.js +370 -0
  5. package/dist/controls/components/index.d.ts +16 -0
  6. package/dist/controls/components/index.js +38 -0
  7. package/dist/controls/components/ion-back-button.d.ts +4 -0
  8. package/dist/controls/components/ion-back-button.js +16 -0
  9. package/dist/controls/components/ion-button.d.ts +4 -0
  10. package/dist/controls/components/ion-button.js +26 -0
  11. package/dist/controls/components/ion-buttons.d.ts +5 -0
  12. package/dist/controls/components/ion-buttons.js +34 -0
  13. package/dist/controls/components/ion-fab.d.ts +4 -0
  14. package/dist/controls/components/ion-fab.js +164 -0
  15. package/dist/controls/components/ion-menu-button.d.ts +7 -0
  16. package/dist/controls/components/ion-menu-button.js +41 -0
  17. package/dist/controls/components/ion-segment.d.ts +5 -0
  18. package/dist/controls/components/ion-segment.js +24 -0
  19. package/dist/controls/components/ion-tab-bar.d.ts +5 -0
  20. package/dist/controls/components/ion-tab-bar.js +49 -0
  21. package/dist/controls/components/searchable-tabs.d.ts +20 -0
  22. package/dist/controls/components/searchable-tabs.js +525 -0
  23. package/dist/controls/definitions.d.ts +360 -0
  24. package/dist/controls/definitions.js +1 -0
  25. package/dist/controls/extensions.d.ts +44 -0
  26. package/dist/controls/extensions.js +164 -0
  27. package/dist/controls/foldable/fold-state.d.ts +4 -0
  28. package/dist/controls/foldable/fold-state.js +8 -0
  29. package/dist/controls/foldable/types.d.ts +9 -0
  30. package/dist/controls/foldable/types.js +1 -0
  31. package/dist/controls/html.d.ts +8 -0
  32. package/dist/controls/html.js +260 -0
  33. package/dist/controls/index.d.ts +7 -0
  34. package/dist/controls/index.js +4 -0
  35. package/dist/controls/ionic.d.ts +1 -0
  36. package/dist/controls/ionic.js +1 -0
  37. package/dist/controls/legacy-layout.d.ts +7 -0
  38. package/dist/controls/legacy-layout.js +23 -0
  39. package/dist/controls/overlays/content.d.ts +2 -0
  40. package/dist/controls/overlays/content.js +55 -0
  41. package/dist/controls/overlays/controller.d.ts +7 -0
  42. package/dist/controls/overlays/controller.js +369 -0
  43. package/dist/controls/overlays/covered-overlay.d.ts +4 -0
  44. package/dist/controls/overlays/covered-overlay.js +9 -0
  45. package/dist/controls/overlays/dialog-animation.d.ts +7 -0
  46. package/dist/controls/overlays/dialog-animation.js +49 -0
  47. package/dist/controls/overlays/dismiss-animation.d.ts +2 -0
  48. package/dist/controls/overlays/dismiss-animation.js +14 -0
  49. package/dist/controls/overlays/focus.d.ts +2 -0
  50. package/dist/controls/overlays/focus.js +67 -0
  51. package/dist/controls/overlays/popover.d.ts +10 -0
  52. package/dist/controls/overlays/popover.js +117 -0
  53. package/dist/controls/overlays/styles.d.ts +9 -0
  54. package/dist/controls/overlays/styles.js +194 -0
  55. package/dist/controls/prehide.d.ts +6 -0
  56. package/dist/controls/prehide.js +280 -0
  57. package/dist/controls/runtime.d.ts +5 -0
  58. package/dist/controls/runtime.js +1685 -0
  59. package/dist/controls/shared/bridge.d.ts +4 -0
  60. package/dist/controls/shared/bridge.js +15 -0
  61. package/dist/controls/shared/candidate.d.ts +23 -0
  62. package/dist/controls/shared/candidate.js +128 -0
  63. package/dist/controls/shared/clipping.d.ts +1 -0
  64. package/dist/controls/shared/clipping.js +1 -0
  65. package/dist/controls/shared/connection.d.ts +21 -0
  66. package/dist/controls/shared/connection.js +112 -0
  67. package/dist/controls/shared/control-options.d.ts +4 -0
  68. package/dist/controls/shared/control-options.js +14 -0
  69. package/dist/controls/shared/covered-snapshot.d.ts +35 -0
  70. package/dist/controls/shared/covered-snapshot.js +41 -0
  71. package/dist/controls/shared/covered-source.d.ts +22 -0
  72. package/dist/controls/shared/covered-source.js +72 -0
  73. package/dist/controls/shared/crossfade.d.ts +11 -0
  74. package/dist/controls/shared/crossfade.js +71 -0
  75. package/dist/controls/shared/diagnostics.d.ts +2 -0
  76. package/dist/controls/shared/diagnostics.js +16 -0
  77. package/dist/controls/shared/dom.d.ts +45 -0
  78. package/dist/controls/shared/dom.js +258 -0
  79. package/dist/controls/shared/events.d.ts +12 -0
  80. package/dist/controls/shared/events.js +13 -0
  81. package/dist/controls/shared/horizontal-navigation.d.ts +36 -0
  82. package/dist/controls/shared/horizontal-navigation.js +90 -0
  83. package/dist/controls/shared/icons.d.ts +5 -0
  84. package/dist/controls/shared/icons.js +66 -0
  85. package/dist/controls/shared/modal-animation.d.ts +21 -0
  86. package/dist/controls/shared/modal-animation.js +97 -0
  87. package/dist/controls/shared/modal.d.ts +26 -0
  88. package/dist/controls/shared/modal.js +261 -0
  89. package/dist/controls/shared/projection-id.d.ts +2 -0
  90. package/dist/controls/shared/projection-id.js +2 -0
  91. package/dist/controls/shared/scroll-placement.d.ts +1 -0
  92. package/dist/controls/shared/scroll-placement.js +1 -0
  93. package/dist/controls/shared/tab-bar-width.d.ts +7 -0
  94. package/dist/controls/shared/tab-bar-width.js +72 -0
  95. package/dist/controls/shared/toolbar-options.d.ts +10 -0
  96. package/dist/controls/shared/toolbar-options.js +38 -0
  97. package/dist/controls/shared/transition-identity.d.ts +3 -0
  98. package/dist/controls/shared/transition-identity.js +64 -0
  99. package/dist/controls/shared/vertical-bars-geometry.d.ts +8 -0
  100. package/dist/controls/shared/vertical-bars-geometry.js +82 -0
  101. package/dist/controls/shared/vertical-navigation.d.ts +5 -0
  102. package/dist/controls/shared/vertical-navigation.js +39 -0
  103. package/dist/controls/startup.d.ts +7 -0
  104. package/dist/controls/startup.js +72 -0
  105. package/dist/controls/transport.d.ts +10 -0
  106. package/dist/controls/transport.js +1 -0
  107. package/dist/controls/vertical-bars-web-search.d.ts +8 -0
  108. package/dist/controls/vertical-bars-web-search.js +83 -0
  109. package/dist/controls/vertical-bars-web.d.ts +2 -0
  110. package/dist/controls/vertical-bars-web.js +609 -0
  111. package/dist/index.d.ts +1 -0
  112. package/dist/index.js +1 -0
  113. package/dist/integration/index.d.ts +36 -0
  114. package/dist/integration/index.js +25 -0
  115. package/dist/native/session.js +14 -8
  116. package/dist/projection/pause.d.ts +10 -0
  117. package/dist/projection/pause.js +58 -0
  118. package/dist/projection/styles.d.ts +1 -1
  119. package/dist/projection/styles.js +0 -1
  120. package/dist/projection/surfaces.js +2 -1
  121. package/dist/projection/web.js +39 -5
  122. package/dist/types.d.ts +5 -1
  123. package/docs/README.md +1 -0
  124. package/docs/configuration/README.md +3 -1
  125. package/docs/configuration/ionic-overlays.md +36 -2
  126. package/docs/configuration/pause-resume.md +51 -0
  127. package/docs/controls.md +39 -0
  128. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift +98 -0
  129. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift +24 -0
  130. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIComponents.swift +19 -0
  131. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift +80 -0
  132. package/ios/Sources/WebViewNativeUIControls/Components/NativeUISearchController.swift +449 -0
  133. package/ios/Sources/WebViewNativeUIControls/Components/NativeUISegment.swift +91 -0
  134. package/ios/Sources/WebViewNativeUIControls/Components/NativeUITabBar.swift +213 -0
  135. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIToolbarTransition.swift +186 -0
  136. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalBars.swift +696 -0
  137. package/ios/Sources/WebViewNativeUIControls/Components/NativeUIVerticalSearch.swift +117 -0
  138. package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUI.swift +180 -0
  139. package/ios/Sources/WebViewNativeUIControls/Extensions/NativeUIRendering.swift +38 -0
  140. package/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +801 -0
  141. package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayController.swift +131 -0
  142. package/ios/Sources/WebViewNativeUIControls/Overlays/NativeUIOverlayHost.swift +85 -0
  143. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIControlRendering.swift +21 -0
  144. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICoveredSnapshots.swift +14 -0
  145. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUICrossfade.swift +35 -0
  146. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIHost.swift +8 -0
  147. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIScrollTracker.swift +2 -0
  148. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUISnapshot.swift +206 -0
  149. package/ios/Sources/WebViewNativeUIControls/Shared/NativeUIVerticalBarsGeometry.swift +93 -0
  150. package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +2 -2
  151. package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +1 -1
  152. package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +3 -4
  153. package/package.json +30 -5
  154. package/src/controls/activation.ts +427 -0
  155. package/src/controls/components/index.ts +45 -0
  156. package/src/controls/components/ion-back-button.ts +22 -0
  157. package/src/controls/components/ion-button.ts +28 -0
  158. package/src/controls/components/ion-buttons.ts +37 -0
  159. package/src/controls/components/ion-fab.ts +151 -0
  160. package/src/controls/components/ion-menu-button.ts +37 -0
  161. package/src/controls/components/ion-segment.ts +27 -0
  162. package/src/controls/components/ion-tab-bar.ts +55 -0
  163. package/src/controls/components/searchable-tabs.ts +534 -0
  164. package/src/controls/definitions.ts +338 -0
  165. package/src/controls/extensions.ts +198 -0
  166. package/src/controls/foldable/fold-state.ts +10 -0
  167. package/src/controls/foldable/types.ts +4 -0
  168. package/src/controls/html.ts +239 -0
  169. package/src/controls/index.ts +7 -0
  170. package/src/controls/ionic.ts +1 -0
  171. package/src/controls/legacy-layout.ts +21 -0
  172. package/src/controls/overlays/content.ts +51 -0
  173. package/src/controls/overlays/controller.ts +394 -0
  174. package/src/controls/overlays/covered-overlay.ts +11 -0
  175. package/src/controls/overlays/dialog-animation.ts +53 -0
  176. package/src/controls/overlays/dismiss-animation.ts +15 -0
  177. package/src/controls/overlays/focus.ts +64 -0
  178. package/src/controls/overlays/popover.ts +116 -0
  179. package/src/controls/overlays/styles.ts +195 -0
  180. package/src/controls/prehide.ts +285 -0
  181. package/src/controls/runtime.ts +1731 -0
  182. package/src/controls/shared/bridge.ts +18 -0
  183. package/src/controls/shared/candidate.ts +163 -0
  184. package/src/controls/shared/clipping.ts +1 -0
  185. package/src/controls/shared/connection.ts +105 -0
  186. package/src/controls/shared/control-options.ts +20 -0
  187. package/src/controls/shared/covered-snapshot.ts +52 -0
  188. package/src/controls/shared/covered-source.ts +78 -0
  189. package/src/controls/shared/crossfade.ts +70 -0
  190. package/src/controls/shared/diagnostics.ts +13 -0
  191. package/src/controls/shared/dom.ts +310 -0
  192. package/src/controls/shared/events.ts +14 -0
  193. package/src/controls/shared/horizontal-navigation.ts +106 -0
  194. package/src/controls/shared/icons.ts +64 -0
  195. package/src/controls/shared/modal-animation.ts +109 -0
  196. package/src/controls/shared/modal.ts +286 -0
  197. package/src/controls/shared/projection-id.ts +2 -0
  198. package/src/controls/shared/scroll-placement.ts +1 -0
  199. package/src/controls/shared/tab-bar-width.ts +66 -0
  200. package/src/controls/shared/toolbar-options.ts +51 -0
  201. package/src/controls/shared/transition-identity.ts +67 -0
  202. package/src/controls/shared/vertical-bars-geometry.ts +92 -0
  203. package/src/controls/shared/vertical-navigation.ts +34 -0
  204. package/src/controls/startup.ts +74 -0
  205. package/src/controls/transport.ts +11 -0
  206. package/src/controls/vertical-bars-web-search.ts +86 -0
  207. package/src/controls/vertical-bars-web.ts +674 -0
  208. package/src/dom/geometry.ts +24 -0
  209. package/src/dom/overlays.ts +196 -0
  210. package/src/dom/scroll-bindings.ts +66 -0
  211. package/src/dom/scroll-placement.ts +92 -0
  212. package/src/dom/visibility.ts +29 -0
  213. package/src/index.ts +19 -0
  214. package/src/integration/index.ts +64 -0
  215. package/src/ionic.ts +34 -0
  216. package/src/native/bootstrap.ts +132 -0
  217. package/src/native/bridge.ts +18 -0
  218. package/src/native/resource-url.ts +11 -0
  219. package/src/native/session.ts +708 -0
  220. package/src/native/updates.ts +103 -0
  221. package/src/native/values.ts +29 -0
  222. package/src/projection/events.ts +58 -0
  223. package/src/projection/pause.ts +58 -0
  224. package/src/projection/placement.ts +34 -0
  225. package/src/projection/scope.ts +13 -0
  226. package/src/projection/styles.ts +26 -0
  227. package/src/projection/surface.ts +88 -0
  228. package/src/projection/surfaces.ts +119 -0
  229. package/src/projection/web.ts +1013 -0
  230. package/src/types.ts +58 -0
@@ -0,0 +1,25 @@
1
+ /// <reference lib="es2021.weakref" />
2
+ const searches = new WeakMap();
3
+ export const registerNativeSearch = (tabBar, trigger, footer) => {
4
+ const binding = { tabBar, trigger, footer, active: false, focused: false };
5
+ const entries = (searches.get(tabBar.ownerDocument) ?? []).filter((entry) => entry.deref() && entry.deref()?.footer !== footer);
6
+ entries.push(new WeakRef(binding));
7
+ searches.set(tabBar.ownerDocument, entries);
8
+ tabBar.ownerDocument.defaultView?.dispatchEvent(new Event('nativeUIRefresh'));
9
+ return binding;
10
+ };
11
+ export const getNativeSearchBindings = (doc) => (searches.get(doc) ?? []).flatMap((entry) => entry.deref() ?? []);
12
+ export const requestNativeSearch = async (binding, active, focus) => (await runtimes.get(binding.tabBar.ownerDocument)?.search?.(binding, active, focus)) ?? false;
13
+ const runtimes = new WeakMap();
14
+ export const VERTICAL_BARS_TRANSITION_CANCELED = 'iosThemeVerticalBarsTransitionCanceled';
15
+ export const setNativeUIIntegration = (doc, runtime) => {
16
+ if (runtime)
17
+ runtimes.set(doc, runtime);
18
+ else
19
+ runtimes.delete(doc);
20
+ };
21
+ export const hasNativeUIIntegration = (doc) => runtimes.has(doc);
22
+ export const isNativeUI = (element) => element.hasAttribute('data-native-ui-projected');
23
+ export const suspendNativeUI = async (scopes, options) => (await runtimes.get(scopes[0]?.ownerDocument)?.suspend(scopes, options)) ?? (() => { });
24
+ /** Shared geometry used by the theme's transition adapter; no theme import in the plugin. */
25
+ export const nativeWebViewMetrics = { radius: 0 };
@@ -1,6 +1,6 @@
1
1
  import { applyNativeValues } from './values.js';
2
2
  import { projectionPlacement } from '../projection/placement.js';
3
- import { getOverlayCoordinator } from '../dom/overlays.js';
3
+ import { getProjectionPause } from '../projection/pause.js';
4
4
  import { readScrollPlacement } from '../dom/scroll-placement.js';
5
5
  import { projectionAncestors } from '../dom/geometry.js';
6
6
  import { createWebProjection } from '../projection/web.js';
@@ -19,8 +19,8 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
19
19
  let ready = false;
20
20
  let requestedDestination = 'native';
21
21
  let destination = 'native';
22
- const overlays = getOverlayCoordinator(document);
23
- const effectiveDestination = () => requestedDestination === 'native' && target && overlays.isCovered(target) ? 'web' : requestedDestination;
22
+ const projectionRequest = getProjectionPause(document);
23
+ const effectiveDestination = () => requestedDestination === 'native' && projectionRequest.isPaused(target) ? 'web' : requestedDestination;
24
24
  let automaticMotion = false;
25
25
  let nativeDragging = false;
26
26
  let placementPending = false;
@@ -575,7 +575,7 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
575
575
  applyDestination(effectiveDestination());
576
576
  });
577
577
  observer.observe(document.documentElement, { childList: true, attributes: true, subtree: true });
578
- const applyDestination = (next, force = false) => {
578
+ const applyDestination = (next, force) => {
579
579
  if (stopped || (!force && next === destination))
580
580
  return;
581
581
  automaticMotion = false;
@@ -602,6 +602,9 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
602
602
  });
603
603
  }
604
604
  else if (destination === 'web') {
605
+ // A native show starts a new local request; the screen-wide Web request prevents restoration.
606
+ if (force === 'native' && projectionRequest.isPaused())
607
+ manager?.end();
605
608
  beginProjection();
606
609
  if (!manager?.token)
607
610
  void bridge.send({ action: 'visibility', visible: false }).catch(() => { });
@@ -614,7 +617,10 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
614
617
  }
615
618
  };
616
619
  // Let a renderer claim a native child window during the same Ionic event dispatch.
617
- const stopOverlays = overlays.subscribe(() => queueMicrotask(() => applyDestination(effectiveDestination())));
620
+ const stopPause = projectionRequest.subscribe(() => queueMicrotask(() => {
621
+ if (ready)
622
+ applyDestination(effectiveDestination());
623
+ }));
618
624
  let events;
619
625
  const destroy = async () => {
620
626
  if (stopped)
@@ -624,7 +630,7 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
624
630
  retireValueRead();
625
631
  clearPlacement();
626
632
  generation++;
627
- stopOverlays();
633
+ stopPause();
628
634
  observer.disconnect();
629
635
  sizeObserver.disconnect();
630
636
  window.removeEventListener('resize', resized);
@@ -656,7 +662,7 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
656
662
  placementPending = false;
657
663
  requestedDestination = next;
658
664
  void bridge.send({ action: 'destination', destination: next }).catch(() => { });
659
- applyDestination(effectiveDestination(), true);
665
+ applyDestination(effectiveDestination(), next);
660
666
  }
661
667
  if (updated) {
662
668
  void refreshValues();
@@ -677,7 +683,7 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
677
683
  show: () => {
678
684
  placementPending = false;
679
685
  requestedDestination = 'native';
680
- applyDestination(effectiveDestination(), true);
686
+ applyDestination(effectiveDestination(), 'native');
681
687
  },
682
688
  hide: () => {
683
689
  requestedDestination = destination = 'none';
@@ -0,0 +1,10 @@
1
+ export declare const getProjectionPause: (doc: Document) => {
2
+ isPaused: (target?: HTMLElement) => boolean;
3
+ subscribe(listener: () => void): () => void;
4
+ pause: () => void;
5
+ resume: () => void;
6
+ };
7
+ /** Request temporary Web projection across this document. Pair each call with resume(). */
8
+ export declare const pause: () => void;
9
+ /** Release one shared pause, regardless of caller; resume exactly once per pause(). */
10
+ export declare const resume: () => void;
@@ -0,0 +1,58 @@
1
+ import { getOverlayCoordinator } from '../dom/overlays.js';
2
+ const key = Symbol.for('@webview-native-ui/engine/projection-pause/v1');
3
+ const createPause = (doc) => {
4
+ const overlays = getOverlayCoordinator(doc);
5
+ const listeners = new Set();
6
+ let depth = 0;
7
+ let releaseFrame = 0;
8
+ const paused = () => depth > 0 || releaseFrame !== 0;
9
+ const notify = () => {
10
+ for (const listener of listeners)
11
+ listener();
12
+ };
13
+ const change = (delta) => {
14
+ if (delta < 0 && depth === 0)
15
+ return;
16
+ const wasPaused = paused();
17
+ depth += delta;
18
+ const win = doc.defaultView;
19
+ if (releaseFrame && win) {
20
+ win.cancelAnimationFrame(releaseFrame);
21
+ releaseFrame = 0;
22
+ }
23
+ if (depth === 0 && win) {
24
+ // Give DOM dismissal a paint opportunity before requesting Native return.
25
+ // Keep all consumers paused meanwhile, including Ionic change callbacks.
26
+ releaseFrame = win.requestAnimationFrame(() => {
27
+ releaseFrame = win.requestAnimationFrame(() => {
28
+ releaseFrame = 0;
29
+ notify();
30
+ });
31
+ });
32
+ }
33
+ else if (wasPaused !== paused())
34
+ notify();
35
+ };
36
+ return {
37
+ // Without a target, only the document-wide request applies; renderers own local coverage.
38
+ isPaused: (target) => paused() || (!!target && overlays.isCovered(target)),
39
+ subscribe(listener) {
40
+ listeners.add(listener);
41
+ const stopOverlays = overlays.subscribe(listener);
42
+ return () => {
43
+ listeners.delete(listener);
44
+ stopOverlays();
45
+ };
46
+ },
47
+ pause: () => change(1),
48
+ resume: () => change(-1),
49
+ };
50
+ };
51
+ export const getProjectionPause = (doc) => {
52
+ const shared = doc;
53
+ return (shared[key] ??= createPause(doc));
54
+ };
55
+ /** Request temporary Web projection across this document. Pair each call with resume(). */
56
+ export const pause = () => getProjectionPause(document).pause();
57
+ /** Release one shared pause, regardless of caller; resume exactly once per pause(). */
58
+ export const resume = () => getProjectionPause(document).resume();
@@ -1,2 +1,2 @@
1
- export declare const snapshotStyles = "\n@layer native-ui-projection {\n:is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content]), :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content]) *, :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content])::part(native) { visibility: hidden !important; }\n:is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content])::before, :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content])::after,\n:is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content]) *::before, :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content]) *::after {\n visibility: hidden !important;\n}\n}\n[data-native-ui-snapshot-visible] { opacity:1!important;overflow:visible!important; }\n[data-native-ui-snapshot-visible][data-native-ui-snapshot-positioned] { position:relative!important; }\n";
1
+ export declare const snapshotStyles = "\n@layer native-ui-projection {\n:is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content]), :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content]) *, :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content])::part(native) { visibility: hidden !important; }\n:is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content])::before, :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content])::after,\n:is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content]) *::before, :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content]) *::after {\n visibility: hidden !important;\n}\n}\n[data-native-ui-snapshot-visible][data-native-ui-snapshot-positioned] { position:relative!important; }\n";
2
2
  export declare const snapshotShadowStyles = "\n@layer native-ui-projection {\n:host([data-native-ui-snapshot-content]),\n:host([data-native-ui-snapshot-content])::before,\n:host([data-native-ui-snapshot-content])::after,\n:host([data-native-ui-snapshot-content]) *,\n:host([data-native-ui-snapshot-content]) *::before,\n:host([data-native-ui-snapshot-content]) *::after {\n visibility: hidden !important;\n}\n}\n";
@@ -8,7 +8,6 @@ ${hidden} *::before, ${hidden} *::after {
8
8
  visibility: hidden !important;
9
9
  }
10
10
  }
11
- [data-native-ui-snapshot-visible] { opacity:1!important;overflow:visible!important; }
12
11
  [data-native-ui-snapshot-visible][data-native-ui-snapshot-positioned] { position:relative!important; }
13
12
  `;
14
13
  export const snapshotShadowStyles = `
@@ -45,7 +45,8 @@ export const createWebProjectionSurfaces = (options = {}) => {
45
45
  if (motionFrame || stopped || !moving.size || !doc?.defaultView)
46
46
  return;
47
47
  // Ancestor transforms already carry the image. Only the host's own motion needs rebasing.
48
- const surfaces = values().filter((surface) => (surface.token || surface.restoring) && surface.owners.some((owner) => moving.has(target(owner))));
48
+ const surfaces = values().filter((surface) => (surface.token || surface.restoring) &&
49
+ (surface.restoring ? surface.visibleOwners : surface.owners).some((owner) => moving.has(target(owner))));
49
50
  if (!surfaces.length)
50
51
  return;
51
52
  motionFrame = doc.defaultView.requestAnimationFrame(() => {
@@ -2,6 +2,7 @@ import { projectionURL } from '../native/resource-url.js';
2
2
  import { bounded, boundedListener } from '../native/bridge.js';
3
3
  import { bindProjectionEvents } from './events.js';
4
4
  import { snapshotStyles, snapshotShadowStyles } from './styles.js';
5
+ import { getProjectionPause } from './pause.js';
5
6
  const layout = ({ control: c }, positionIndependent = false) => [
6
7
  c.id,
7
8
  c.kind,
@@ -20,6 +21,8 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
20
21
  throw new RangeError('captureDelayMs must be a finite, nonnegative timer interval');
21
22
  let destination = 'native';
22
23
  const doc = scope.element.ownerDocument;
24
+ const projectionRequest = getProjectionPause(doc);
25
+ let locallyRequested = false;
23
26
  const win = doc.defaultView;
24
27
  // Native tokens are local to a transport; never use them as global CSS identities.
25
28
  const imageIdentity = Array.from(win.crypto.getRandomValues(new Uint32Array(4)), (value) => value.toString(16)).join('-');
@@ -324,6 +327,8 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
324
327
  preparedVisualRevision = visual;
325
328
  adopted = true;
326
329
  dispose(previous);
330
+ if (projectionRequest.isPaused())
331
+ present();
327
332
  }
328
333
  catch {
329
334
  // Preparing a visual is optional. Preserve the previous image or the Web fallback.
@@ -618,11 +623,11 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
618
623
  const rect = element.getBoundingClientRect();
619
624
  return { ...image.data.parts[index], x: rect.x, y: rect.y, width: rect.width, height: rect.height };
620
625
  });
621
- const begin = () => {
626
+ const present = (force = false) => {
622
627
  retireRestoration();
623
- if (stopped || destination === 'none' || active)
628
+ if (stopped || destination === 'none' || (active && !force))
624
629
  return;
625
- const image = cached;
630
+ const image = active ?? cached;
626
631
  if (!image ||
627
632
  image.viewport !== viewport() ||
628
633
  !image.sources.every(validSource) ||
@@ -685,7 +690,12 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
685
690
  paintFrame = 0;
686
691
  if (!isCurrent())
687
692
  return;
688
- void call({ action: 'painted', token: image.data.token }).catch(() => {
693
+ void call({ action: 'painted', token: image.data.token })
694
+ .then((result) => {
695
+ if (isCurrent() && result?.shown === false)
696
+ abandon(image, false);
697
+ })
698
+ .catch(() => {
689
699
  if (isCurrent())
690
700
  abandon(image, false);
691
701
  });
@@ -697,7 +707,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
697
707
  abandon(image, false);
698
708
  });
699
709
  };
700
- const end = () => {
710
+ const restore = () => {
701
711
  if (!active || stopped || ending)
702
712
  return;
703
713
  // A return belongs to the captured surface, never to the modal exposed beneath it.
@@ -796,6 +806,25 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
796
806
  win.dispatchEvent(new Event(sourcesChangedEvent));
797
807
  }
798
808
  };
809
+ const begin = () => {
810
+ const starting = !locallyRequested;
811
+ locallyRequested = true;
812
+ present(starting && projectionRequest.isPaused());
813
+ };
814
+ const end = () => {
815
+ locallyRequested = false;
816
+ if (!projectionRequest.isPaused())
817
+ restore();
818
+ };
819
+ const projectionChanged = () => queueMicrotask(() => {
820
+ if (stopped)
821
+ return;
822
+ if (projectionRequest.isPaused())
823
+ present();
824
+ else if (!locallyRequested)
825
+ restore();
826
+ callbacks.wake();
827
+ });
799
828
  let eventListener;
800
829
  try {
801
830
  if (callbacks.events)
@@ -807,6 +836,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
807
836
  // Native hide has already happened. Retire images and invalidate pending captures,
808
837
  // without calling the plugin's public hide method or disturbing its fallback DOM.
809
838
  if (destination === 'none') {
839
+ locallyRequested = false;
810
840
  invalidate();
811
841
  return;
812
842
  }
@@ -814,6 +844,8 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
814
844
  begin();
815
845
  if (next === 'native') {
816
846
  end();
847
+ if (projectionRequest.isPaused())
848
+ present(true);
817
849
  schedule();
818
850
  }
819
851
  if (updated)
@@ -826,6 +858,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
826
858
  await bounded(Promise.resolve(listener.remove())).catch(() => { });
827
859
  throw error;
828
860
  }
861
+ const stopPause = projectionRequest.subscribe(projectionChanged);
829
862
  return {
830
863
  get owners() {
831
864
  return [...owners];
@@ -855,6 +888,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
855
888
  invalidate,
856
889
  async destroy() {
857
890
  stopped = true;
891
+ stopPause();
858
892
  invalidate();
859
893
  imagePool.forEach((pool) => pool.forEach((image) => image.remove()));
860
894
  imagePool.clear();
package/dist/types.d.ts CHANGED
@@ -79,7 +79,11 @@ export type WebProjectionResult<Request extends WebProjectionRequest> = Request
79
79
  action: 'cover' | 'restore';
80
80
  } ? {
81
81
  shown: boolean;
82
- } : void;
82
+ } : Request extends {
83
+ action: 'painted';
84
+ } ? {
85
+ shown: boolean;
86
+ } | void : void;
83
87
  export interface ListenerHandle {
84
88
  remove(): Promise<void> | void;
85
89
  }
package/docs/README.md CHANGED
@@ -49,6 +49,7 @@ that require each option. Read only the relevant detail:
49
49
  - [Value bindings](configuration/values.md): keep the fallback host's component data aligned with native state.
50
50
  - [Capture timing](configuration/capture.md): prepare pixels after native appearance settles.
51
51
  - [Ionic overlays](configuration/ionic-overlays.md): connect Ionic coverage with one shared application initializer.
52
+ - [Custom Web overlays](configuration/pause-resume.md): re-export pause/resume from a plugin so applications can bracket their own overlays.
52
53
 
53
54
  ## 4. Use advanced APIs when you own a renderer
54
55
 
@@ -24,6 +24,8 @@ same position: include that successful selection notification in `receiveListene
24
24
  The Engine exposes [presentation intent](../reference/state.md) separately from
25
25
  component values. Neither requires an application-side Engine wrapper. Ionic
26
26
  overlay detection is a separate, opt-in [shared initializer](ionic-overlays.md),
27
- not a registration argument; other frameworks report their lifecycle explicitly.
27
+ not a registration argument. For application-owned overlays, plugins can
28
+ [re-export pause/resume](pause-resume.md) so applications explicitly bracket their
29
+ overlays.
28
30
 
29
31
  [Documentation overview](../README.md)
@@ -5,10 +5,17 @@ above Engine-managed native content. Native registration handles capture and
5
5
  restoration, but it cannot infer Ionic's presentation lifecycle from a native
6
6
  view. This shared monitor supplies that coverage decision for all registrations.
7
7
 
8
- Integrate the plugin through the [Capacitor](../getting-started/capacitor.md) or
9
- [Cordova](../getting-started/cordova.md) guide first. Non-Ionic applications use their own
8
+ Applications use this adapter with an Engine-enabled plugin. Plugin authors first
9
+ integrate through the [Capacitor](../getting-started/capacitor.md) or
10
+ [Cordova](../getting-started/cordova.md) guide. Non-Ionic applications use their own
10
11
  lifecycle notifications and do not install this adapter.
11
12
 
13
+ The `/ionic` entry point provides shared framework integration. It observes Ionic
14
+ overlays and determines which registered surfaces they cover. A plugin's explicit
15
+ presentation controls remain part of that plugin's API, connected through
16
+ [projection notifications](events.md) or its existing
17
+ [renderer API](../advanced/renderer.md).
18
+
12
19
  For example, opening an Ionic settings modal should cover the background native
13
20
  tab bar while leaving the modal's own native close button usable. A blanket hide
14
21
  notification cannot express that relationship. The shared monitor evaluates each
@@ -32,6 +39,10 @@ One monitor is shared per document, including between the npm module and the run
32
39
 
33
40
  The default covers Modal, Popover, Alert, Action Sheet, Loading, Picker, Toast and Menu lifecycle events. It compares each projection's DOM with the active overlay stack: content inside the front modal stays native, while covered content temporarily uses its prepared image. Toasts and inactive sheet backdrops do not cover a surface. Nested overlays keep it covered until the remaining stack permits restoration. Missing images retain the normal Web fallback. This temporary coverage never overwrites a plugin's explicit `web` or `none` destination. Projections registered later read the current state.
34
41
 
42
+ Here, Alert means Ionic's `ion-alert`. This adapter does not intercept
43
+ `window.alert()`, detect HTML `<dialog>` automatically, or observe native dialogs.
44
+ Custom Web overlays can supply lifecycle events through `additional`, below.
45
+
35
46
  A popover displayed in a native window is different from a popover rendered in
36
47
  the WebView: its DOM placeholder must not be treated as a covering Web overlay.
37
48
  The `@rdlabo/capacitor-native-ui` plugin reports this native presentation ownership
@@ -42,6 +53,8 @@ explicit integration; it is not inferred from the popover's DOM alone.
42
53
  ## Customize unusual layouts
43
54
 
44
55
  Optional settings are document-wide. Keep the defaults for ordinary Ionic overlays.
56
+ `IonicOverlayOptions`, exported from the same `/ionic` entry point, is the TypeScript
57
+ type for these settings; it is not another initialization function.
45
58
  Use `exclude` for an application overlay that should not cover native content, and
46
59
  `additional` when the same app also uses a custom dialog with its own lifecycle
47
60
  events. Use `covers` only when DOM containment and backdrop state do not describe
@@ -63,3 +76,24 @@ enableSupportIonicOverlays({
63
76
  ```
64
77
 
65
78
  Use `covers(overlay, target)` only when a registered DOM projection needs a custom coverage rule; return `undefined` for the default, or a boolean to override it. Exclusions and explicit native-window ownership take precedence. The standard relationship uses overlay order, DOM containment and backdrop state, rather than attempting to interpret arbitrary CSS stacking contexts. Configure unusual layouts explicitly.
79
+
80
+ ## When a plugin needs to know which modal is behind another
81
+
82
+ For ordinary application setup, use `enableSupportIonicOverlays()` as shown above.
83
+ The Engine then handles Ionic overlay coverage without additional calls.
84
+
85
+ A plugin that places native controls inside Ionic modals may also need to know
86
+ which modal lies behind the one being opened. Two helpers from the same `/ionic`
87
+ entry point let the plugin read the modal order already tracked by the Engine.
88
+
89
+ For example, open modal A, then modal B above it:
90
+
91
+ - `ionicModalStack(document)` returns `[A, B]`, from back to front.
92
+ - `coveredIonicSurface(B)` returns A, the modal beneath B.
93
+ - If a popover opens above B, `coveredIonicSurface(popover)` returns B.
94
+ - `coveredIonicSurface(A)` returns `undefined`: the page behind A is not a modal.
95
+
96
+ A and B here are the actual `ion-modal` DOM elements. These helpers only read the
97
+ tracked modal order; they do not open or close overlays, switch projection, or
98
+ start monitoring. Call them after `enableSupportIonicOverlays()` has enabled the
99
+ monitor and the relevant overlay has entered its presentation lifecycle.
@@ -0,0 +1,51 @@
1
+ # Cover native UI with a custom Web overlay
2
+
3
+ Use `pause()` before presenting a custom modal or `div` over native UI and
4
+ `resume()` when it closes. For Ionic overlays, use the automatic
5
+ [Ionic integration](ionic-overlays.md), which shares this coordination but only
6
+ covers surfaces behind the overlay.
7
+
8
+ ## Plugin authors: re-export the Engine functions
9
+
10
+ ```ts
11
+ export { pause, resume } from '@webview-native-ui/engine';
12
+ ```
13
+
14
+ The functions share one pause count across Engine-enabled plugins and bundles in
15
+ the same document. No wrapper or native plugin method is needed.
16
+
17
+ ## Application authors: pair each pause with a resume
18
+
19
+ ```ts
20
+ import { pause, resume } from 'your-plugin';
21
+
22
+ pause();
23
+ try {
24
+ await showCustomModal();
25
+ } finally {
26
+ resume();
27
+ }
28
+ ```
29
+
30
+ `showCustomModal()` must resolve when the modal closes. With event-driven modals,
31
+ call `resume()` on dismissal, cancellation or failure. Use one pair per overlay,
32
+ even with multiple plugins; nested overlays each need their own pair.
33
+
34
+ A pause replaces visible native surfaces with prepared Web images or their normal
35
+ Web fallback. Hidden surfaces stay hidden. The final `resume()` releases
36
+ the request, preserving each plugin's presentation intent and remaining Ionic
37
+ overlay coverage.
38
+
39
+ The counter does not track callers or pair individual calls. An extra `resume()`
40
+ while paused can release another overlay's request; only a `resume()` at zero has
41
+ no effect. Each owner must release exactly once for each pause it requested.
42
+
43
+ Both functions return immediately, without waiting for native acknowledgments or
44
+ painting. Returning from `pause()` does not mean native UI has already moved
45
+ behind the Web overlay; it may remain in front until the handoff completes.
46
+ The final `resume()` defers native restoration until the second animation frame,
47
+ allowing overlay removal to paint without guaranteeing compositor completion.
48
+ Call it after removing the overlay or completing
49
+ its dismissal animation. A new `pause()` cancels a pending restoration.
50
+ Calling `pause()` just before a blocking `window.alert()` therefore does
51
+ not guarantee a completed handoff. They do not detect custom overlays automatically.
@@ -0,0 +1,39 @@
1
+ # Shared controls for native adapters
2
+
3
+ The existing Engine package also owns the control implementation used by Capacitor Native UI.
4
+ No additional npm package or workspace is required.
5
+
6
+ - `@webview-native-ui/engine`: generic projection API; unchanged dependency boundary.
7
+ - `@webview-native-ui/engine/controls`: control runtime and transport contract.
8
+ - `@webview-native-ui/engine/controls/ionic`: optional Ionic overlay implementation.
9
+ - `@webview-native-ui/engine/integration`: the single shared transition/search integration state.
10
+
11
+ An adapter creates one `createNativeUI({ plugin, getPlatform, isAvailable })` instance and keeps it
12
+ for its lifetime. The plugin implements the `NativeUIPlugin` transport interface. The adapter owns
13
+ host registration, method dispatch, listener delivery, and lifecycle hooks; Engine owns rendering,
14
+ DOM ownership, extension acknowledgement, and teardown sequencing. Cordova can supply this same
15
+ transport without importing Capacitor. The Cordova Native UI plugin itself is a subsequent change.
16
+
17
+ SwiftPM exposes the optional `WebViewNativeUIControls` product, depending on `WebViewNativeUIEngine`.
18
+ CocoaPods exposes `WebViewNativeUIControls` with the same dependency. Android exposes the optional
19
+ `io.github.webview-native-ui:controls` artifact from `android/controls`; it depends on `engine` and
20
+ Compose. Generic Engine consumers keep their existing dependencies and Android minimum SDK 23.
21
+ Controls require Android API 24. Native adapters create `NativeUIController` and forward requests
22
+ and events. iOS receives JSON dictionaries and completion closures; Android receives JSONObjects.
23
+
24
+ The Ionic theme imports Engine integration directly. The Capacitor compatibility integration export
25
+ re-exports that module. Resolve a single Engine version in the application; do not bundle a private
26
+ copy of integration into the theme or adapter. The `/controls` entry does not load Ionic runtime code;
27
+ load `/controls/ionic` only when opting into Ionic overlays.
28
+
29
+ ## Verification and release order
30
+
31
+ Run `npm run test:controls` for the migrated control tests and `npm test` for generic projection.
32
+ SwiftPM's `WebViewNativeUIEngine-Package` scheme covers both native targets. Android's
33
+ `:controls:testReleaseUnitTest` covers the migrated snapshot parser. Existing Capacitor transport
34
+ and hosted lifecycle tests remain in its repository, and the theme retains the real demo E2E suite.
35
+
36
+ Build and verify Engine first, including both signed Maven artifacts in the same Central bundle and
37
+ both npm-distributed podspecs. Release the Engine native artifacts and npm package at the same version
38
+ before refreshing the Capacitor and theme registry lockfiles. Candidate verification uses local npm
39
+ tarballs and a temporary SwiftPM path or local Maven repository; do not commit these temporary paths.
@@ -0,0 +1,98 @@
1
+ import UIKit
2
+
3
+ enum NativeUIButton {
4
+ private final class BadgeLabel: UILabel {}
5
+ private static let activationID = UIAction.Identifier("native-ui-activate")
6
+
7
+ /// Reuse an unambiguous presentation slot while keeping action IDs source-specific.
8
+ @MainActor
9
+ static func reassociate(_ nodes: [NativeUIControl], controls: inout [String: UIView],
10
+ fingerprints: inout [String: NativeUIControl]) -> Set<String> {
11
+ func identity(_ node: NativeUIControl) -> String? {
12
+ guard node.placement == nil, kinds.contains(node.kind), node.items.count == 1 else { return nil }
13
+ return node.items[0].content.transitionId
14
+ }
15
+ // swiftlint:disable:next force_unwrapping
16
+ let old = Dictionary(grouping: fingerprints.values.filter { identity($0) != nil }, by: { identity($0)! })
17
+ // swiftlint:disable:next force_unwrapping
18
+ let incoming = Dictionary(grouping: nodes.filter { identity($0) != nil }, by: { identity($0)! })
19
+ let retained = Set(nodes.map(\.id))
20
+ var reused = Set<String>()
21
+ for (key, matches) in incoming {
22
+ guard matches.count == 1, let node = matches.first, controls[node.id] == nil,
23
+ let previous = old[key], previous.count == 1, let source = previous.first,
24
+ !retained.contains(source.id), let button = controls[source.id] as? UIButton else { continue }
25
+ controls.removeValue(forKey: source.id)
26
+ fingerprints.removeValue(forKey: source.id)
27
+ controls[node.id] = button
28
+ reused.insert(node.id)
29
+ }
30
+ return reused
31
+ }
32
+ // These Ionic components share the same native UIButton presentation.
33
+ static let kinds: [NativeUIComponent] = [.button, .backButton, .menuButton]
34
+
35
+ @available(iOS 26.0, *)
36
+ static func make(_ node: NativeUIControl, existing: UIButton? = nil, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UIButton? {
37
+ guard node.items.count == 1, let item = node.items.first else { return nil }
38
+ let control = render(item.content, glass: item.content.buttonFill != .clear, existing: existing, rendering: rendering, activate: activate)
39
+ control.semanticContentAttribute = node.rtl ? .forceRightToLeft : .forceLeftToRight
40
+ return control
41
+ }
42
+
43
+ @available(iOS 26.0, *)
44
+ static func render(_ item: NativeUIItemContent, glass: Bool, existing: UIButton? = nil, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UIButton {
45
+ var configuration: UIButton.Configuration = glass ? .glass() : .plain()
46
+ configuration.title = item.label
47
+ configuration.image = rendering.image(item)
48
+ configuration.imagePadding = item.imagePadding ?? 4
49
+ configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: item.contentInsetLeading ?? 4,
50
+ bottom: 0, trailing: item.contentInsetTrailing ?? 4)
51
+ configuration.imagePlacement = item.iconPosition == .trailing ? .trailing : .leading
52
+ configuration.baseForegroundColor = rendering.color(item.color)
53
+ configuration.titleLineBreakMode = .byTruncatingTail
54
+ let size = item.fontSize
55
+ let weight = item.fontWeight
56
+ configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
57
+ var outgoing = incoming
58
+ outgoing.font = UIFont.systemFont(ofSize: size, weight: weight >= 600 ? .semibold : weight >= 500 ? .medium : .regular)
59
+ return outgoing
60
+ }
61
+ let button = existing ?? UIButton(configuration: configuration)
62
+ if existing != nil { button.configuration = configuration }
63
+ button.titleLabel?.numberOfLines = 1
64
+ // Web and UIKit can differ by a fraction of a point in text measurement.
65
+ // Absorb that rounding without widening the projected button.
66
+ button.titleLabel?.adjustsFontSizeToFitWidth = true
67
+ button.titleLabel?.minimumScaleFactor = 0.99
68
+ button.isEnabled = !item.disabled
69
+ button.accessibilityLabel = item.accessibilityLabel
70
+ button.accessibilityIdentifier = item.id
71
+ if item.selected { button.accessibilityTraits.insert(.selected) } else { button.accessibilityTraits.remove(.selected) }
72
+ if item.disabled { button.accessibilityTraits.insert(.notEnabled) } else { button.accessibilityTraits.remove(.notEnabled) }
73
+ button.subviews.filter { $0 is BadgeLabel }.forEach { $0.removeFromSuperview() }
74
+ button.accessibilityValue = item.badge?.value
75
+ if let badge = item.badge {
76
+ let label = BadgeLabel()
77
+ label.text = badge.value
78
+ label.font = .systemFont(ofSize: 11, weight: .semibold)
79
+ label.textColor = rendering.color(badge.textColor)
80
+ label.backgroundColor = rendering.color(badge.color)
81
+ label.textAlignment = .center
82
+ label.layer.cornerRadius = 8
83
+ label.clipsToBounds = true
84
+ label.isAccessibilityElement = false
85
+ label.translatesAutoresizingMaskIntoConstraints = false
86
+ button.addSubview(label)
87
+ NSLayoutConstraint.activate([
88
+ label.centerXAnchor.constraint(equalTo: button.centerXAnchor, constant: 14),
89
+ label.topAnchor.constraint(equalTo: button.topAnchor, constant: 1),
90
+ label.widthAnchor.constraint(greaterThanOrEqualToConstant: max(16, label.intrinsicContentSize.width + 6)),
91
+ label.heightAnchor.constraint(equalToConstant: 16)
92
+ ])
93
+ }
94
+ button.removeAction(identifiedBy: activationID, for: .touchUpInside)
95
+ button.addAction(UIAction(identifier: activationID) { _ in activate(item.id) }, for: .touchUpInside)
96
+ return button
97
+ }
98
+ }
@@ -0,0 +1,24 @@
1
+ import UIKit
2
+
3
+ enum NativeUIButtons {
4
+ static let kind = NativeUIComponent.buttons
5
+
6
+ @available(iOS 26.0, *)
7
+ static func make(_ node: NativeUIControl, scale: CGFloat, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UIView {
8
+ let items = node.items
9
+ let rtl = node.rtl
10
+ let effect = UIGlassEffect(style: .regular)
11
+ let control = UIVisualEffectView(effect: effect)
12
+ control.layer.cornerRadius = node.frame.height * scale / 2
13
+ control.clipsToBounds = true
14
+ control.accessibilityIdentifier = node.id
15
+ for item in items {
16
+ let button = NativeUIButton.render(item.content, glass: false, rendering: rendering, activate: activate)
17
+ button.semanticContentAttribute = rtl ? .forceRightToLeft : .forceLeftToRight
18
+ let rect = item.frame.rect
19
+ button.frame = CGRect(x: rect.minX * scale, y: rect.minY * scale, width: rect.width * scale, height: rect.height * scale)
20
+ control.contentView.addSubview(button)
21
+ }
22
+ return control
23
+ }
24
+ }