@rdlabo/capacitor-native-ui 0.0.0-stage → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/LICENSE +21 -0
  2. package/Package.swift +23 -0
  3. package/README.md +53 -2
  4. package/RdlaboCapacitorNativeUi.podspec +17 -0
  5. package/android/build.gradle +69 -0
  6. package/android/proguard-rules.pro +0 -0
  7. package/android/src/main/AndroidManifest.xml +1 -0
  8. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUI.kt +51 -0
  9. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUIPlugin.kt +192 -0
  10. package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUISnapshot.kt +57 -0
  11. package/dist/_virtual/_rolldown/runtime.js +13 -0
  12. package/dist/core.d.ts +4 -0
  13. package/dist/core.js +3 -0
  14. package/dist/native/activation.d.ts +14 -0
  15. package/dist/native/activation.d.ts.map +1 -0
  16. package/dist/native/activation.js +305 -0
  17. package/dist/native/activation.js.map +1 -0
  18. package/dist/native/components/index.d.ts +19 -0
  19. package/dist/native/components/index.d.ts.map +1 -0
  20. package/dist/native/components/index.js +38 -0
  21. package/dist/native/components/index.js.map +1 -0
  22. package/dist/native/components/ion-back-button.d.ts +10 -0
  23. package/dist/native/components/ion-back-button.d.ts.map +1 -0
  24. package/dist/native/components/ion-back-button.js +21 -0
  25. package/dist/native/components/ion-back-button.js.map +1 -0
  26. package/dist/native/components/ion-button.d.ts +10 -0
  27. package/dist/native/components/ion-button.d.ts.map +1 -0
  28. package/dist/native/components/ion-button.js +25 -0
  29. package/dist/native/components/ion-button.js.map +1 -0
  30. package/dist/native/components/ion-buttons.d.ts +11 -0
  31. package/dist/native/components/ion-buttons.d.ts.map +1 -0
  32. package/dist/native/components/ion-buttons.js +30 -0
  33. package/dist/native/components/ion-buttons.js.map +1 -0
  34. package/dist/native/components/ion-fab.d.ts +10 -0
  35. package/dist/native/components/ion-fab.d.ts.map +1 -0
  36. package/dist/native/components/ion-fab.js +132 -0
  37. package/dist/native/components/ion-fab.js.map +1 -0
  38. package/dist/native/components/ion-menu-button.d.ts +13 -0
  39. package/dist/native/components/ion-menu-button.d.ts.map +1 -0
  40. package/dist/native/components/ion-menu-button.js +44 -0
  41. package/dist/native/components/ion-menu-button.js.map +1 -0
  42. package/dist/native/components/ion-segment.d.ts +11 -0
  43. package/dist/native/components/ion-segment.d.ts.map +1 -0
  44. package/dist/native/components/ion-segment.js +30 -0
  45. package/dist/native/components/ion-segment.js.map +1 -0
  46. package/dist/native/components/ion-tab-bar.d.ts +11 -0
  47. package/dist/native/components/ion-tab-bar.d.ts.map +1 -0
  48. package/dist/native/components/ion-tab-bar.js +50 -0
  49. package/dist/native/components/ion-tab-bar.js.map +1 -0
  50. package/dist/native/components/searchable-tabs.d.ts +20 -0
  51. package/dist/native/components/searchable-tabs.d.ts.map +1 -0
  52. package/dist/native/components/searchable-tabs.js +413 -0
  53. package/dist/native/components/searchable-tabs.js.map +1 -0
  54. package/dist/native/definitions.d.ts +255 -0
  55. package/dist/native/definitions.d.ts.map +1 -0
  56. package/dist/native/definitions.js +0 -0
  57. package/dist/native/extensions.d.ts +31 -0
  58. package/dist/native/extensions.d.ts.map +1 -0
  59. package/dist/native/extensions.js +122 -0
  60. package/dist/native/extensions.js.map +1 -0
  61. package/dist/native/foldable/fold-state.d.ts +5 -0
  62. package/dist/native/foldable/fold-state.d.ts.map +1 -0
  63. package/dist/native/foldable/fold-state.js +15 -0
  64. package/dist/native/foldable/fold-state.js.map +1 -0
  65. package/dist/native/foldable/types.d.ts +12 -0
  66. package/dist/native/foldable/types.d.ts.map +1 -0
  67. package/dist/native/foldable/types.js +0 -0
  68. package/dist/native/html.d.ts +9 -0
  69. package/dist/native/html.d.ts.map +1 -0
  70. package/dist/native/html.js +188 -0
  71. package/dist/native/html.js.map +1 -0
  72. package/dist/native/index.d.ts +10 -0
  73. package/dist/native/index.d.ts.map +1 -0
  74. package/dist/native/index.js +28 -0
  75. package/dist/native/index.js.map +1 -0
  76. package/dist/native/legacy-layout.d.ts +5 -0
  77. package/dist/native/legacy-layout.d.ts.map +1 -0
  78. package/dist/native/legacy-layout.js +27 -0
  79. package/dist/native/legacy-layout.js.map +1 -0
  80. package/dist/native/overlays/content.d.ts +4 -0
  81. package/dist/native/overlays/content.d.ts.map +1 -0
  82. package/dist/native/overlays/content.js +51 -0
  83. package/dist/native/overlays/content.js.map +1 -0
  84. package/dist/native/overlays/controller.d.ts +8 -0
  85. package/dist/native/overlays/controller.d.ts.map +1 -0
  86. package/dist/native/overlays/controller.js +285 -0
  87. package/dist/native/overlays/controller.js.map +1 -0
  88. package/dist/native/overlays/dialog-animation.d.ts +9 -0
  89. package/dist/native/overlays/dialog-animation.d.ts.map +1 -0
  90. package/dist/native/overlays/dialog-animation.js +53 -0
  91. package/dist/native/overlays/dialog-animation.js.map +1 -0
  92. package/dist/native/overlays/dismiss-animation.d.ts +4 -0
  93. package/dist/native/overlays/dismiss-animation.d.ts.map +1 -0
  94. package/dist/native/overlays/dismiss-animation.js +18 -0
  95. package/dist/native/overlays/dismiss-animation.js.map +1 -0
  96. package/dist/native/overlays/focus.d.ts +5 -0
  97. package/dist/native/overlays/focus.d.ts.map +1 -0
  98. package/dist/native/overlays/focus.js +59 -0
  99. package/dist/native/overlays/focus.js.map +1 -0
  100. package/dist/native/overlays/popover.d.ts +12 -0
  101. package/dist/native/overlays/popover.d.ts.map +1 -0
  102. package/dist/native/overlays/popover.js +110 -0
  103. package/dist/native/overlays/popover.js.map +1 -0
  104. package/dist/native/overlays/styles.d.ts +11 -0
  105. package/dist/native/overlays/styles.d.ts.map +1 -0
  106. package/dist/native/overlays/styles.js +167 -0
  107. package/dist/native/overlays/styles.js.map +1 -0
  108. package/dist/native/prehide.d.ts +8 -0
  109. package/dist/native/prehide.d.ts.map +1 -0
  110. package/dist/native/prehide.js +238 -0
  111. package/dist/native/prehide.js.map +1 -0
  112. package/dist/native/runtime.d.ts +7 -0
  113. package/dist/native/runtime.d.ts.map +1 -0
  114. package/dist/native/runtime.js +788 -0
  115. package/dist/native/runtime.js.map +1 -0
  116. package/dist/native/shared/bridge.d.ts +6 -0
  117. package/dist/native/shared/bridge.d.ts.map +1 -0
  118. package/dist/native/shared/bridge.js +18 -0
  119. package/dist/native/shared/bridge.js.map +1 -0
  120. package/dist/native/shared/candidate.d.ts +25 -0
  121. package/dist/native/shared/candidate.d.ts.map +1 -0
  122. package/dist/native/shared/candidate.js +106 -0
  123. package/dist/native/shared/candidate.js.map +1 -0
  124. package/dist/native/shared/clipping.d.ts +5 -0
  125. package/dist/native/shared/clipping.d.ts.map +1 -0
  126. package/dist/native/shared/clipping.js +26 -0
  127. package/dist/native/shared/clipping.js.map +1 -0
  128. package/dist/native/shared/crossfade.d.ts +12 -0
  129. package/dist/native/shared/crossfade.d.ts.map +1 -0
  130. package/dist/native/shared/crossfade.js +67 -0
  131. package/dist/native/shared/crossfade.js.map +1 -0
  132. package/dist/native/shared/diagnostics.d.ts +4 -0
  133. package/dist/native/shared/diagnostics.d.ts.map +1 -0
  134. package/dist/native/shared/diagnostics.js +18 -0
  135. package/dist/native/shared/diagnostics.js.map +1 -0
  136. package/dist/native/shared/dom.d.ts +46 -0
  137. package/dist/native/shared/dom.d.ts.map +1 -0
  138. package/dist/native/shared/dom.js +180 -0
  139. package/dist/native/shared/dom.js.map +1 -0
  140. package/dist/native/shared/events.d.ts +15 -0
  141. package/dist/native/shared/events.d.ts.map +1 -0
  142. package/dist/native/shared/events.js +17 -0
  143. package/dist/native/shared/events.js.map +1 -0
  144. package/dist/native/shared/icons.d.ts +8 -0
  145. package/dist/native/shared/icons.d.ts.map +1 -0
  146. package/dist/native/shared/icons.js +61 -0
  147. package/dist/native/shared/icons.js.map +1 -0
  148. package/dist/native/shared/modal.d.ts +10 -0
  149. package/dist/native/shared/modal.d.ts.map +1 -0
  150. package/dist/native/shared/modal.js +160 -0
  151. package/dist/native/shared/modal.js.map +1 -0
  152. package/dist/native/shared/projection-id.d.ts +4 -0
  153. package/dist/native/shared/projection-id.d.ts.map +1 -0
  154. package/dist/native/shared/projection-id.js +7 -0
  155. package/dist/native/shared/projection-id.js.map +1 -0
  156. package/dist/native/shared/toolbar-options.d.ts +9 -0
  157. package/dist/native/shared/toolbar-options.d.ts.map +1 -0
  158. package/dist/native/shared/toolbar-options.js +36 -0
  159. package/dist/native/shared/toolbar-options.js.map +1 -0
  160. package/dist/native/shared/vertical-bars-geometry.d.ts +10 -0
  161. package/dist/native/shared/vertical-bars-geometry.d.ts.map +1 -0
  162. package/dist/native/shared/vertical-bars-geometry.js +85 -0
  163. package/dist/native/shared/vertical-bars-geometry.js.map +1 -0
  164. package/dist/native/startup.d.ts +8 -0
  165. package/dist/native/startup.d.ts.map +1 -0
  166. package/dist/native/startup.js +86 -0
  167. package/dist/native/startup.js.map +1 -0
  168. package/dist/native/vertical-bars-web-search.d.ts +10 -0
  169. package/dist/native/vertical-bars-web-search.d.ts.map +1 -0
  170. package/dist/native/vertical-bars-web-search.js +90 -0
  171. package/dist/native/vertical-bars-web-search.js.map +1 -0
  172. package/dist/native/vertical-bars-web.d.ts +5 -0
  173. package/dist/native/vertical-bars-web.d.ts.map +1 -0
  174. package/dist/native/vertical-bars-web.js +421 -0
  175. package/dist/native/vertical-bars-web.js.map +1 -0
  176. package/dist/native-integration/index.d.ts +25 -0
  177. package/dist/native-integration/index.d.ts.map +1 -0
  178. package/dist/native-integration/index.js +33 -0
  179. package/dist/native-integration/index.js.map +1 -0
  180. package/dist/vertical-bars.d.ts +6 -0
  181. package/dist/vertical-bars.js +5 -0
  182. package/docs/configuration.md +187 -0
  183. package/docs/framework-independent.md +82 -0
  184. package/docs/migration.md +206 -0
  185. package/docs/native-mini-player.md +47 -0
  186. package/docs/native-ui-extensions.md +365 -0
  187. package/docs/native-ui.md +409 -0
  188. package/docs/setup.md +205 -0
  189. package/docs/vertical-bars.md +379 -0
  190. package/ios/Sources/NativeUIPlugin/Components/NativeUIButton.swift +71 -0
  191. package/ios/Sources/NativeUIPlugin/Components/NativeUIButtons.swift +24 -0
  192. package/ios/Sources/NativeUIPlugin/Components/NativeUIComponents.swift +19 -0
  193. package/ios/Sources/NativeUIPlugin/Components/NativeUIFab.swift +80 -0
  194. package/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift +439 -0
  195. package/ios/Sources/NativeUIPlugin/Components/NativeUISegment.swift +91 -0
  196. package/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift +218 -0
  197. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +590 -0
  198. package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalSearch.swift +118 -0
  199. package/ios/Sources/NativeUIPlugin/Extensions/NativeUI.swift +178 -0
  200. package/ios/Sources/NativeUIPlugin/Extensions/NativeUIRendering.swift +38 -0
  201. package/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +615 -0
  202. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayController.swift +119 -0
  203. package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayHost.swift +75 -0
  204. package/ios/Sources/NativeUIPlugin/Shared/NativeUIControlRendering.swift +21 -0
  205. package/ios/Sources/NativeUIPlugin/Shared/NativeUICrossfade.swift +35 -0
  206. package/ios/Sources/NativeUIPlugin/Shared/NativeUIHost.swift +8 -0
  207. package/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift +161 -0
  208. package/ios/Sources/NativeUIPlugin/Shared/NativeUIVerticalBarsGeometry.swift +52 -0
  209. package/ios/Tests/NativeUIPluginTests/NativeUIExtensionTests.swift +165 -0
  210. package/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +805 -0
  211. package/package.json +89 -4
  212. package/src/core.ts +15 -0
  213. package/src/native/activation.ts +394 -0
  214. package/src/native/components/index.ts +45 -0
  215. package/src/native/components/ion-back-button.ts +22 -0
  216. package/src/native/components/ion-button.ts +25 -0
  217. package/src/native/components/ion-buttons.ts +37 -0
  218. package/src/native/components/ion-fab.ts +151 -0
  219. package/src/native/components/ion-menu-button.ts +37 -0
  220. package/src/native/components/ion-segment.ts +27 -0
  221. package/src/native/components/ion-tab-bar.ts +50 -0
  222. package/src/native/components/searchable-tabs.ts +499 -0
  223. package/src/native/definitions.ts +324 -0
  224. package/src/native/extensions.ts +176 -0
  225. package/src/native/foldable/fold-state.ts +10 -0
  226. package/src/native/foldable/types.ts +4 -0
  227. package/src/native/html.ts +193 -0
  228. package/src/native/index.ts +41 -0
  229. package/src/native/legacy-layout.ts +21 -0
  230. package/src/native/overlays/content.ts +51 -0
  231. package/src/native/overlays/controller.ts +349 -0
  232. package/src/native/overlays/dialog-animation.ts +53 -0
  233. package/src/native/overlays/dismiss-animation.ts +15 -0
  234. package/src/native/overlays/focus.ts +64 -0
  235. package/src/native/overlays/popover.ts +120 -0
  236. package/src/native/overlays/styles.ts +181 -0
  237. package/src/native/prehide.ts +285 -0
  238. package/src/native/runtime.ts +988 -0
  239. package/src/native/shared/bridge.ts +18 -0
  240. package/src/native/shared/candidate.ts +162 -0
  241. package/src/native/shared/clipping.ts +24 -0
  242. package/src/native/shared/crossfade.ts +67 -0
  243. package/src/native/shared/diagnostics.ts +13 -0
  244. package/src/native/shared/dom.ts +304 -0
  245. package/src/native/shared/events.ts +14 -0
  246. package/src/native/shared/icons.ts +57 -0
  247. package/src/native/shared/modal.ts +157 -0
  248. package/src/native/shared/projection-id.ts +2 -0
  249. package/src/native/shared/toolbar-options.ts +53 -0
  250. package/src/native/shared/vertical-bars-geometry.ts +80 -0
  251. package/src/native/startup.ts +84 -0
  252. package/src/native/vertical-bars-web-search.ts +86 -0
  253. package/src/native/vertical-bars-web.ts +498 -0
  254. package/src/native-integration/index.ts +49 -0
  255. package/src/vertical-bars.ts +11 -0
@@ -0,0 +1,499 @@
1
+ import { getNativeSearchBindings, requestNativeSearch } from '../../native-integration';
2
+ import type { NativeSearchBinding } from '../../native-integration';
3
+ import { excluded, inFixedToolbar, isNativeUIDisabled, marker, text, visible, withoutPrehide } from '../shared/dom';
4
+ import { iconSource } from '../shared/icons';
5
+ import type { Candidate } from '../shared/candidate';
6
+ import type { NativeUIItem, NativeUISearch, NativeUISearchEvent } from '../definitions';
7
+
8
+ interface SearchState {
9
+ binding: NativeSearchBinding;
10
+ bar: HTMLIonSearchbarElement;
11
+ input: HTMLInputElement;
12
+ editSequence: number;
13
+ composing: boolean;
14
+ focused: boolean;
15
+ minimumRevision: number;
16
+ valueVersion: number;
17
+ applyingInput: boolean;
18
+ keyboardHidden?: boolean;
19
+ last?: NativeUISearch;
20
+ layout?: string;
21
+ rejectedLayout?: string;
22
+ restore: () => void;
23
+ }
24
+
25
+ export const createSearchSupport = (doc: Document, id: (element: Element) => string, schedule: () => void) => {
26
+ const states = new Map<NativeSearchBinding, SearchState>();
27
+ let keyboardVisible = false;
28
+ let heldKeyboardResize: string | null = null;
29
+ let keyboardResizeGeneration = 0;
30
+ type KeyboardPlugin = {
31
+ setResizeMode?: (options: { mode: string }) => Promise<void>;
32
+ getResizeMode?: () => Promise<{ mode?: string }>;
33
+ };
34
+ const keyboardPlugin = (): KeyboardPlugin | undefined =>
35
+ (
36
+ doc.defaultView as Window & {
37
+ Capacitor?: { Plugins?: { Keyboard?: KeyboardPlugin } };
38
+ }
39
+ )?.Capacitor?.Plugins?.Keyboard;
40
+ const projected = (binding: NativeSearchBinding) => binding.footer.hasAttribute(marker);
41
+ // Hold Cap resize at none while search is active so WebView shrink does not fight UISearchTab.
42
+ const holdKeyboardResize = () => {
43
+ const keyboard = keyboardPlugin();
44
+ if (!keyboard?.setResizeMode || heldKeyboardResize !== null) return;
45
+ const setResizeMode = keyboard.setResizeMode.bind(keyboard);
46
+ const generation = ++keyboardResizeGeneration;
47
+ heldKeyboardResize = 'native';
48
+ void (async () => {
49
+ let restoreMode = 'native';
50
+ try {
51
+ const current = await keyboard.getResizeMode?.();
52
+ if (typeof current?.mode === 'string') restoreMode = current.mode;
53
+ } catch {
54
+ /* keep default restore target */
55
+ }
56
+ if (generation !== keyboardResizeGeneration) return;
57
+ heldKeyboardResize = restoreMode;
58
+ try {
59
+ await setResizeMode({ mode: 'none' });
60
+ } catch {
61
+ if (generation === keyboardResizeGeneration) heldKeyboardResize = null;
62
+ return;
63
+ }
64
+ // Leave raced ahead of setResizeMode(none) — undo the stale none apply.
65
+ if (generation !== keyboardResizeGeneration) void setResizeMode({ mode: restoreMode }).catch(() => undefined);
66
+ })();
67
+ };
68
+ const hasActiveProjection = () => [...states.keys()].some((binding) => binding.active && projected(binding));
69
+ const releaseKeyboardResize = () => {
70
+ if (hasActiveProjection()) return;
71
+ keyboardResizeGeneration++;
72
+ const mode = heldKeyboardResize;
73
+ heldKeyboardResize = null;
74
+ if (!mode) return;
75
+ void keyboardPlugin()?.setResizeMode?.({ mode });
76
+ };
77
+ const isCurrent = (state: SearchState) =>
78
+ state.bar.isConnected &&
79
+ state.binding.footer.querySelector('ion-searchbar') === state.bar &&
80
+ state.bar.querySelector('input.searchbar-input') === state.input;
81
+ const install = (binding: NativeSearchBinding, bar: HTMLIonSearchbarElement, input: HTMLInputElement): SearchState => {
82
+ const originalFocus = bar.setFocus;
83
+ const ownValue = Object.getOwnPropertyDescriptor(bar, 'value');
84
+ let owner: object | null = bar;
85
+ let value = ownValue;
86
+ while (!value && (owner = Object.getPrototypeOf(owner))) value = Object.getOwnPropertyDescriptor(owner, 'value');
87
+ const focus = async () => {
88
+ if (projected(binding) && (await requestNativeSearch(binding, true, true))) return;
89
+ await originalFocus.call(bar);
90
+ };
91
+ bar.setFocus = focus;
92
+ let wrapped: PropertyDescriptor | undefined;
93
+ if (value?.get && value.set && ownValue?.configurable !== false) {
94
+ const descriptor = value;
95
+ wrapped = {
96
+ configurable: true,
97
+ enumerable: value.enumerable,
98
+ get: () => descriptor.get!.call(bar),
99
+ set: (next: string | null | undefined) => {
100
+ if (state.applyingInput) state.applyingInput = false;
101
+ else state.valueVersion++;
102
+ descriptor.set!.call(bar, next);
103
+ schedule();
104
+ },
105
+ };
106
+ Object.defineProperty(bar, 'value', wrapped);
107
+ }
108
+ const state: SearchState = {
109
+ binding,
110
+ bar,
111
+ input,
112
+ editSequence: 0,
113
+ composing: false,
114
+ focused: false,
115
+ minimumRevision: 0,
116
+ valueVersion: 0,
117
+ applyingInput: false,
118
+ restore: () => {
119
+ if (bar.setFocus === focus) bar.setFocus = originalFocus;
120
+ if (wrapped && Object.getOwnPropertyDescriptor(bar, 'value')?.set === wrapped.set) {
121
+ if (ownValue) Object.defineProperty(bar, 'value', ownValue);
122
+ else Reflect.deleteProperty(bar, 'value');
123
+ }
124
+ binding.active = binding.focused = false;
125
+ },
126
+ };
127
+ states.set(binding, state);
128
+ return state;
129
+ };
130
+ const readIcon = (element: HTMLElement, owner: Element, candidate: Candidate): NativeUIItem | undefined => {
131
+ const style = getComputedStyle(element);
132
+ const rect = element.getBoundingClientRect();
133
+ const item: NativeUIItem = {
134
+ id: id(owner),
135
+ x: rect.x,
136
+ y: rect.y,
137
+ width: rect.width,
138
+ height: rect.height,
139
+ label: text(element),
140
+ accessibilityLabel: owner.getAttribute('aria-label') ?? text(element),
141
+ disabled: false,
142
+ selected: false,
143
+ fontSize: parseFloat(style.fontSize),
144
+ fontWeight: parseInt(style.fontWeight, 10) || 400,
145
+ color: style.color,
146
+ };
147
+ const icon = element.matches('ion-icon') ? element : element.querySelector('ion-icon');
148
+ const svg = icon?.shadowRoot?.querySelector<SVGElement>('svg') ?? element.querySelector<SVGElement>('svg');
149
+ if (svg) {
150
+ const source = iconSource(svg);
151
+ if (!source) return;
152
+ const iconStyle = getComputedStyle(icon ?? svg);
153
+ item.iconWidth = parseFloat(iconStyle.width);
154
+ item.iconHeight = parseFloat(iconStyle.height);
155
+ // ion-searchbar gives its icon a full-height box; SVG meet sizing keeps the glyph square.
156
+ const box = (svg as SVGSVGElement).viewBox?.baseVal;
157
+ if (box?.width && box.height && svg.getAttribute('preserveAspectRatio') !== 'none') {
158
+ const scale = Math.min(item.iconWidth! / box.width, item.iconHeight / box.height);
159
+ item.iconWidth = box.width * scale;
160
+ item.iconHeight = box.height * scale;
161
+ }
162
+ candidate.icons.push({ item, source });
163
+ } else if (!item.label) return;
164
+ return item;
165
+ };
166
+ return {
167
+ keyboard(visible: boolean) {
168
+ keyboardVisible = visible;
169
+ if (!visible) {
170
+ for (const [binding, state] of states) {
171
+ // Ionic can wait for the pre-keyboard height indefinitely after rotation.
172
+ // Release the visibility override we own when native search's keyboard closes.
173
+ if (state.keyboardHidden) binding.tabBar.classList.remove('tab-bar-hidden');
174
+ state.keyboardHidden = false;
175
+ }
176
+ releaseKeyboardResize();
177
+ }
178
+ schedule();
179
+ },
180
+ keepSearchTabsVisible() {
181
+ if (!keyboardVisible) return;
182
+ for (const [binding, state] of states) {
183
+ // Ionic hides bottom tabs for every keyboard, including native search.
184
+ // Correct only that framework class; application styles still apply.
185
+ if (binding.active && projected(binding) && binding.tabBar.classList.contains('tab-bar-hidden')) {
186
+ state.keyboardHidden = true;
187
+ binding.tabBar.classList.remove('tab-bar-hidden');
188
+ }
189
+ }
190
+ },
191
+ decorate(candidates: Candidate[], blocked: (element: HTMLElement) => boolean): Candidate[] {
192
+ const bindings = getNativeSearchBindings(doc);
193
+ for (const [binding, state] of states) {
194
+ if (!bindings.includes(binding) || !isCurrent(state)) {
195
+ this.retire(binding);
196
+ state.restore();
197
+ states.delete(binding);
198
+ }
199
+ }
200
+ const inactive: [Candidate, NativeUISearch][] = [];
201
+ const pageHidden = (element: HTMLElement) => !!element.closest('.ion-page-hidden, .ion-page-invisible');
202
+ const retainInactive = (candidate: Candidate, state?: SearchState, keepAvailable = false) => {
203
+ // Keep NativeUISearchController across page transitions instead of demoting to UITabBar.
204
+ // While the searchable page is only blocked mid-transition (not yet hidden), keep
205
+ // available:true so the system search button stays available over album content.
206
+ if (state?.last) inactive.push([candidate, { ...state.last, active: false, available: keepAvailable, focused: false }]);
207
+ };
208
+ for (const binding of bindings) {
209
+ const candidate = candidates.find((c) => c.element === binding.tabBar);
210
+ if (!candidate || candidate.control.search || binding.tabBar.getAttribute('slot') !== 'bottom') continue;
211
+ const fab = binding.trigger.parentElement;
212
+ // Search also supports the existing page-level fixed FAB, outside scrolling content.
213
+ if (
214
+ !fab?.matches('ion-fab[slot="fixed"]') ||
215
+ !(fab.parentElement?.matches('ion-content') || (fab.parentElement?.matches('.ion-page') && !fab.closest('ion-content')))
216
+ )
217
+ continue;
218
+ if (isNativeUIDisabled(fab) || isNativeUIDisabled(binding.footer)) continue;
219
+ const pageUnavailable = blocked(binding.footer) || !!binding.footer.closest(excluded);
220
+ const existing = states.get(binding);
221
+ if (pageUnavailable) {
222
+ retainInactive(candidate, existing, !!existing?.last?.available && !pageHidden(binding.footer));
223
+ // A first registration on a cached/hidden page has no measurable trigger.
224
+ // Keep ordinary tabs until the page becomes available; only reuse a previous valid search.
225
+ continue;
226
+ }
227
+ if (binding.active && existing && isCurrent(existing) && existing.last && !pageUnavailable) {
228
+ const back = binding.footer.querySelector<HTMLIonButtonElement>('ion-buttons[slot=start] ion-button');
229
+ // Close must stay eligible; otherwise fall through and demote instead of caching active.
230
+ if (back && !back.disabled && !back.closest(excluded) && !existing.bar.closest(excluded)) {
231
+ candidate.control.search = {
232
+ ...existing.last,
233
+ active: true,
234
+ available: true,
235
+ focused: binding.focused,
236
+ value: existing.bar.value ?? '',
237
+ placeholder: existing.bar.placeholder ?? '',
238
+ disabled: existing.bar.disabled,
239
+ editSequence: existing.editSequence,
240
+ valueVersion: existing.valueVersion,
241
+ };
242
+ existing.last = candidate.control.search;
243
+ candidate.sources = [binding.tabBar, binding.trigger.closest<HTMLElement>('ion-fab') ?? binding.trigger, binding.footer];
244
+ candidate.actions.set(existing.last.trigger.id, binding.trigger);
245
+ candidate.actions.set(existing.last.closeId, back);
246
+ continue;
247
+ }
248
+ }
249
+ if (!visible(binding.trigger) && !pageUnavailable) {
250
+ retainInactive(candidate, existing);
251
+ continue;
252
+ }
253
+ const bar = binding.footer.querySelector<HTMLIonSearchbarElement>('ion-searchbar');
254
+ const input = bar?.querySelector<HTMLInputElement>('input.searchbar-input');
255
+ const back = binding.footer.querySelector<HTMLIonButtonElement>('ion-buttons[slot=start] ion-button');
256
+ if (
257
+ !bar ||
258
+ !input ||
259
+ !back ||
260
+ !binding.footer.matches('ion-footer') ||
261
+ !inFixedToolbar(bar) ||
262
+ !inFixedToolbar(back) ||
263
+ (!pageUnavailable && bar.closest(excluded)) ||
264
+ !bar.classList.contains('ios') ||
265
+ bar.classList.contains('searchbar-classic')
266
+ ) {
267
+ retainInactive(candidate, existing);
268
+ continue;
269
+ }
270
+ if (
271
+ bar.color ||
272
+ back.fill !== 'default' ||
273
+ back.color ||
274
+ bar.maxlength != null ||
275
+ bar.minlength != null ||
276
+ bar.inputmode ||
277
+ bar.enterkeyhint ||
278
+ !['off', 'none'].includes(bar.autocapitalize) ||
279
+ bar.autocomplete !== 'off' ||
280
+ ![false, 'off', undefined].includes(bar.autocorrect) ||
281
+ bar.spellcheck ||
282
+ bar.showClearButton !== 'always' ||
283
+ bar.clearIcon
284
+ ) {
285
+ retainInactive(candidate, existing);
286
+ continue;
287
+ }
288
+ if (bar.showCancelButton !== 'never' || bar.type !== 'search' || !getComputedStyle(input).backdropFilter.includes('blur')) {
289
+ retainInactive(candidate, existing);
290
+ continue;
291
+ }
292
+ const fabButton = binding.trigger as HTMLIonFabButtonElement;
293
+ const nativeTrigger = fabButton.shadowRoot?.querySelector<HTMLElement>('[part=native]');
294
+ if (
295
+ (!pageUnavailable && binding.trigger.closest(excluded)) ||
296
+ (!pageUnavailable && back.closest(excluded)) ||
297
+ back.disabled ||
298
+ fabButton.disabled ||
299
+ fabButton.color ||
300
+ fabButton.type !== 'button' ||
301
+ fabButton.href ||
302
+ !nativeTrigger ||
303
+ getComputedStyle(fabButton).backgroundColor !== 'rgba(0, 0, 0, 0)' ||
304
+ !getComputedStyle(nativeTrigger).backdropFilter.includes('blur')
305
+ ) {
306
+ retainInactive(candidate, existing);
307
+ continue;
308
+ }
309
+ const triggerRect = binding.trigger.getBoundingClientRect();
310
+ if (
311
+ ![triggerRect.x, triggerRect.y, triggerRect.width, triggerRect.height].every(Number.isFinite) ||
312
+ triggerRect.width <= 0 ||
313
+ triggerRect.height <= 0
314
+ ) {
315
+ retainInactive(candidate, existing);
316
+ continue;
317
+ }
318
+ const state = existing ?? install(binding, bar, input);
319
+ const trigger = readIcon(binding.trigger, binding.trigger, candidate);
320
+ const searchIcon = bar.querySelector<HTMLElement>('.searchbar-search-icon');
321
+ const field = searchIcon && readIcon(searchIcon, bar, candidate);
322
+ if (!trigger || !field) {
323
+ retainInactive(candidate, state);
324
+ continue;
325
+ }
326
+ field.label = '';
327
+ field.accessibilityLabel = bar.getAttribute('aria-label') ?? input.getAttribute('aria-label') ?? 'Search';
328
+ // Icon-only search FABs often rely on the searchbar for their accessible
329
+ // name. Carry that semantic name into the independent resting trigger.
330
+ if (!trigger.accessibilityLabel) trigger.accessibilityLabel = field.accessibilityLabel;
331
+ state.layout = JSON.stringify([
332
+ innerWidth,
333
+ innerHeight,
334
+ candidate.control.x,
335
+ candidate.control.y,
336
+ candidate.control.width,
337
+ candidate.control.height,
338
+ candidate.control.rtl,
339
+ trigger.x,
340
+ trigger.y,
341
+ trigger.width,
342
+ trigger.height,
343
+ candidate.control.items,
344
+ candidate.icons.map(({ source }) => source),
345
+ field,
346
+ trigger,
347
+ bar.placeholder,
348
+ bar.disabled,
349
+ ]);
350
+ if (state.rejectedLayout === state.layout) {
351
+ retainInactive(candidate, state);
352
+ continue;
353
+ }
354
+ const available = !pageUnavailable && visible(binding.trigger);
355
+ candidate.control.search = {
356
+ id: id(input),
357
+ field,
358
+ trigger,
359
+ closeId: id(back),
360
+ active: available && binding.active,
361
+ available,
362
+ focused: available && binding.focused,
363
+ value: bar.value ?? '',
364
+ placeholder: bar.placeholder ?? '',
365
+ disabled: bar.disabled,
366
+ editSequence: state.editSequence,
367
+ valueVersion: state.valueVersion,
368
+ };
369
+ state.last = candidate.control.search;
370
+ if (available) {
371
+ candidate.sources = [binding.tabBar, binding.trigger.closest<HTMLElement>('ion-fab') ?? binding.trigger, binding.footer];
372
+ candidate.actions.set(id(binding.trigger), binding.trigger);
373
+ candidate.actions.set(id(back), back);
374
+ }
375
+ }
376
+ for (const [candidate, configuration] of inactive) candidate.control.search ??= configuration;
377
+ // Every unsupported search path leaves its trigger on the Web, including
378
+ // opt-outs and unsupported markup before a native layout was attempted.
379
+ // Ordinary native tabs must not intercept that trigger's input.
380
+ const projectedCandidates = candidates.filter(
381
+ (candidate) =>
382
+ candidate.control.search?.available ||
383
+ !bindings.some((binding) => {
384
+ if (binding.tabBar !== candidate.element) return false;
385
+ const trigger = withoutPrehide(binding.trigger, () => {
386
+ // Projection opt-outs do not hide a usable Web button. Check actual
387
+ // presentation here, rather than the native eligibility predicate.
388
+ if (!binding.trigger.isConnected || binding.trigger.closest('.ion-page-hidden, .ion-page-invisible, [hidden], [inert]'))
389
+ return;
390
+ for (let element: HTMLElement | null = binding.trigger; element; element = element.parentElement) {
391
+ const style = getComputedStyle(element);
392
+ if (style.display === 'none' || style.visibility !== 'visible' || Number(style.opacity) === 0) return;
393
+ }
394
+ const rect = binding.trigger.getBoundingClientRect();
395
+ if ([rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0) return rect;
396
+ return;
397
+ });
398
+ if (!trigger) return false;
399
+ const tabs = candidate.control;
400
+ return (
401
+ tabs.x < trigger.right && tabs.x + tabs.width > trigger.left && tabs.y < trigger.bottom && tabs.y + tabs.height > trigger.top
402
+ );
403
+ }),
404
+ );
405
+ const groups = projectedCandidates.filter((c) => c.control.search);
406
+ return projectedCandidates.filter(
407
+ (c) => !groups.some((group) => group !== c && group.sources?.some((source) => source.contains(c.element))),
408
+ );
409
+ },
410
+ projected,
411
+ hasActive() {
412
+ for (const [binding] of states) if (binding.active && projected(binding)) return true;
413
+ return false;
414
+ },
415
+ begin(binding: NativeSearchBinding, revision: number) {
416
+ const state = states.get(binding);
417
+ if (state) state.minimumRevision = revision;
418
+ },
419
+ /** Enter / focus / leave. `focused` is ignored when leaving. */
420
+ setStatus(binding: NativeSearchBinding, active: boolean, focused = false) {
421
+ if (!active) {
422
+ this.retire(binding);
423
+ return;
424
+ }
425
+ binding.active = true;
426
+ // Projection intent only. Ionic focus/blur events still wait for the native phase.
427
+ binding.focused = focused;
428
+ holdKeyboardResize();
429
+ },
430
+ release(element: HTMLElement) {
431
+ for (const binding of states.keys()) if (binding.footer === element) this.retire(binding);
432
+ },
433
+ reject(ids: string[]) {
434
+ states.forEach((state) => {
435
+ if (ids.includes(id(state.binding.tabBar))) {
436
+ state.rejectedLayout = state.layout;
437
+ // UIKit removed the search surface — do not reuse its cache; project ordinary tabs.
438
+ state.last = undefined;
439
+ }
440
+ });
441
+ },
442
+ event(event: NativeUISearchEvent) {
443
+ const state = Array.from(states.values()).find((s) => id(s.input) === event.id && projected(s.binding) && isCurrent(s));
444
+ if (
445
+ !state ||
446
+ !state.binding.active ||
447
+ ((event.phase === 'input' || event.phase === 'clear') && event.valueVersion !== state.valueVersion) ||
448
+ event.revision < state.minimumRevision ||
449
+ event.sequence <= state.editSequence ||
450
+ state.bar.disabled
451
+ )
452
+ return;
453
+ state.editSequence = event.sequence;
454
+ const { input, binding } = state;
455
+ if (event.phase === 'input') {
456
+ if (state.composing !== event.composing)
457
+ input.dispatchEvent(
458
+ new CompositionEvent(event.composing ? 'compositionstart' : 'compositionend', { bubbles: true, data: event.value }),
459
+ );
460
+ state.composing = event.composing;
461
+ input.value = event.value;
462
+ state.applyingInput = true;
463
+ try {
464
+ input.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, isComposing: event.composing }));
465
+ } finally {
466
+ state.applyingInput = false;
467
+ }
468
+ } else if (event.phase === 'clear') {
469
+ state.bar.querySelector<HTMLButtonElement>('.searchbar-clear-button')?.click();
470
+ } else if (event.phase === 'commit') {
471
+ input.dispatchEvent(new Event('change', { bubbles: true }));
472
+ } else {
473
+ if (state.focused === (event.phase === 'focus')) return;
474
+ state.focused = binding.focused = event.phase === 'focus';
475
+ input.dispatchEvent(new FocusEvent(event.phase, { composed: true }));
476
+ }
477
+ schedule();
478
+ },
479
+ retire(binding: NativeSearchBinding) {
480
+ const state = states.get(binding);
481
+ // Keep ownership until keyboardWillHide can release this class.
482
+ if (state?.keyboardHidden && keyboardVisible) binding.tabBar.classList.add('tab-bar-hidden');
483
+ if (state?.focused) {
484
+ state.focused = false;
485
+ state.input.dispatchEvent(new FocusEvent('blur'));
486
+ }
487
+ binding.active = binding.focused = false;
488
+ releaseKeyboardResize();
489
+ },
490
+ destroy() {
491
+ states.forEach((state) => {
492
+ this.retire(state.binding);
493
+ state.restore();
494
+ });
495
+ states.clear();
496
+ releaseKeyboardResize();
497
+ },
498
+ };
499
+ };