@rdlabo/ionic-theme-ios27 1.1.0 → 1.2.0-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 (93) hide show
  1. package/README.md +8 -1
  2. package/dist/css/components/ion-list.css +1 -1
  3. package/dist/css/components/ion-menu.css +1 -1
  4. package/dist/css/ionic-theme-ios27.css +1 -1
  5. package/dist/css/vertical-bars.css +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +15 -8
  8. package/dist/native/components/index.d.ts +1 -0
  9. package/dist/native/components/index.d.ts.map +1 -1
  10. package/dist/native/components/index.js +8 -3
  11. package/dist/native/components/ion-back-button.d.ts.map +1 -1
  12. package/dist/native/components/ion-back-button.js +5 -2
  13. package/dist/native/components/ion-button.d.ts.map +1 -1
  14. package/dist/native/components/ion-button.js +10 -2
  15. package/dist/native/components/ion-buttons.d.ts.map +1 -1
  16. package/dist/native/components/ion-buttons.js +12 -4
  17. package/dist/native/components/ion-fab.js +4 -4
  18. package/dist/native/components/ion-menu-button.d.ts.map +1 -1
  19. package/dist/native/components/ion-menu-button.js +5 -4
  20. package/dist/native/components/searchable-tabs.d.ts.map +1 -1
  21. package/dist/native/components/searchable-tabs.js +4 -1
  22. package/dist/native/definitions.d.ts +29 -3
  23. package/dist/native/definitions.d.ts.map +1 -1
  24. package/dist/native/definitions.js +6 -1
  25. package/dist/native/index.d.ts +6 -2
  26. package/dist/native/index.d.ts.map +1 -1
  27. package/dist/native/index.js +154 -17
  28. package/dist/native/prehide.d.ts +5 -0
  29. package/dist/native/prehide.d.ts.map +1 -0
  30. package/dist/native/prehide.js +246 -0
  31. package/dist/native/runtime.d.ts +1 -1
  32. package/dist/native/runtime.d.ts.map +1 -1
  33. package/dist/native/runtime.js +162 -28
  34. package/dist/native/shared/candidate.d.ts.map +1 -1
  35. package/dist/native/shared/candidate.js +7 -3
  36. package/dist/native/shared/dom.d.ts +33 -2
  37. package/dist/native/shared/dom.d.ts.map +1 -1
  38. package/dist/native/shared/dom.js +171 -10
  39. package/dist/native/vertical-bars-web.d.ts +3 -0
  40. package/dist/native/vertical-bars-web.d.ts.map +1 -0
  41. package/dist/native/vertical-bars-web.js +341 -0
  42. package/dist/native-integration/index.d.ts +3 -2
  43. package/dist/native-integration/index.d.ts.map +1 -1
  44. package/dist/native-integration/index.js +11 -5
  45. package/dist/transition/index.d.ts +2 -2
  46. package/dist/transition/index.d.ts.map +1 -1
  47. package/dist/transition/index.js +1 -1
  48. package/dist/transition/ios.transition.d.ts.map +1 -1
  49. package/dist/transition/ios.transition.js +1 -0
  50. package/dist/utils.d.ts +3 -3
  51. package/dist/utils.d.ts.map +1 -1
  52. package/dist/utils.js +1 -1
  53. package/dist/vertical-bars.d.ts +4 -0
  54. package/dist/vertical-bars.d.ts.map +1 -0
  55. package/dist/vertical-bars.js +2 -0
  56. package/docs/iphone-duo.md +255 -0
  57. package/docs/migration.md +2 -2
  58. package/docs/native-ui-shell.md +15 -3
  59. package/docs/special-markup.md +6 -0
  60. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +15 -1
  61. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +358 -0
  62. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +215 -35
  63. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +10 -1
  64. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +29 -0
  65. package/package.json +6 -2
  66. package/src/index.ts +15 -8
  67. package/src/native/components/index.ts +12 -3
  68. package/src/native/components/ion-back-button.ts +8 -2
  69. package/src/native/components/ion-button.ts +12 -2
  70. package/src/native/components/ion-buttons.ts +9 -4
  71. package/src/native/components/ion-fab.ts +4 -4
  72. package/src/native/components/ion-menu-button.ts +5 -4
  73. package/src/native/components/searchable-tabs.ts +9 -1
  74. package/src/native/definitions.ts +40 -3
  75. package/src/native/index.ts +188 -20
  76. package/src/native/prehide.ts +255 -0
  77. package/src/native/runtime.ts +173 -27
  78. package/src/native/shared/candidate.ts +11 -3
  79. package/src/native/shared/dom.ts +212 -11
  80. package/src/native/vertical-bars-web.ts +374 -0
  81. package/src/native-integration/index.ts +13 -8
  82. package/src/styles/components/ion-list.scss +2 -2
  83. package/src/styles/components/ion-menu.scss +2 -2
  84. package/src/styles/ionic-theme-ios27.scss +1 -0
  85. package/src/styles/vertical-bars.scss +342 -0
  86. package/src/transition/index.ts +11 -6
  87. package/src/transition/ios.transition.ts +1 -0
  88. package/src/utils.ts +8 -8
  89. package/src/vertical-bars.ts +3 -0
  90. package/dist/native/lifecycle.d.ts +0 -4
  91. package/dist/native/lifecycle.d.ts.map +0 -1
  92. package/dist/native/lifecycle.js +0 -34
  93. package/src/native/lifecycle.ts +0 -38
@@ -1,6 +1,6 @@
1
1
  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
- import { getNativeSearchBindings, setNativeUIShellIntegration } from '../native-integration';
3
+ import { VERTICAL_BARS_TRANSITION_CANCELED, getNativeSearchBindings, setNativeUIShellIntegration } from '../native-integration';
4
4
  import { createSearchSupport } from './components/searchable-tabs';
5
5
  import type {
6
6
  ShellActivation,
@@ -10,8 +10,17 @@ import type {
10
10
  NativeUIShellPlugin,
11
11
  NativeUIShellStatus,
12
12
  } from './definitions';
13
- import { readCandidate, selector, shadowSelector, motionSelector } from './components';
14
- import { marker, unprojected } from './shared/dom';
13
+ import { readCandidate, selector, shadowSelector, motionSelector, isVerticalBarsCandidate } from './components';
14
+ import {
15
+ activateProjectedElement,
16
+ createVerticalBarsPageState,
17
+ isVerticalBarsSource,
18
+ preferredVerticalBarsBack,
19
+ marker,
20
+ prehideOnlyMutation,
21
+ rejectedClass,
22
+ unprojected,
23
+ } from './shared/dom';
15
24
  import { createIconRenderer } from './shared/icons';
16
25
  import type { Candidate } from './shared/candidate';
17
26
  import { CSS_MOTION_EVENTS } from './shared/events';
@@ -19,6 +28,8 @@ import { createCrossfade, fadeMarker } from './shared/crossfade';
19
28
 
20
29
  const overlays = 'ion-modal, ion-popover, ion-alert, ion-action-sheet, ion-loading, ion-picker, ion-toast, ion-menu';
21
30
  const overlayNames = ['Modal', 'Popover', 'Alert', 'ActionSheet', 'Loading', 'Picker', 'Toast'];
31
+ const verticalBarsMarker = 'data-native-ui-shell-vertical-bars';
32
+ const verticalBarsMemberMarker = 'data-native-ui-shell-vertical-bars-member';
22
33
 
23
34
  // A failed bridge must not leave the source inaccessible indefinitely.
24
35
  const bounded = <T>(promise: Promise<T>): Promise<T> =>
@@ -31,6 +42,8 @@ export const createRuntime = async (
31
42
  doc: Document,
32
43
  plugin: NativeUIShellPlugin,
33
44
  options: NativeUIShellOptions = {},
45
+ nativeVerticalBars: () => boolean = () => true,
46
+ verticalBarsOnly = false,
34
47
  ): Promise<NativeUIShellHandle> => {
35
48
  const win = doc.defaultView!;
36
49
  const icons = createIconRenderer();
@@ -38,8 +51,11 @@ export const createRuntime = async (
38
51
  const ids = new WeakMap<Element, string>();
39
52
  let rejected = new WeakMap<HTMLElement, string>();
40
53
  const sources = new Map<HTMLElement, string | null>();
54
+ const verticalBarsOwners = new Set<HTMLElement>();
55
+ const verticalBarsMembers = new Set<HTMLElement>();
41
56
  const suspended = new Set<HTMLElement[]>();
42
57
  const pages = new Set<HTMLElement>();
58
+ const verticalBarsPages = createVerticalBarsPageState();
43
59
  const presented = new Set<HTMLElement>();
44
60
  const manualSuspensions = new Set<symbol>();
45
61
  const moving = new Map<HTMLElement, Set<string>>();
@@ -95,13 +111,22 @@ export const createRuntime = async (
95
111
  const style = doc.createElement('style');
96
112
  const hidden = `[${marker}]:not([${fadeMarker}])`;
97
113
  style.textContent = `${hidden}, ${hidden} *, ${hidden}::before, ${hidden}::after, ${hidden}::part(native) { visibility: hidden !important; }
98
- [${marker}], [${marker}] * { pointer-events: none !important; }`;
114
+ [${marker}], [${marker}] * { pointer-events: none !important; }
115
+ /* Ionic disables the covered page while a menu is open. VerticalBars rail
116
+ controls remain outside that page; zero specificity preserves any
117
+ pointer-events rule supplied by the application itself. */
118
+ :where(.menu-content-open) :where([${verticalBarsMarker}]) {
119
+ pointer-events: auto;
120
+ }
121
+ :where(.menu-content-open) :where([${verticalBarsMarker}]) > :where(:not([${verticalBarsMemberMarker}])) {
122
+ pointer-events: none;
123
+ }`;
99
124
 
100
125
  const restore = (element: HTMLElement) => {
101
126
  lastSnapshot = '';
102
127
  search.release(element);
103
128
  element.removeAttribute(marker);
104
- if (!stopped) crossfade.play(element, false, handoffInstant);
129
+ if (!stopped) crossfade.play(element, false, handoffInstant || isVerticalBarsSource(element));
105
130
  if (element.getAttribute('aria-hidden') === 'true') {
106
131
  const previous = sources.get(element);
107
132
  if (previous == null) element.removeAttribute('aria-hidden');
@@ -110,7 +135,13 @@ export const createRuntime = async (
110
135
  sources.delete(element);
111
136
  element.dispatchEvent(new CustomEvent('nativeUIShellChange'));
112
137
  };
113
- const restoreAll = () => Array.from(sources.keys()).forEach(restore);
138
+ const restoreAll = () => {
139
+ Array.from(sources.keys()).forEach(restore);
140
+ verticalBarsOwners.forEach((element) => element.removeAttribute(verticalBarsMarker));
141
+ verticalBarsOwners.clear();
142
+ verticalBarsMembers.forEach((element) => element.removeAttribute(verticalBarsMemberMarker));
143
+ verticalBarsMembers.clear();
144
+ };
114
145
  const finishWaiters = () => {
115
146
  const current = waiters;
116
147
  waiters = [];
@@ -123,8 +154,39 @@ export const createRuntime = async (
123
154
  };
124
155
  const search = createSearchSupport(doc, id, schedule);
125
156
  const candidateSources = (candidate: Candidate) => candidate.sources ?? [candidate.element];
157
+ const setRejected = (element: HTMLElement, value: boolean) => {
158
+ element.classList.toggle(rejectedClass, value);
159
+ };
160
+ const measuringPointerPages = new WeakSet<HTMLElement>();
126
161
  const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => {
127
- const candidate = readCandidate(element, id);
162
+ if (element.matches('ion-back-button') && element.closest('ion-app.ios-theme-vertical-bars') && !isVerticalBarsCandidate(element))
163
+ return;
164
+ if (verticalBarsOnly && !isVerticalBarsCandidate(element)) return;
165
+ const pointerPage = isVerticalBarsCandidate(element) ? element.closest<HTMLElement>('.ion-page') : undefined;
166
+ let candidate: Candidate | undefined;
167
+ if (pointerPage && getComputedStyle(pointerPage).pointerEvents === 'none') {
168
+ const previous = pointerPage.style.getPropertyValue('pointer-events');
169
+ const priority = pointerPage.style.getPropertyPriority('pointer-events');
170
+ const hadStyle = pointerPage.hasAttribute('style');
171
+ measuringPointerPages.add(pointerPage);
172
+ pointerPage.style.setProperty('pointer-events', 'auto', 'important');
173
+ try {
174
+ candidate = readCandidate(element, id);
175
+ } finally {
176
+ if (previous) pointerPage.style.setProperty('pointer-events', previous, priority);
177
+ else pointerPage.style.removeProperty('pointer-events');
178
+ if (!hadStyle && !pointerPage.style.length) pointerPage.removeAttribute('style');
179
+ win.setTimeout(() => measuringPointerPages.delete(pointerPage), 0);
180
+ }
181
+ } else candidate = readCandidate(element, id);
182
+ if (candidate && isVerticalBarsCandidate(element)) {
183
+ if (!nativeVerticalBars()) return undefined;
184
+ candidate.control.placement = 'vertical-bars';
185
+ if (['ion-button', 'ion-buttons', 'ion-menu-button'].includes(candidate.control.kind)) {
186
+ const slot = (element.matches('ion-buttons') ? element : (element.closest('ion-buttons') ?? element)).getAttribute('slot');
187
+ if (slot === 'start' || slot === 'end') candidate.control.toolbarSlot = slot;
188
+ }
189
+ }
128
190
  return candidate && controlEnabled(candidate) ? candidate : undefined;
129
191
  };
130
192
  const flush = async () => {
@@ -137,17 +199,22 @@ export const createRuntime = async (
137
199
  }
138
200
  };
139
201
  const blocked = (element: HTMLElement) =>
140
- Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element))) ||
141
- Array.from(pages).some((page) => page.contains(element)) ||
142
- Array.from(moving.keys()).some((surface) => surface.contains(element));
202
+ verticalBarsPages.isDeparted(element) ||
203
+ (!isVerticalBarsSource(element) &&
204
+ (Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element))) ||
205
+ Array.from(pages).some((scope) => scope.contains(element)) ||
206
+ Array.from(moving.keys()).some((surface) => surface.contains(element))));
143
207
  const painted = () => new Promise<void>((resolve) => win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve())));
144
- const overlayOpen = () => {
208
+ const overlayOpen = (includeMenu = true) => {
145
209
  for (const element of presented) if (!element.isConnected) presented.delete(element);
210
+ const presentedOverlayOpen = Array.from(presented).some((element) => includeMenu || !element.matches('ion-menu'));
146
211
  return (
147
212
  manualSuspensions.size > 0 ||
148
- presented.size > 0 ||
213
+ presentedOverlayOpen ||
149
214
  Array.from(doc.querySelectorAll(overlays)).some(
150
- (element) => (element as Element & { presented?: boolean }).presented || element.classList.contains('show-menu'),
215
+ (element) =>
216
+ (includeMenu || !element.matches('ion-menu')) &&
217
+ ((element as Element & { presented?: boolean }).presented || element.classList.contains('show-menu')),
151
218
  )
152
219
  );
153
220
  };
@@ -158,19 +225,33 @@ export const createRuntime = async (
158
225
  search.keepSearchTabsVisible();
159
226
  for (const page of pages) if (!page.isConnected) pages.delete(page);
160
227
  for (const surface of moving.keys()) if (!surface.isConnected) moving.delete(surface);
161
- if (doc.hidden || overlayOpen()) return [];
228
+ if (doc.hidden || overlayOpen(false)) return [];
162
229
  if (win.visualViewport && (win.visualViewport.scale !== 1 || win.visualViewport.offsetTop !== 0) && !search.hasActive()) return [];
163
- return unprojected(sources.keys(), () =>
230
+ const menuOpen = overlayOpen();
231
+ const candidates = unprojected(sources.keys(), () =>
164
232
  search
165
233
  .decorate(
166
234
  Array.from(doc.querySelectorAll<HTMLElement>(selector))
167
- .filter((element) => !blocked(element))
235
+ .filter(
236
+ (element) => !verticalBarsPages.isDeparted(element) && (!blocked(element) || (menuOpen && isVerticalBarsSource(element))),
237
+ )
168
238
  .map(readEnabledCandidate)
169
239
  .filter((candidate): candidate is Candidate => !!candidate),
170
240
  blocked,
171
241
  )
172
242
  .filter((candidate) => !rejected.has(candidate.element) || rejected.get(candidate.element) !== signature(candidate)),
173
243
  );
244
+ const back = preferredVerticalBarsBack(
245
+ candidates
246
+ .filter((candidate) => candidate.control.kind === 'ion-back-button' && isVerticalBarsCandidate(candidate.element))
247
+ .map((candidate) => candidate.element),
248
+ doc,
249
+ );
250
+ const selected = candidates.filter(
251
+ (candidate) =>
252
+ candidate.control.kind !== 'ion-back-button' || !isVerticalBarsCandidate(candidate.element) || candidate.element === back,
253
+ );
254
+ return menuOpen ? selected.filter((candidate) => isVerticalBarsCandidate(candidate.element)) : selected;
174
255
  };
175
256
  const observe = () => {
176
257
  const wanted = new Set<Element | ShadowRoot>();
@@ -202,6 +283,8 @@ export const createRuntime = async (
202
283
  reason = error instanceof Error ? error.message : String(error);
203
284
  console.warn('[Native UI Shell] Returning to Web:', reason);
204
285
  await handle.destroy();
286
+ // The Web fallback re-evaluates eligibility once native projection is stopped.
287
+ win.dispatchEvent(new Event('nativeUIShellRefresh'));
205
288
  };
206
289
  const sync = async () => {
207
290
  frame = 0;
@@ -239,12 +322,18 @@ export const createRuntime = async (
239
322
  removed.forEach(restore);
240
323
  // The outgoing tab is no longer visible, so waiting two frames only leaves its
241
324
  // native snapshot over the destination. Stack transitions still need the paint.
242
- if (!handoffInstant) {
325
+ if (!handoffInstant && removed.some((element) => !isVerticalBarsSource(element))) {
243
326
  await painted();
244
327
  if (stopped || dirty) return;
245
328
  }
246
329
  }
247
- const data = { viewportWidth: win.innerWidth, controls: candidates.map((candidate) => candidate.control) };
330
+ const root = doc.querySelector('ion-app.ios-theme-vertical-bars');
331
+ const verticalBarEdge: 'left' | 'right' | undefined = root
332
+ ? root.classList.contains('ios-theme-vertical-bars-left')
333
+ ? 'left'
334
+ : 'right'
335
+ : undefined;
336
+ const data = { viewportWidth: win.innerWidth, verticalBarEdge, controls: candidates.map((candidate) => candidate.control) };
248
337
  const serialized = JSON.stringify(data);
249
338
  if (serialized === lastSnapshot && !forceRefresh) return;
250
339
  const snapshot: ShellSnapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration(handoffInstant) };
@@ -268,6 +357,7 @@ export const createRuntime = async (
268
357
  for (const candidate of candidates) {
269
358
  if (result.rejectedControls?.includes(candidate.control.id)) {
270
359
  rejected.set(candidate.element, signatures.get(candidate.element)!);
360
+ setRejected(candidate.element, true);
271
361
  dirty = true;
272
362
  }
273
363
  }
@@ -276,6 +366,15 @@ export const createRuntime = async (
276
366
  const current = dirty ? new Map(currentCandidates.map((candidate) => [candidate.element, signature(candidate)])) : signatures;
277
367
  const currentSources = new Set(currentCandidates.flatMap(candidateSources));
278
368
  const accepted = candidates.filter((candidate) => current.get(candidate.element) === signatures.get(candidate.element));
369
+ accepted.forEach((candidate) => setRejected(candidate.element, false));
370
+ const acceptedVerticalBarsOwners = new Set(
371
+ accepted.filter((candidate) => candidate.control.placement === 'vertical-bars').map((candidate) => candidate.element),
372
+ );
373
+ const acceptedVerticalBarsMembers = new Set(
374
+ accepted
375
+ .filter((candidate) => candidate.control.placement === 'vertical-bars')
376
+ .flatMap((candidate) => Array.from(candidate.actions.values())),
377
+ );
279
378
  const invalidated = candidates.length !== accepted.length;
280
379
  acceptedRevision = result.revision;
281
380
  lastSnapshot = invalidated ? '' : serialized;
@@ -285,10 +384,28 @@ export const createRuntime = async (
285
384
  // Keep an existing cover while its content catches up. Only an ineligible
286
385
  // source needs to return to Web; new sources still require an exact ack.
287
386
  for (const element of sources.keys()) if (!currentSources.has(element)) restore(element);
387
+ for (const element of verticalBarsOwners) {
388
+ if (acceptedVerticalBarsOwners.has(element)) continue;
389
+ element.removeAttribute(verticalBarsMarker);
390
+ verticalBarsOwners.delete(element);
391
+ }
392
+ for (const element of acceptedVerticalBarsOwners) {
393
+ element.setAttribute(verticalBarsMarker, '');
394
+ verticalBarsOwners.add(element);
395
+ }
396
+ for (const element of verticalBarsMembers) {
397
+ if (acceptedVerticalBarsMembers.has(element)) continue;
398
+ element.removeAttribute(verticalBarsMemberMarker);
399
+ verticalBarsMembers.delete(element);
400
+ }
401
+ for (const element of acceptedVerticalBarsMembers) {
402
+ element.setAttribute(verticalBarsMemberMarker, '');
403
+ verticalBarsMembers.add(element);
404
+ }
288
405
  for (const element of accepted.flatMap(candidateSources)) {
289
406
  if (!sources.has(element)) {
290
407
  sources.set(element, element.getAttribute('aria-hidden'));
291
- crossfade.play(element, true, handoffInstant);
408
+ crossfade.play(element, true, handoffInstant || isVerticalBarsSource(element));
292
409
  element.setAttribute(marker, '');
293
410
  element.setAttribute('aria-hidden', 'true');
294
411
  element.dispatchEvent(new CustomEvent('nativeUIShellChange'));
@@ -320,13 +437,21 @@ export const createRuntime = async (
320
437
  else finishWaiters();
321
438
  }
322
439
  };
323
- const observation: MutationObserverInit = { subtree: true, childList: true, characterData: true, attributes: true };
440
+ const observation: MutationObserverInit = {
441
+ subtree: true,
442
+ childList: true,
443
+ characterData: true,
444
+ attributes: true,
445
+ attributeOldValue: true,
446
+ };
324
447
  const observer = new MutationObserver((records) => {
325
448
  if (
326
449
  records.some(
327
450
  (record) =>
328
451
  record.attributeName !== marker &&
329
452
  record.attributeName !== fadeMarker &&
453
+ !prehideOnlyMutation(record) &&
454
+ !(record.attributeName === 'style' && measuringPointerPages.has(record.target as HTMLElement)) &&
330
455
  !(
331
456
  record.attributeName === 'aria-hidden' &&
332
457
  sources.has(record.target as HTMLElement) &&
@@ -341,6 +466,7 @@ export const createRuntime = async (
341
466
  target.addEventListener(name, callback, { capture: true, signal: listeners.signal });
342
467
  const pageWill: EventListener = (event) => {
343
468
  const page = event.target as HTMLElement;
469
+ verticalBarsPages.lifecycle(event);
344
470
  getNativeSearchBindings(doc)
345
471
  .filter((binding) => page.contains(binding.footer))
346
472
  .forEach((binding) => search.retire(binding));
@@ -351,7 +477,9 @@ export const createRuntime = async (
351
477
  schedule();
352
478
  };
353
479
  const pageDid: EventListener = (event) => {
354
- pages.delete(event.target as HTMLElement);
480
+ const page = event.target as HTMLElement;
481
+ verticalBarsPages.lifecycle(event);
482
+ pages.delete(page);
355
483
  schedule();
356
484
  if (tabSwitchHandoff && pages.size === 0) endTabSwitchHandoff();
357
485
  };
@@ -411,6 +539,14 @@ export const createRuntime = async (
411
539
  for (const name of Object.values(CSS_MOTION_EVENTS)) on(doc, name, motion);
412
540
  for (const name of [LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE]) on(doc, name, pageWill);
413
541
  for (const name of [LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE]) on(doc, name, pageDid);
542
+ on(doc, VERTICAL_BARS_TRANSITION_CANCELED, (event) => {
543
+ const leaving = event.target as HTMLElement;
544
+ const entering = (event as CustomEvent<{ entering?: HTMLElement }>).detail?.entering;
545
+ verticalBarsPages.cancel(entering, leaving);
546
+ pages.delete(leaving);
547
+ if (entering) pages.delete(entering);
548
+ schedule();
549
+ });
414
550
  on(doc, 'ionTabsWillChange', () => {
415
551
  // Vanilla ion-tabs dispatches DOM events; @ionic/angular uses EventEmitters instead.
416
552
  armTabSwitchHandoff();
@@ -433,12 +569,16 @@ export const createRuntime = async (
433
569
  schedule();
434
570
  });
435
571
  }
572
+ const eventMenu = (event: Event) =>
573
+ event.composedPath().find((target): target is HTMLElement => target instanceof HTMLElement && target.matches('ion-menu'));
436
574
  on(doc, 'ionWillOpen', (event) => {
437
- presented.add(event.target as HTMLElement);
575
+ const menu = eventMenu(event);
576
+ if (menu) presented.add(menu);
438
577
  schedule();
439
578
  });
440
579
  on(doc, 'ionDidClose', (event) => {
441
- presented.delete(event.target as HTMLElement);
580
+ const menu = eventMenu(event);
581
+ if (menu) presented.delete(menu);
442
582
  schedule();
443
583
  });
444
584
  for (const name of [
@@ -516,6 +656,7 @@ export const createRuntime = async (
516
656
  /* Always restore the Web, even after bridge loss. */
517
657
  }
518
658
  style.remove();
659
+ doc.querySelectorAll<HTMLElement>(`.${rejectedClass}`).forEach((element) => setRejected(element, false));
519
660
  icons.clear();
520
661
  pages.clear();
521
662
  presented.clear();
@@ -534,7 +675,7 @@ export const createRuntime = async (
534
675
  event.revision < acceptedRevision ||
535
676
  event.revision > revision ||
536
677
  event.sequence <= lastSequence ||
537
- overlayOpen()
678
+ overlayOpen(false)
538
679
  )
539
680
  return;
540
681
  // Native may send input before update() resolves on the JS bridge.
@@ -555,7 +696,7 @@ export const createRuntime = async (
555
696
  candidate?.control.search && [candidate.control.search.trigger.id, candidate.control.search.closeId].includes(event.id);
556
697
  if (!searchAction && (!item || item.disabled || item.visible === false)) return;
557
698
  // The original Ionic host owns form submission, routerLink and selection events.
558
- element.click();
699
+ activateProjectedElement(element);
559
700
  lastSnapshot = ''; // Reconcile even if Ionic rejects the proposed native selection.
560
701
  schedule();
561
702
  };
@@ -591,9 +732,14 @@ export const createRuntime = async (
591
732
  await flush();
592
733
  // Source DOM has been restored before Ionic starts moving it.
593
734
  await new Promise<void>((resolve) => win.requestAnimationFrame(() => resolve()));
594
- return () => {
735
+ return (canceled = false) => {
595
736
  suspended.delete(scopes);
596
- scopes.forEach((scope) => pages.delete(scope)); // interactive cancellation has no didLeave.
737
+ if (canceled || !scopes.some((scope) => scope.closest('ion-app.ios-theme-vertical-bars'))) {
738
+ scopes.forEach((scope) => pages.delete(scope)); // Preserve ordinary iPhone handoff; cancellation has no DidLeave.
739
+ if (canceled) {
740
+ verticalBarsPages.cancel(scopes[0], scopes[1]); // The entering page is abandoned; the leaving page stays active.
741
+ }
742
+ }
597
743
  schedule();
598
744
  };
599
745
  },
@@ -1,5 +1,5 @@
1
1
  import type { ShellControl, ShellItem, NativeUIShellComponent } from '../definitions';
2
- import { frame, isDark, text, visible } from './dom';
2
+ import { frame, isDark, isVerticalBarsSource, text, visible } from './dom';
3
3
  import { iconSource } from './icons';
4
4
 
5
5
  export interface Candidate {
@@ -39,7 +39,11 @@ export const appendItem = (
39
39
  content: ParentNode = child,
40
40
  label = text(child),
41
41
  ): ShellItem | undefined => {
42
- if (!visible(child) || child.querySelector('input, button, a, img, canvas, video, ion-spinner, ion-avatar')) return;
42
+ if (
43
+ !visible(child, isVerticalBarsSource(candidate.element)) ||
44
+ child.querySelector('input, button, a, img, canvas, video, ion-spinner, ion-avatar')
45
+ )
46
+ return;
43
47
  const icons = Array.from(content.querySelectorAll<HTMLElement>('ion-icon'));
44
48
  const directSVGs = Array.from(content.querySelectorAll<SVGElement>('svg'));
45
49
  if (icons.length + directSVGs.length > 1) return undefined;
@@ -54,7 +58,11 @@ export const appendItem = (
54
58
  id: id(child),
55
59
  ...frame(child.getBoundingClientRect(), candidate.element.getBoundingClientRect()),
56
60
  label,
57
- accessibilityLabel: child.getAttribute('aria-label') ?? native?.getAttribute('aria-label') ?? label,
61
+ accessibilityLabel:
62
+ child.getAttribute('aria-label') ??
63
+ native?.getAttribute('aria-label') ??
64
+ (icons[0]?.getAttribute('aria-hidden') === 'true' ? null : icons[0]?.getAttribute('aria-label')) ??
65
+ label,
58
66
  disabled:
59
67
  !!(child as ItemElement).disabled ||
60
68
  !!(candidate.element as ItemElement).disabled ||