@rdlabo/ionic-theme-ios27 0.1.0 → 0.2.1-beta.pr185.sha0793981f6cf4

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 (148) hide show
  1. package/README.md +34 -8
  2. package/dist/css/components/ion-button.css +1 -1
  3. package/dist/css/components/ion-fab.css +1 -1
  4. package/dist/css/components/ion-list.css +1 -1
  5. package/dist/css/components/ion-menu.css +1 -1
  6. package/dist/css/components/ion-searchbar.css +1 -1
  7. package/dist/css/components/ion-tabs.css +1 -1
  8. package/dist/css/components/ion-toolbar.css +1 -1
  9. package/dist/css/ionic-theme-ios27.css +1 -1
  10. package/dist/css/md-ion-list-inset.css +1 -1
  11. package/dist/css/utils/translucent.css +1 -1
  12. package/dist/index.d.ts +1 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -2
  15. package/dist/native/components/searchable-tabs.d.ts +2 -0
  16. package/dist/native/components/searchable-tabs.d.ts.map +1 -1
  17. package/dist/native/components/searchable-tabs.js +131 -21
  18. package/dist/native/definitions.d.ts +19 -0
  19. package/dist/native/definitions.d.ts.map +1 -1
  20. package/dist/native/index.d.ts +4 -3
  21. package/dist/native/index.d.ts.map +1 -1
  22. package/dist/native/index.js +22 -9
  23. package/dist/native/lifecycle.d.ts +4 -0
  24. package/dist/native/lifecycle.d.ts.map +1 -0
  25. package/dist/native/lifecycle.js +34 -0
  26. package/dist/native/runtime.d.ts +2 -2
  27. package/dist/native/runtime.d.ts.map +1 -1
  28. package/dist/native/runtime.js +57 -11
  29. package/dist/tab-bar-searchable/animations/enter.d.ts +1 -1
  30. package/dist/tab-bar-searchable/animations/enter.d.ts.map +1 -1
  31. package/dist/tab-bar-searchable/animations/enter.js +1 -1
  32. package/dist/tab-bar-searchable/animations/leave.d.ts +1 -1
  33. package/dist/tab-bar-searchable/animations/leave.d.ts.map +1 -1
  34. package/dist/tab-bar-searchable/animations/leave.js +1 -1
  35. package/dist/tab-bar-searchable/index.d.ts +2 -2
  36. package/dist/tab-bar-searchable/index.d.ts.map +1 -1
  37. package/dist/tab-bar-searchable/index.js +2 -3
  38. package/dist/transition/ios.transition.d.ts +6 -1
  39. package/dist/transition/ios.transition.d.ts.map +1 -1
  40. package/dist/transition/ios.transition.js +10 -385
  41. package/docs/features.md +3 -1
  42. package/docs/ios-adaptive.md +20 -21
  43. package/docs/native-ui-shell.md +117 -3
  44. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +243 -64
  45. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabBar.swift +77 -6
  46. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +106 -11
  47. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +258 -2
  48. package/node_modules/@rdlabo/ionic-theme-utils/LICENSE +21 -0
  49. package/node_modules/@rdlabo/ionic-theme-utils/README.md +28 -0
  50. package/node_modules/@rdlabo/ionic-theme-utils/dist/dom.d.ts +3 -0
  51. package/node_modules/@rdlabo/ionic-theme-utils/dist/dom.d.ts.map +1 -0
  52. package/node_modules/@rdlabo/ionic-theme-utils/dist/dom.js +13 -0
  53. package/node_modules/@rdlabo/ionic-theme-utils/dist/dom.js.map +1 -0
  54. package/node_modules/@rdlabo/ionic-theme-utils/dist/focus-controller.d.ts +6 -0
  55. package/node_modules/@rdlabo/ionic-theme-utils/dist/focus-controller.d.ts.map +1 -0
  56. package/node_modules/@rdlabo/ionic-theme-utils/dist/focus-controller.js +37 -0
  57. package/node_modules/@rdlabo/ionic-theme-utils/dist/focus-controller.js.map +1 -0
  58. package/node_modules/@rdlabo/ionic-theme-utils/dist/index.d.ts +13 -0
  59. package/node_modules/@rdlabo/ionic-theme-utils/dist/index.d.ts.map +1 -0
  60. package/node_modules/@rdlabo/ionic-theme-utils/dist/index.js +11 -0
  61. package/node_modules/@rdlabo/ionic-theme-utils/dist/index.js.map +1 -0
  62. package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.enter.d.ts +0 -1
  63. package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/animations/ios.enter.d.ts.map +1 -0
  64. package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.enter.js +8 -5
  65. package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/animations/ios.enter.js.map +1 -0
  66. package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.leave.d.ts.map +1 -1
  67. package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.leave.js +11 -5
  68. package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/animations/ios.leave.js.map +1 -0
  69. package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/callout-surface.js +1 -0
  70. package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/callout-surface.js.map +1 -0
  71. package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/popover-interface.js +2 -0
  72. package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/popover-interface.js.map +1 -0
  73. package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/utils.d.ts +1 -0
  74. package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/utils.d.ts.map +1 -0
  75. package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/utils.js +3 -2
  76. package/node_modules/@rdlabo/ionic-theme-utils/dist/popover/utils.js.map +1 -0
  77. package/node_modules/@rdlabo/ionic-theme-utils/dist/sheets-of-glass.d.ts +16 -0
  78. package/node_modules/@rdlabo/ionic-theme-utils/dist/sheets-of-glass.d.ts.map +1 -0
  79. package/node_modules/@rdlabo/ionic-theme-utils/dist/sheets-of-glass.js +2 -0
  80. package/node_modules/@rdlabo/ionic-theme-utils/dist/sheets-of-glass.js.map +1 -0
  81. package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/interfaces.js +1 -0
  82. package/node_modules/@rdlabo/ionic-theme-utils/dist/tab-bar-searchable/interfaces.js.map +1 -0
  83. package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/utils.js +1 -0
  84. package/node_modules/@rdlabo/ionic-theme-utils/dist/tab-bar-searchable/utils.js.map +1 -0
  85. package/node_modules/@rdlabo/ionic-theme-utils/dist/theme.d.ts +15 -0
  86. package/node_modules/@rdlabo/ionic-theme-utils/dist/theme.d.ts.map +1 -0
  87. package/node_modules/@rdlabo/ionic-theme-utils/dist/theme.js +49 -0
  88. package/node_modules/@rdlabo/ionic-theme-utils/dist/theme.js.map +1 -0
  89. package/node_modules/@rdlabo/ionic-theme-utils/dist/transition/ios.transition.d.ts +18 -0
  90. package/node_modules/@rdlabo/ionic-theme-utils/dist/transition/ios.transition.d.ts.map +1 -0
  91. package/node_modules/@rdlabo/ionic-theme-utils/dist/transition/ios.transition.js +412 -0
  92. package/node_modules/@rdlabo/ionic-theme-utils/dist/transition/ios.transition.js.map +1 -0
  93. package/node_modules/@rdlabo/ionic-theme-utils/package.json +64 -0
  94. package/node_modules/@rdlabo/ionic-theme-utils/src/dom.spec.ts +19 -0
  95. package/node_modules/@rdlabo/ionic-theme-utils/src/dom.ts +16 -0
  96. package/node_modules/@rdlabo/ionic-theme-utils/src/focus-controller.ts +38 -0
  97. package/node_modules/@rdlabo/ionic-theme-utils/src/index.ts +59 -0
  98. package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/animations/ios.enter.ts +7 -5
  99. package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/animations/ios.leave.ts +14 -5
  100. package/node_modules/@rdlabo/ionic-theme-utils/src/popover/utils.spec.ts +58 -0
  101. package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/utils.ts +4 -2
  102. package/node_modules/@rdlabo/ionic-theme-utils/src/sheets-of-glass.ts +17 -0
  103. package/node_modules/@rdlabo/ionic-theme-utils/src/styles/index.scss +1 -0
  104. package/node_modules/@rdlabo/ionic-theme-utils/src/styles/md-ion-list-inset.scss +20 -0
  105. package/node_modules/@rdlabo/ionic-theme-utils/src/styles/structured-list.scss +310 -0
  106. package/node_modules/@rdlabo/ionic-theme-utils/src/tab-bar-searchable/utils.spec.ts +56 -0
  107. package/node_modules/@rdlabo/ionic-theme-utils/src/theme.ts +51 -0
  108. package/node_modules/@rdlabo/ionic-theme-utils/src/transition/ios.transition.spec.ts +52 -0
  109. package/node_modules/@rdlabo/ionic-theme-utils/src/transition/ios.transition.ts +542 -0
  110. package/package.json +13 -5
  111. package/src/index.ts +4 -2
  112. package/src/native/components/searchable-tabs.ts +142 -22
  113. package/src/native/definitions.ts +34 -0
  114. package/src/native/index.ts +38 -11
  115. package/src/native/lifecycle.ts +38 -0
  116. package/src/native/runtime.ts +60 -10
  117. package/src/styles/components/ion-button.scss +21 -10
  118. package/src/styles/components/ion-fab.scss +10 -4
  119. package/src/styles/components/ion-list.scss +12 -3
  120. package/src/styles/components/ion-menu.scss +17 -10
  121. package/src/styles/components/ion-searchbar.scss +1 -1
  122. package/src/styles/components/ion-tabs.scss +14 -10
  123. package/src/styles/components/ion-toolbar.scss +9 -10
  124. package/src/styles/md-ion-list-inset.scss +2 -2
  125. package/src/styles/utils/dark/ion-list.scss +1 -1
  126. package/src/styles/utils/translucent.scss +44 -0
  127. package/src/tab-bar-searchable/animations/enter.ts +1 -2
  128. package/src/tab-bar-searchable/animations/leave.ts +1 -2
  129. package/src/tab-bar-searchable/index.ts +12 -4
  130. package/src/transition/ios.transition.ts +14 -473
  131. package/dist/css/utils/structured-list.css +0 -1
  132. package/dist/popover/animations/ios.enter.d.ts.map +0 -1
  133. package/dist/popover/popover-interface.js +0 -1
  134. package/dist/popover/utils.d.ts.map +0 -1
  135. package/src/styles/utils/structured-list.scss +0 -223
  136. /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/animations/ios.leave.d.ts +0 -0
  137. /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/callout-surface.d.ts +0 -0
  138. /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/callout-surface.d.ts.map +0 -0
  139. /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/popover-interface.d.ts +0 -0
  140. /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/popover/popover-interface.d.ts.map +0 -0
  141. /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/interfaces.d.ts +0 -0
  142. /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/interfaces.d.ts.map +0 -0
  143. /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/utils.d.ts +0 -0
  144. /package/{dist → node_modules/@rdlabo/ionic-theme-utils/dist}/tab-bar-searchable/utils.d.ts.map +0 -0
  145. /package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/callout-surface.ts +0 -0
  146. /package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/popover/popover-interface.ts +0 -0
  147. /package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/tab-bar-searchable/interfaces.ts +0 -0
  148. /package/{src → node_modules/@rdlabo/ionic-theme-utils/src}/tab-bar-searchable/utils.ts +0 -0
@@ -25,7 +25,55 @@ interface SearchState {
25
25
  export const createSearchSupport = (doc: Document, id: (element: Element) => string, schedule: () => void) => {
26
26
  const states = new Map<NativeSearchBinding, SearchState>();
27
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;
28
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
+ };
29
77
  const isCurrent = (state: SearchState) =>
30
78
  state.bar.isConnected &&
31
79
  state.binding.footer.querySelector('ion-searchbar') === state.bar &&
@@ -118,7 +166,10 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
118
166
  return {
119
167
  keyboard(visible: boolean) {
120
168
  keyboardVisible = visible;
121
- if (!visible) states.forEach((state) => (state.keyboardHidden = false));
169
+ if (!visible) {
170
+ states.forEach((state) => (state.keyboardHidden = false));
171
+ releaseKeyboardResize();
172
+ }
122
173
  schedule();
123
174
  },
124
175
  keepSearchTabsVisible() {
@@ -142,6 +193,13 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
142
193
  }
143
194
  }
144
195
  const inactive: [Candidate, ShellSearch][] = [];
196
+ const pageHidden = (element: HTMLElement) => !!element.closest('.ion-page-hidden, .ion-page-invisible');
197
+ const retainInactive = (candidate: Candidate, state?: SearchState, keepAvailable = false) => {
198
+ // Keep ShellSearchController across page transitions instead of demoting to UITabBar.
199
+ // While the searchable page is only blocked mid-transition (not yet hidden), keep
200
+ // available:true so native chrome does not swap to ordinary idleBar over album content.
201
+ if (state?.last) inactive.push([candidate, { ...state.last, active: false, available: keepAvailable, focused: false }]);
202
+ };
145
203
  for (const binding of bindings) {
146
204
  const candidate = candidates.find((c) => c.element === binding.tabBar);
147
205
  if (!candidate || candidate.control.search || binding.tabBar.getAttribute('slot') !== 'bottom') continue;
@@ -153,9 +211,37 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
153
211
  )
154
212
  continue;
155
213
  if (isShellDisabled(fab) || isShellDisabled(binding.footer)) continue;
156
- if (blocked(binding.footer) || !visible(binding.trigger) || binding.footer.closest(excluded)) {
157
- const state = states.get(binding);
158
- if (state?.last) inactive.push([candidate, { ...state.last, active: false, available: false, focused: false }]);
214
+ const pageUnavailable = blocked(binding.footer) || !!binding.footer.closest(excluded);
215
+ const existing = states.get(binding);
216
+ if (pageUnavailable) {
217
+ retainInactive(candidate, existing, !!existing?.last?.available && !pageHidden(binding.footer));
218
+ if (existing?.last) continue;
219
+ // First registration during a transition: fall through and measure available:false.
220
+ }
221
+ if (binding.active && existing && isCurrent(existing) && existing.last && !pageUnavailable) {
222
+ const back = binding.footer.querySelector<HTMLIonButtonElement>('ion-buttons[slot=start] ion-button');
223
+ // Close must stay eligible; otherwise fall through and demote instead of caching active.
224
+ if (back && !back.disabled && !back.closest(excluded)) {
225
+ candidate.control.search = {
226
+ ...existing.last,
227
+ active: true,
228
+ available: true,
229
+ focused: binding.focused,
230
+ value: existing.bar.value ?? '',
231
+ placeholder: existing.bar.placeholder ?? '',
232
+ disabled: existing.bar.disabled,
233
+ editSequence: existing.editSequence,
234
+ valueVersion: existing.valueVersion,
235
+ };
236
+ existing.last = candidate.control.search;
237
+ candidate.sources = [binding.tabBar, binding.trigger.closest<HTMLElement>('ion-fab') ?? binding.trigger, binding.footer];
238
+ candidate.actions.set(existing.last.trigger.id, binding.trigger);
239
+ candidate.actions.set(existing.last.closeId, back);
240
+ continue;
241
+ }
242
+ }
243
+ if (!visible(binding.trigger) && !pageUnavailable) {
244
+ retainInactive(candidate, existing);
159
245
  continue;
160
246
  }
161
247
  const bar = binding.footer.querySelector<HTMLIonSearchbarElement>('ion-searchbar');
@@ -168,11 +254,13 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
168
254
  !binding.footer.matches('ion-footer') ||
169
255
  !inFixedToolbar(bar) ||
170
256
  !inFixedToolbar(back) ||
171
- bar.closest(excluded) ||
257
+ (!pageUnavailable && bar.closest(excluded)) ||
172
258
  !bar.classList.contains('ios') ||
173
259
  bar.classList.contains('searchbar-classic')
174
- )
260
+ ) {
261
+ retainInactive(candidate, existing);
175
262
  continue;
263
+ }
176
264
  if (
177
265
  bar.color ||
178
266
  back.fill !== 'default' ||
@@ -187,14 +275,19 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
187
275
  bar.spellcheck ||
188
276
  bar.showClearButton !== 'always' ||
189
277
  bar.clearIcon
190
- )
278
+ ) {
279
+ retainInactive(candidate, existing);
191
280
  continue;
192
- if (bar.showCancelButton !== 'never' || bar.type !== 'search' || !getComputedStyle(input).backdropFilter.includes('blur')) continue;
281
+ }
282
+ if (bar.showCancelButton !== 'never' || bar.type !== 'search' || !getComputedStyle(input).backdropFilter.includes('blur')) {
283
+ retainInactive(candidate, existing);
284
+ continue;
285
+ }
193
286
  const fabButton = binding.trigger as HTMLIonFabButtonElement;
194
287
  const nativeTrigger = fabButton.shadowRoot?.querySelector<HTMLElement>('[part=native]');
195
288
  if (
196
- binding.trigger.closest(excluded) ||
197
- back.closest(excluded) ||
289
+ (!pageUnavailable && binding.trigger.closest(excluded)) ||
290
+ (!pageUnavailable && back.closest(excluded)) ||
198
291
  back.disabled ||
199
292
  fabButton.disabled ||
200
293
  fabButton.color ||
@@ -203,15 +296,20 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
203
296
  !nativeTrigger ||
204
297
  getComputedStyle(fabButton).backgroundColor !== 'rgba(0, 0, 0, 0)' ||
205
298
  !getComputedStyle(nativeTrigger).backdropFilter.includes('blur')
206
- )
299
+ ) {
300
+ retainInactive(candidate, existing);
207
301
  continue;
302
+ }
303
+ const state = existing ?? install(binding, bar, input);
208
304
  const trigger = readIcon(binding.trigger, binding.trigger, candidate);
209
305
  const searchIcon = bar.querySelector<HTMLElement>('.searchbar-search-icon');
210
306
  const field = searchIcon && readIcon(searchIcon, bar, candidate);
211
- if (!trigger || !field) continue;
307
+ if (!trigger || !field) {
308
+ retainInactive(candidate, state);
309
+ continue;
310
+ }
212
311
  field.label = '';
213
312
  field.accessibilityLabel = bar.getAttribute('aria-label') ?? input.getAttribute('aria-label') ?? 'Search';
214
- const state = states.get(binding) ?? install(binding, bar, input);
215
313
  state.layout = JSON.stringify([
216
314
  innerWidth,
217
315
  innerHeight,
@@ -231,15 +329,19 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
231
329
  bar.placeholder,
232
330
  bar.disabled,
233
331
  ]);
234
- if (state.rejectedLayout === state.layout) continue;
332
+ if (state.rejectedLayout === state.layout) {
333
+ retainInactive(candidate, state);
334
+ continue;
335
+ }
336
+ const available = !pageUnavailable && visible(binding.trigger);
235
337
  candidate.control.search = {
236
338
  id: id(input),
237
339
  field,
238
340
  trigger,
239
341
  closeId: id(back),
240
- active: binding.active,
241
- available: true,
242
- focused: binding.focused,
342
+ active: available && binding.active,
343
+ available,
344
+ focused: available && binding.focused,
243
345
  value: bar.value ?? '',
244
346
  placeholder: bar.placeholder ?? '',
245
347
  disabled: bar.disabled,
@@ -247,19 +349,36 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
247
349
  valueVersion: state.valueVersion,
248
350
  };
249
351
  state.last = candidate.control.search;
250
- candidate.sources = [binding.tabBar, binding.trigger.closest<HTMLElement>('ion-fab') ?? binding.trigger, binding.footer];
251
- candidate.actions.set(id(binding.trigger), binding.trigger);
252
- candidate.actions.set(id(back), back);
352
+ if (available) {
353
+ candidate.sources = [binding.tabBar, binding.trigger.closest<HTMLElement>('ion-fab') ?? binding.trigger, binding.footer];
354
+ candidate.actions.set(id(binding.trigger), binding.trigger);
355
+ candidate.actions.set(id(back), back);
356
+ }
253
357
  }
254
358
  for (const [candidate, configuration] of inactive) candidate.control.search ??= configuration;
255
359
  const groups = candidates.filter((c) => c.control.search);
256
360
  return candidates.filter((c) => !groups.some((group) => group !== c && group.sources?.some((source) => source.contains(c.element))));
257
361
  },
258
362
  projected,
363
+ hasActive() {
364
+ for (const [binding] of states) if (binding.active && projected(binding)) return true;
365
+ return false;
366
+ },
259
367
  begin(binding: NativeSearchBinding, revision: number) {
260
368
  const state = states.get(binding);
261
369
  if (state) state.minimumRevision = revision;
262
370
  },
371
+ /** Enter / focus / leave. `focused` is ignored when leaving. */
372
+ setStatus(binding: NativeSearchBinding, active: boolean, focused = false) {
373
+ if (!active) {
374
+ this.retire(binding);
375
+ return;
376
+ }
377
+ binding.active = true;
378
+ // Projection intent only. Ionic focus/blur events still wait for the native phase.
379
+ binding.focused = focused;
380
+ holdKeyboardResize();
381
+ },
263
382
  release(element: HTMLElement) {
264
383
  for (const binding of states.keys()) if (binding.footer === element) this.retire(binding);
265
384
  },
@@ -267,8 +386,7 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
267
386
  states.forEach((state) => {
268
387
  if (ids.includes(id(state.binding.tabBar))) {
269
388
  state.rejectedLayout = state.layout;
270
- // A rejected search surface has been removed by UIKit. Do not reuse its
271
- // cached configuration on an inactive page; project ordinary tabs instead.
389
+ // UIKit removed the search surface — do not reuse its cache; project ordinary tabs.
272
390
  state.last = undefined;
273
391
  }
274
392
  });
@@ -319,6 +437,7 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
319
437
  state.input.dispatchEvent(new FocusEvent('blur'));
320
438
  }
321
439
  binding.active = binding.focused = false;
440
+ releaseKeyboardResize();
322
441
  },
323
442
  destroy() {
324
443
  states.forEach((state) => {
@@ -326,6 +445,7 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
326
445
  state.restore();
327
446
  });
328
447
  states.clear();
448
+ releaseKeyboardResize();
329
449
  },
330
450
  };
331
451
  };
@@ -10,11 +10,38 @@ export interface NativeUIShellStatus {
10
10
  reason?: string;
11
11
  }
12
12
 
13
+ export interface NativeUIShellOptions {
14
+ /** Enables Native UI Shell globally. Defaults to `true`. */
15
+ enabled?: boolean;
16
+ /** Controls eligible for native projection. Omit to enable every control; when present, only `true` controls are enabled. */
17
+ controls?: NativeUIShellControls;
18
+ }
19
+
20
+ export interface NativeUIShellControls {
21
+ /** Projects tab bars and their native search presentation. */
22
+ tabs?: boolean;
23
+ /** Projects toolbar buttons, including back and menu buttons. */
24
+ toolbar?: boolean;
25
+ /** Projects segments. */
26
+ segment?: boolean;
27
+ /** Projects floating action buttons. */
28
+ fab?: boolean;
29
+ }
30
+
13
31
  export interface NativeUIShellHandle {
32
+ /** Returns the current Web/native projection state. */
14
33
  getStatus(): NativeUIShellStatus;
34
+ /** Restores projected controls to the Web until the returned lease is resumed. */
35
+ suspend(): Promise<NativeUIShellSuspension>;
36
+ /** Stops synchronization, restores Web controls and releases native resources. */
15
37
  destroy(): Promise<void>;
16
38
  }
17
39
 
40
+ export interface NativeUIShellSuspension {
41
+ /** Releases this suspension. Native projection resumes after all active suspensions are released. */
42
+ resume(): Promise<void>;
43
+ }
44
+
18
45
  export interface Frame {
19
46
  x: number;
20
47
  y: number;
@@ -98,10 +125,17 @@ export interface ShellActivation {
98
125
  sequence: number;
99
126
  }
100
127
 
128
+ export interface WebViewMetrics {
129
+ /** The WebView's effective top-left corner radius in points, or `0` when unavailable. */
130
+ radius: number;
131
+ }
132
+
101
133
  export interface NativeUIShellPlugin {
102
134
  configure(): Promise<{ supported: boolean }>;
135
+ getWebViewMetrics(): Promise<WebViewMetrics>;
103
136
  update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>;
104
137
  clear(options: { revision: number }): Promise<void>;
105
138
  addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise<PluginListenerHandle>;
106
139
  addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise<PluginListenerHandle>;
140
+ addListener(name: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise<PluginListenerHandle>;
107
141
  }
@@ -1,32 +1,59 @@
1
1
  import { Capacitor, registerPlugin } from '@capacitor/core';
2
- import type { NativeUIShellHandle, NativeUIShellPlugin } from './definitions';
2
+ import { setConfig } from '../transition/ios.transition';
3
+ import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellPlugin, WebViewMetrics } from './definitions';
4
+ import { bindMetricsLifecycle } from './lifecycle';
3
5
  import { createRuntime } from './runtime';
4
- export type { NativeUIShellComponent, NativeUIShellHandle, NativeUIShellStatus } from './definitions';
6
+ export type {
7
+ NativeUIShellComponent,
8
+ NativeUIShellControls,
9
+ NativeUIShellHandle,
10
+ NativeUIShellOptions,
11
+ NativeUIShellStatus,
12
+ NativeUIShellSuspension,
13
+ WebViewMetrics,
14
+ } from './definitions';
5
15
 
6
16
  const plugin = registerPlugin<NativeUIShellPlugin>('IonicNativeUIShell');
7
17
  let active: Promise<NativeUIShellHandle> | undefined;
8
18
  const web = (reason: string): NativeUIShellHandle => ({
9
19
  getStatus: () => ({ state: 'web', projected: 0, updates: 0, reason }),
20
+ suspend: async () => ({ resume: async () => {} }),
10
21
  destroy: async () => {},
11
22
  });
12
23
 
24
+ /** Reads the current native WebView geometry and applies it to page transitions. */
25
+ export const configureNativeTransition = async (): Promise<WebViewMetrics> => {
26
+ const metrics = typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? await plugin.getWebViewMetrics() : { radius: 0 };
27
+ setConfig({ radius: metrics.radius });
28
+ return metrics;
29
+ };
30
+
13
31
  /** Call once at application startup. Ionic markup remains the source of truth. */
14
- export const enableNativeUIShell = (): Promise<NativeUIShellHandle> => {
32
+ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise<NativeUIShellHandle> => {
33
+ if (options.enabled === false) {
34
+ const current = active;
35
+ active = undefined;
36
+ return current
37
+ ? current.then(async (handle) => {
38
+ await handle.destroy();
39
+ return web('Disabled');
40
+ })
41
+ : Promise.resolve(web('Disabled'));
42
+ }
15
43
  if (typeof document === 'undefined' || Capacitor.getPlatform() !== 'ios') return Promise.resolve(web('Requires Capacitor iOS'));
16
44
  return (active ??= (async () => {
17
45
  try {
46
+ await configureNativeTransition().catch(() => undefined);
18
47
  if (!(await plugin.configure()).supported) {
19
48
  active = undefined;
20
49
  return web('Requires iOS 26 or later');
21
50
  }
22
- const runtime = await createRuntime(document, plugin);
23
- return {
24
- getStatus: runtime.getStatus,
25
- async destroy() {
26
- await runtime.destroy();
27
- active = undefined;
28
- },
29
- };
51
+ const runtime = await createRuntime(document, plugin, options);
52
+ return await bindMetricsLifecycle(
53
+ runtime,
54
+ () => plugin.addListener('webViewMetricsChange', (metrics) => setConfig({ radius: metrics.radius })),
55
+ () => (active = undefined),
56
+ );
30
57
  } catch (error) {
31
58
  active = undefined;
32
59
  return web(error instanceof Error ? error.message : String(error));
@@ -0,0 +1,38 @@
1
+ import type { PluginListenerHandle } from '@capacitor/core';
2
+ import type { NativeUIShellHandle } from './definitions';
3
+
4
+ export const bindMetricsLifecycle = async (
5
+ runtime: NativeUIShellHandle,
6
+ listen: () => Promise<PluginListenerHandle>,
7
+ deactivate: () => void,
8
+ ): Promise<NativeUIShellHandle> => {
9
+ let listener: PluginListenerHandle;
10
+ try {
11
+ listener = await listen();
12
+ } catch (error) {
13
+ await runtime.destroy();
14
+ throw error;
15
+ }
16
+
17
+ let destroying: Promise<void> | undefined;
18
+ return {
19
+ getStatus: runtime.getStatus,
20
+ suspend: runtime.suspend,
21
+ destroy() {
22
+ return (destroying ??= (async () => {
23
+ let removalError: unknown;
24
+ try {
25
+ await listener.remove();
26
+ } catch (error) {
27
+ removalError = error;
28
+ }
29
+ try {
30
+ await runtime.destroy();
31
+ } finally {
32
+ deactivate();
33
+ }
34
+ if (removalError) throw removalError;
35
+ })());
36
+ },
37
+ };
38
+ };
@@ -2,7 +2,14 @@ import type { PluginListenerHandle } from '@capacitor/core';
2
2
  import { LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE } from '@ionic/core';
3
3
  import { getNativeSearchBindings, setNativeUIShellIntegration } from '../native-integration';
4
4
  import { createSearchSupport } from './components/searchable-tabs';
5
- import type { ShellActivation, ShellSnapshot, NativeUIShellHandle, NativeUIShellPlugin, NativeUIShellStatus } from './definitions';
5
+ import type {
6
+ ShellActivation,
7
+ ShellSnapshot,
8
+ NativeUIShellHandle,
9
+ NativeUIShellOptions,
10
+ NativeUIShellPlugin,
11
+ NativeUIShellStatus,
12
+ } from './definitions';
6
13
  import { readCandidate, selector, shadowSelector, motionSelector } from './components';
7
14
  import { marker, unprojected } from './shared/dom';
8
15
  import { createIconRenderer } from './shared/icons';
@@ -20,7 +27,11 @@ const bounded = <T>(promise: Promise<T>): Promise<T> =>
20
27
  promise.then(resolve, reject).finally(() => clearTimeout(timer));
21
28
  });
22
29
 
23
- export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin): Promise<NativeUIShellHandle> => {
30
+ export const createRuntime = async (
31
+ doc: Document,
32
+ plugin: NativeUIShellPlugin,
33
+ options: NativeUIShellOptions = {},
34
+ ): Promise<NativeUIShellHandle> => {
24
35
  const win = doc.defaultView!;
25
36
  const icons = createIconRenderer();
26
37
  const crossfade = createCrossfade(win);
@@ -30,6 +41,7 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
30
41
  const suspended = new Set<HTMLElement[]>();
31
42
  const pages = new Set<HTMLElement>();
32
43
  const presented = new Set<HTMLElement>();
44
+ const manualSuspensions = new Set<symbol>();
33
45
  const moving = new Map<HTMLElement, Set<string>>();
34
46
  const observed = new Set<Element | ShadowRoot>();
35
47
  const listeners = new AbortController();
@@ -52,6 +64,16 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
52
64
  let listener: PluginListenerHandle | undefined;
53
65
  let searchListener: PluginListenerHandle | undefined;
54
66
  let waiters: (() => void)[] = [];
67
+ const control = (kind: Candidate['control']['kind']): keyof NonNullable<NativeUIShellOptions['controls']> => {
68
+ if (kind === 'ion-tab-bar') return 'tabs';
69
+ if (kind === 'ion-segment') return 'segment';
70
+ if (kind === 'ion-fab') return 'fab';
71
+ return 'toolbar';
72
+ };
73
+ const controlEnabled = (candidate: Candidate) => {
74
+ const controls = options.controls;
75
+ return controls === undefined || controls[control(candidate.control.kind)] === true;
76
+ };
55
77
  const id = (element: Element) => {
56
78
  let value = ids.get(element);
57
79
  if (!value) {
@@ -91,6 +113,10 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
91
113
  };
92
114
  const search = createSearchSupport(doc, id, schedule);
93
115
  const candidateSources = (candidate: Candidate) => candidate.sources ?? [candidate.element];
116
+ const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => {
117
+ const candidate = readCandidate(element, id);
118
+ return candidate && controlEnabled(candidate) ? candidate : undefined;
119
+ };
94
120
  const flush = async () => {
95
121
  if (stopped) return;
96
122
  schedule();
@@ -108,6 +134,7 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
108
134
  const overlayOpen = () => {
109
135
  for (const element of presented) if (!element.isConnected) presented.delete(element);
110
136
  return (
137
+ manualSuspensions.size > 0 ||
111
138
  presented.size > 0 ||
112
139
  Array.from(doc.querySelectorAll(overlays)).some(
113
140
  (element) => (element as Element & { presented?: boolean }).presented || element.classList.contains('show-menu'),
@@ -121,14 +148,14 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
121
148
  search.keepSearchTabsVisible();
122
149
  for (const page of pages) if (!page.isConnected) pages.delete(page);
123
150
  for (const surface of moving.keys()) if (!surface.isConnected) moving.delete(surface);
124
- if (doc.hidden || overlayOpen() || (win.visualViewport && (win.visualViewport.scale !== 1 || win.visualViewport.offsetTop !== 0)))
125
- return [];
151
+ if (doc.hidden || overlayOpen()) return [];
152
+ if (win.visualViewport && (win.visualViewport.scale !== 1 || win.visualViewport.offsetTop !== 0) && !search.hasActive()) return [];
126
153
  return unprojected(sources.keys(), () =>
127
154
  search
128
155
  .decorate(
129
156
  Array.from(doc.querySelectorAll<HTMLElement>(selector))
130
157
  .filter((element) => !blocked(element))
131
- .map((element) => readCandidate(element, id))
158
+ .map(readEnabledCandidate)
132
159
  .filter((candidate): candidate is Candidate => !!candidate),
133
160
  blocked,
134
161
  )
@@ -219,6 +246,7 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
219
246
  }
220
247
  if (result.rejectedSearches?.length) {
221
248
  search.reject(result.rejectedSearches);
249
+ lastSnapshot = '';
222
250
  dirty = true;
223
251
  }
224
252
  for (const candidate of candidates) {
@@ -375,6 +403,23 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
375
403
  updates,
376
404
  reason,
377
405
  }),
406
+ async suspend() {
407
+ const token = Symbol();
408
+ let resumed = false;
409
+ if (!stopped) {
410
+ manualSuspensions.add(token);
411
+ getNativeSearchBindings(doc).forEach((binding) => search.retire(binding));
412
+ await flush();
413
+ }
414
+ return {
415
+ async resume() {
416
+ if (resumed) return;
417
+ resumed = true;
418
+ if (stopped || !manualSuspensions.delete(token)) return;
419
+ await flush();
420
+ },
421
+ };
422
+ },
378
423
  async destroy() {
379
424
  if (stopped) return;
380
425
  stopped = true;
@@ -399,6 +444,7 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
399
444
  icons.clear();
400
445
  pages.clear();
401
446
  presented.clear();
447
+ manualSuspensions.clear();
402
448
  moving.clear();
403
449
  suspended.clear();
404
450
  await listener?.remove().catch(() => {});
@@ -427,7 +473,7 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
427
473
  if (!element) return;
428
474
  const owner = Array.from(sources.keys()).find((source) => source === element || source.contains(element));
429
475
  if (!owner) return;
430
- const direct = !blocked(owner) && unprojected(sources.keys(), () => readCandidate(owner, id));
476
+ const direct = !blocked(owner) && unprojected(sources.keys(), () => readEnabledCandidate(owner));
431
477
  const candidate = direct || read().find((candidate) => candidate.actions.has(event.id));
432
478
  const item = candidate?.control.items.find((item) => item.id === event.id);
433
479
  const searchAction =
@@ -451,10 +497,14 @@ export const createRuntime = async (doc: Document, plugin: NativeUIShellPlugin):
451
497
  setNativeUIShellIntegration(doc, {
452
498
  async search(binding, active, focus) {
453
499
  if (!getNativeSearchBindings(doc).includes(binding) || !search.projected(binding)) return false;
454
- if (active && !binding.active) search.begin(binding, revision + 1);
455
- binding.active = active;
456
- binding.focused = focus ?? (active && binding.focused);
457
- if (!active) search.retire(binding);
500
+ if (!active) {
501
+ search.setStatus(binding, false);
502
+ } else {
503
+ const entering = !binding.active;
504
+ if (entering) search.begin(binding, revision + 1);
505
+ const nextFocused = focus === true ? true : focus === false ? false : entering ? false : binding.focused;
506
+ search.setStatus(binding, true, nextFocused);
507
+ }
458
508
  await flush();
459
509
  return !stopped && search.projected(binding);
460
510
  },