@rdlabo/ionic-theme-ios27 1.2.0-1 → 1.2.0-3

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 (75) hide show
  1. package/README.md +9 -0
  2. package/dist/css/components/ion-tabs.css +1 -1
  3. package/dist/css/ionic-theme-ios27.css +1 -1
  4. package/dist/css/vertical-bars.css +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +31 -12
  7. package/dist/native/components/index.d.ts.map +1 -1
  8. package/dist/native/components/index.js +4 -1
  9. package/dist/native/components/ion-button.d.ts.map +1 -1
  10. package/dist/native/components/ion-button.js +3 -3
  11. package/dist/native/components/ion-buttons.d.ts.map +1 -1
  12. package/dist/native/components/ion-buttons.js +3 -2
  13. package/dist/native/components/searchable-tabs.d.ts.map +1 -1
  14. package/dist/native/components/searchable-tabs.js +6 -7
  15. package/dist/native/definitions.d.ts +8 -15
  16. package/dist/native/definitions.d.ts.map +1 -1
  17. package/dist/native/definitions.js +1 -6
  18. package/dist/native/index.d.ts +1 -2
  19. package/dist/native/index.d.ts.map +1 -1
  20. package/dist/native/index.js +64 -28
  21. package/dist/native/prehide.d.ts.map +1 -1
  22. package/dist/native/prehide.js +6 -4
  23. package/dist/native/runtime.d.ts.map +1 -1
  24. package/dist/native/runtime.js +40 -19
  25. package/dist/native/shared/candidate.d.ts.map +1 -1
  26. package/dist/native/shared/candidate.js +8 -0
  27. package/dist/native/shared/dom.d.ts.map +1 -1
  28. package/dist/native/shared/dom.js +11 -10
  29. package/dist/native/shared/modal.d.ts +8 -0
  30. package/dist/native/shared/modal.d.ts.map +1 -0
  31. package/dist/native/shared/modal.js +143 -0
  32. package/dist/native/vertical-bars-web.d.ts.map +1 -1
  33. package/dist/native/vertical-bars-web.js +11 -3
  34. package/dist/sheets-of-glass/animations.d.ts +13 -4
  35. package/dist/sheets-of-glass/animations.d.ts.map +1 -1
  36. package/dist/sheets-of-glass/animations.js +56 -40
  37. package/dist/sheets-of-glass/index.d.ts +1 -1
  38. package/dist/sheets-of-glass/index.d.ts.map +1 -1
  39. package/dist/sheets-of-glass/index.js +64 -20
  40. package/dist/tab-bar-searchable/index.d.ts.map +1 -1
  41. package/dist/tab-bar-searchable/index.js +39 -11
  42. package/dist/vertical-bars.d.ts +1 -2
  43. package/dist/vertical-bars.d.ts.map +1 -1
  44. package/dist/vertical-bars.js +0 -1
  45. package/docs/iphone-duo-with-original-theme.md +37 -15
  46. package/docs/iphone-duo.md +90 -47
  47. package/docs/migration.md +3 -3
  48. package/docs/native-ui-shell.md +12 -8
  49. package/docs/special-markup.md +0 -13
  50. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +1 -1
  51. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +50 -6
  52. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalSearch.swift +118 -0
  53. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +32 -179
  54. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +5 -0
  55. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +67 -0
  56. package/package.json +1 -1
  57. package/src/index.ts +40 -9
  58. package/src/native/components/index.ts +6 -1
  59. package/src/native/components/ion-button.ts +4 -3
  60. package/src/native/components/ion-buttons.ts +4 -2
  61. package/src/native/components/searchable-tabs.ts +8 -11
  62. package/src/native/definitions.ts +11 -19
  63. package/src/native/index.ts +67 -30
  64. package/src/native/prehide.ts +7 -4
  65. package/src/native/runtime.ts +40 -18
  66. package/src/native/shared/candidate.ts +10 -0
  67. package/src/native/shared/dom.ts +11 -10
  68. package/src/native/shared/modal.ts +157 -0
  69. package/src/native/vertical-bars-web.ts +12 -3
  70. package/src/sheets-of-glass/animations.ts +66 -41
  71. package/src/sheets-of-glass/index.ts +82 -16
  72. package/src/styles/components/ion-tabs.scss +7 -4
  73. package/src/styles/vertical-bars.scss +110 -35
  74. package/src/tab-bar-searchable/index.ts +40 -9
  75. package/src/vertical-bars.ts +1 -2
@@ -1,3 +1,4 @@
1
+ import { inVerticalBarsSurface, topModal, modalUsesVerticalBars, modalVerticalBarFrame } from './shared/modal';
1
2
  import type { PluginListenerHandle } from '@capacitor/core';
2
3
  import { LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE } from '@ionic/core';
3
4
  import { VERTICAL_BARS_TRANSITION_CANCELED, getNativeSearchBindings, setNativeUIShellIntegration } from '../native-integration';
@@ -126,7 +127,7 @@ export const createRuntime = async (
126
127
  lastSnapshot = '';
127
128
  search.release(element);
128
129
  element.removeAttribute(marker);
129
- if (!stopped) crossfade.play(element, false, handoffInstant || isVerticalBarsSource(element));
130
+ if (!stopped) crossfade.play(element, false, isVerticalBarsSource(element) ? !element.matches('ion-tab-bar') : handoffInstant);
130
131
  if (element.getAttribute('aria-hidden') === 'true') {
131
132
  const previous = sources.get(element);
132
133
  if (previous == null) element.removeAttribute('aria-hidden');
@@ -205,14 +206,19 @@ export const createRuntime = async (
205
206
  Array.from(pages).some((scope) => scope.contains(element)) ||
206
207
  Array.from(moving.keys()).some((surface) => surface.contains(element))));
207
208
  const painted = () => new Promise<void>((resolve) => win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve())));
208
- const overlayOpen = (includeMenu = true) => {
209
+ const overlayOpen = (includeMenu = true, allowModal = false) => {
210
+ const modal = topModal(doc);
211
+ const ignored = (element: Element) => allowModal && !!modal && modalUsesVerticalBars(modal) && element.matches('ion-modal');
209
212
  for (const element of presented) if (!element.isConnected) presented.delete(element);
210
- const presentedOverlayOpen = Array.from(presented).some((element) => includeMenu || !element.matches('ion-menu'));
213
+ const presentedOverlayOpen = Array.from(presented).some(
214
+ (element) => !ignored(element) && (includeMenu || !element.matches('ion-menu')),
215
+ );
211
216
  return (
212
217
  manualSuspensions.size > 0 ||
213
218
  presentedOverlayOpen ||
214
219
  Array.from(doc.querySelectorAll(overlays)).some(
215
220
  (element) =>
221
+ !ignored(element) &&
216
222
  (includeMenu || !element.matches('ion-menu')) &&
217
223
  ((element as Element & { presented?: boolean }).presented || element.classList.contains('show-menu')),
218
224
  )
@@ -225,7 +231,7 @@ export const createRuntime = async (
225
231
  search.keepSearchTabsVisible();
226
232
  for (const page of pages) if (!page.isConnected) pages.delete(page);
227
233
  for (const surface of moving.keys()) if (!surface.isConnected) moving.delete(surface);
228
- if (doc.hidden || overlayOpen(false)) return [];
234
+ if (doc.hidden || overlayOpen(false, true)) return [];
229
235
  if (win.visualViewport && (win.visualViewport.scale !== 1 || win.visualViewport.offsetTop !== 0) && !search.hasActive()) return [];
230
236
  const menuOpen = overlayOpen();
231
237
  const candidates = unprojected(sources.keys(), () =>
@@ -233,7 +239,10 @@ export const createRuntime = async (
233
239
  .decorate(
234
240
  Array.from(doc.querySelectorAll<HTMLElement>(selector))
235
241
  .filter(
236
- (element) => !verticalBarsPages.isDeparted(element) && (!blocked(element) || (menuOpen && isVerticalBarsSource(element))),
242
+ (element) =>
243
+ inVerticalBarsSurface(element) &&
244
+ !verticalBarsPages.isDeparted(element) &&
245
+ (!blocked(element) || (menuOpen && isVerticalBarsSource(element))),
237
246
  )
238
247
  .map(readEnabledCandidate)
239
248
  .filter((candidate): candidate is Candidate => !!candidate),
@@ -333,7 +342,14 @@ export const createRuntime = async (
333
342
  ? 'left'
334
343
  : 'right'
335
344
  : undefined;
336
- const data = { viewportWidth: win.innerWidth, verticalBarEdge, controls: candidates.map((candidate) => candidate.control) };
345
+ const modal = topModal(doc);
346
+ const verticalBarFrame = modal && modalUsesVerticalBars(modal) ? modalVerticalBarFrame(modal) : undefined;
347
+ const data = {
348
+ verticalBarFrame,
349
+ viewportWidth: win.innerWidth,
350
+ verticalBarEdge,
351
+ controls: candidates.map((candidate) => candidate.control),
352
+ };
337
353
  const serialized = JSON.stringify(data);
338
354
  if (serialized === lastSnapshot && !forceRefresh) return;
339
355
  const snapshot: ShellSnapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration(handoffInstant) };
@@ -403,13 +419,14 @@ export const createRuntime = async (
403
419
  verticalBarsMembers.add(element);
404
420
  }
405
421
  for (const element of accepted.flatMap(candidateSources)) {
422
+ const newlyProjected = !sources.has(element) || !element.hasAttribute(marker);
406
423
  if (!sources.has(element)) {
407
424
  sources.set(element, element.getAttribute('aria-hidden'));
408
425
  crossfade.play(element, true, handoffInstant || isVerticalBarsSource(element));
409
- element.setAttribute(marker, '');
410
- element.setAttribute('aria-hidden', 'true');
411
- element.dispatchEvent(new CustomEvent('nativeUIShellChange'));
412
426
  }
427
+ element.setAttribute(marker, '');
428
+ element.setAttribute('aria-hidden', 'true');
429
+ if (newlyProjected) element.dispatchEvent(new CustomEvent('nativeUIShellChange'));
413
430
  }
414
431
  const received = pendingActivations;
415
432
  pendingActivations = [];
@@ -591,24 +608,29 @@ export const createRuntime = async (
591
608
  'focusout',
592
609
  ])
593
610
  on(doc, name);
594
- on(doc, 'visibilitychange', () => {
595
- if (doc.hidden) getNativeSearchBindings(doc).forEach((binding) => search.retire(binding));
611
+ const refreshLayout = () => {
612
+ rejected = new WeakMap();
613
+ forceRefresh = true;
596
614
  schedule();
615
+ };
616
+ on(doc, 'visibilitychange', () => {
617
+ if (doc.hidden) {
618
+ getNativeSearchBindings(doc).forEach((binding) => search.retire(binding));
619
+ schedule();
620
+ } else refreshLayout();
597
621
  });
598
- on(win, 'resize');
622
+ on(win, 'resize', refreshLayout);
599
623
  on(win, 'keyboardWillShow', () => search.keyboard(true));
600
624
  on(win, 'keyboardWillHide', () => search.keyboard(false));
601
625
  on(win, 'scroll');
602
626
  on(win.matchMedia('(prefers-color-scheme: dark)'), 'change');
603
627
  on(win, 'nativeUIShellRefresh', (event) => {
604
- rejected = new WeakMap();
605
628
  if ((event as Event & { retireSearch?: boolean }).retireSearch)
606
629
  getNativeSearchBindings(doc).forEach((binding) => search.retire(binding));
607
- forceRefresh = true;
608
- schedule();
630
+ refreshLayout();
609
631
  });
610
632
  if (win.visualViewport) {
611
- on(win.visualViewport, 'resize');
633
+ on(win.visualViewport, 'resize', refreshLayout);
612
634
  on(win.visualViewport, 'scroll');
613
635
  }
614
636
  const handle: NativeUIShellHandle = {
@@ -675,7 +697,7 @@ export const createRuntime = async (
675
697
  event.revision < acceptedRevision ||
676
698
  event.revision > revision ||
677
699
  event.sequence <= lastSequence ||
678
- overlayOpen(false)
700
+ overlayOpen(false, true)
679
701
  )
680
702
  return;
681
703
  // Native may send input before update() resolves on the JS bridge.
@@ -688,7 +710,7 @@ export const createRuntime = async (
688
710
  const element = actions.get(event.id);
689
711
  if (!element) return;
690
712
  const owner = Array.from(sources.keys()).find((source) => source === element || source.contains(element));
691
- if (!owner) return;
713
+ if (!owner || !inVerticalBarsSurface(owner)) return;
692
714
  const direct = !blocked(owner) && unprojected(sources.keys(), () => readEnabledCandidate(owner));
693
715
  const candidate = direct || read().find((candidate) => candidate.actions.has(event.id));
694
716
  const item = candidate?.control.items.find((item) => item.id === event.id);
@@ -54,6 +54,10 @@ export const appendItem = (
54
54
  const labelStyle = getComputedStyle(labelElement);
55
55
  const badge = child.querySelector<HTMLElement>('ion-badge');
56
56
  const badgeStyle = badge && visible(badge) ? getComputedStyle(badge) : undefined;
57
+ const outline =
58
+ isVerticalBarsSource(child) && child.matches('ion-button') && (child as HTMLIonButtonElement).fill === 'outline'
59
+ ? getComputedStyle(native ?? child)
60
+ : undefined;
57
61
  const item: ShellItem = {
58
62
  id: id(child),
59
63
  ...frame(child.getBoundingClientRect(), candidate.element.getBoundingClientRect()),
@@ -71,6 +75,12 @@ export const appendItem = (
71
75
  fontSize: parseFloat(labelStyle.fontSize),
72
76
  fontWeight: parseInt(labelStyle.fontWeight, 10) || 400,
73
77
  color: getComputedStyle(native ?? child).color,
78
+ backgroundColor:
79
+ isVerticalBarsSource(child) && child.matches('ion-button') && (child as HTMLIonButtonElement).fill === 'solid'
80
+ ? getComputedStyle(native ?? child).backgroundColor
81
+ : undefined,
82
+ borderColor: outline?.borderTopColor,
83
+ borderWidth: outline ? parseFloat(outline.borderTopWidth) : undefined,
74
84
  badge: badgeStyle
75
85
  ? { value: badge!.textContent?.trim() ?? '', color: badgeStyle.backgroundColor, textColor: badgeStyle.color }
76
86
  : undefined,
@@ -1,4 +1,5 @@
1
1
  import { fadeMarker } from './crossfade';
2
+ import { modalUsesVerticalBars } from './modal';
2
3
 
3
4
  import type { Frame } from '../definitions';
4
5
 
@@ -98,7 +99,8 @@ const verticalBarsTags = new Set(['ion-button', 'ion-back-button', 'ion-buttons'
98
99
  export const isVerticalBarsSource = (element: HTMLElement): boolean =>
99
100
  verticalBarsTags.has(element.localName) &&
100
101
  (element.matches('ion-tab-bar') || verticalBarsOwned(element)) &&
101
- !element.closest('ion-menu, ion-modal, ion-popover') &&
102
+ !element.closest('ion-menu, ion-popover') &&
103
+ modalUsesVerticalBars(element) &&
102
104
  !!element.closest('ion-app.ios-theme-vertical-bars');
103
105
 
104
106
  export const isVerticalBarsBackPosition = (element: HTMLElement): boolean => {
@@ -109,7 +111,8 @@ export const isVerticalBarsBackPosition = (element: HTMLElement): boolean => {
109
111
  (back) =>
110
112
  back.closest('.ion-page') === page &&
111
113
  !back.matches('.ion-cloned-element') &&
112
- !back.closest('ion-header[collapse], ion-footer[collapse], ion-menu, ion-modal, ion-popover'),
114
+ !back.closest('ion-header[collapse], ion-footer[collapse], ion-menu, ion-popover') &&
115
+ modalUsesVerticalBars(back),
113
116
  );
114
117
  const rank = (back: HTMLElement) =>
115
118
  inFixedToolbar(back) ? 0 : back.closest('ion-header ion-toolbar, ion-footer ion-toolbar') ? 1 : back.closest('ion-toolbar') ? 3 : 2;
@@ -141,11 +144,7 @@ export const isShellDisabled = (element: Element): boolean =>
141
144
 
142
145
  export const isVerticalBarsToolbarActionShape = (element: HTMLElement): boolean =>
143
146
  element.matches('ion-menu-button') ||
144
- (element.matches('ion-button') &&
145
- !!element.querySelector('ion-icon, svg') &&
146
- !childNodesOf(element).some((node) => node.nodeType === 3 && !!node.textContent?.trim()) &&
147
- !element.matches('.ion-color, [color]') &&
148
- ['default', 'clear'].includes((element as HTMLIonButtonElement).fill ?? element.getAttribute('fill') ?? 'default'));
147
+ (element.matches('ion-button') && !!element.querySelector('ion-icon[slot="icon-only"], svg[slot="icon-only"]'));
149
148
 
150
149
  // Placement belongs to the DOM identity for one routed-page epoch. Changes to
151
150
  // content/disabled state affect rendering, never its chosen surface.
@@ -161,7 +160,7 @@ export const verticalBarsOwned = (element: HTMLElement): boolean => verticalBars
161
160
  export const isVerticalBarsToolbarAction = (element: HTMLElement): boolean =>
162
161
  verticalBarsOwned(element) && !isExcluded(element, verticalBarsEnteringPage(element)) && !isShellDisabled(element);
163
162
 
164
- const overlays = 'ion-menu, ion-modal, ion-popover';
163
+ const overlays = 'ion-menu, ion-popover';
165
164
 
166
165
  /** Shared eligibility for a toolbar action candidate, before placement is decided (prehide) or consumed (projection). */
167
166
  export const verticalBarsActionCandidate = (element: HTMLElement): boolean =>
@@ -169,7 +168,8 @@ export const verticalBarsActionCandidate = (element: HTMLElement): boolean =>
169
168
  !element.closest('ion-buttons.ios-theme-horizontal-only, ion-button.ios-theme-horizontal-only') &&
170
169
  !isPermanentlyExcluded(element) &&
171
170
  !isShellDisabled(element) &&
172
- !element.closest(overlays);
171
+ !element.closest(overlays) &&
172
+ modalUsesVerticalBars(element);
173
173
 
174
174
  /** Shared eligibility for a back-button candidate; the back button may sit outside a fixed toolbar. */
175
175
  export const verticalBarsBackCandidate = (element: HTMLElement): boolean =>
@@ -177,7 +177,8 @@ export const verticalBarsBackCandidate = (element: HTMLElement): boolean =>
177
177
  !element.closest('ion-buttons.ios-theme-horizontal-only') &&
178
178
  !isPermanentlyExcluded(element) &&
179
179
  !isShellDisabled(element) &&
180
- !element.closest(overlays);
180
+ !element.closest(overlays) &&
181
+ modalUsesVerticalBars(element);
181
182
 
182
183
  export const verticalBarsToolbarActions = (element: HTMLElement): HTMLElement[] =>
183
184
  // Back buttons use the dedicated rail slot; they are never toolbar actions.
@@ -0,0 +1,157 @@
1
+ import type { Frame } from '../definitions';
2
+
3
+ /** Modal hosts cover the viewport even when their visible dialog is centered. */
4
+ export const modalRailClass = 'ios-theme-vertical-bars-modal';
5
+ const states = new WeakMap<Document, { top?: HTMLIonModalElement }>();
6
+
7
+ export const topModal = (doc: Document): HTMLIonModalElement | undefined => states.get(doc)?.top;
8
+ export const modalUsesVerticalBars = (element: Element): boolean => {
9
+ const modal = element.closest('ion-modal');
10
+ return !modal || modal.classList.contains(modalRailClass);
11
+ };
12
+
13
+ /** Only the foreground modal may contribute controls; never the covered page. */
14
+ export const inVerticalBarsSurface = (element: Element): boolean => {
15
+ const modal = topModal(element.ownerDocument);
16
+ return modal ? element.closest('ion-modal') === modal && modalUsesVerticalBars(element) : !element.closest('ion-modal');
17
+ };
18
+
19
+ export const observeVerticalBarsModals = (doc: Document): (() => void) => {
20
+ const win = doc.defaultView!;
21
+ const state: { top?: HTMLIonModalElement } = {};
22
+ states.set(doc, state);
23
+ const presenting = new Set<HTMLIonModalElement>();
24
+ const dismissed = new WeakSet<HTMLIonModalElement>();
25
+ const observed = new Set<Element>();
26
+ const listeners = new AbortController();
27
+ let stopped = false;
28
+ let frame = 0;
29
+ let pointerActive = false;
30
+ let bounds = '';
31
+ const toolbarTop = '--ios-theme-vertical-bars-modal-offset-top';
32
+ const refresh = () => {
33
+ win.cancelAnimationFrame(frame);
34
+ frame = 0;
35
+ if (stopped) return;
36
+ const app = doc.querySelector('ion-app.ios-theme-vertical-bars');
37
+ const modals = Array.from(doc.querySelectorAll<HTMLIonModalElement>('ion-modal'));
38
+ const active = modals.filter(
39
+ (modal) => !dismissed.has(modal) && ((modal as HTMLIonModalElement & { presented?: boolean }).presented || presenting.has(modal)),
40
+ );
41
+ for (const modal of presenting) if (!modal.isConnected) presenting.delete(modal);
42
+ const top = active.sort((a, b) => a.overlayIndex - b.overlayIndex).slice(-1)[0];
43
+ let changed = state.top !== top;
44
+ state.top = top;
45
+ const nextBounds = top ? JSON.stringify(modalVerticalBarFrame(top)) : '';
46
+ changed ||= bounds !== nextBounds;
47
+ bounds = nextBounds;
48
+ const wanted = new Set<Element>();
49
+ for (const modal of modals) {
50
+ const content = modal.shadowRoot?.querySelector<HTMLElement>('[part~="content"]');
51
+ if (content) wanted.add(content);
52
+ // offsetWidth is unaffected by Ionic's presentation transforms.
53
+ const fullWidth = !!content && Math.abs(content.offsetWidth - win.innerWidth) <= 1 && Math.abs(content.offsetLeft) <= 1;
54
+ const enabled = !!app?.contains(modal) && active.includes(modal) && fullWidth;
55
+ if (enabled) {
56
+ const top = modalVerticalBarFrame(modal)?.y ?? 0;
57
+ const offset = `${top}px`;
58
+ if (modal.style.getPropertyValue(toolbarTop) !== offset) {
59
+ modal.style.setProperty(toolbarTop, offset);
60
+ changed = true;
61
+ }
62
+ } else modal.style.removeProperty(toolbarTop);
63
+ if (modal.classList.contains(modalRailClass) !== enabled) {
64
+ modal.classList.toggle(modalRailClass, enabled);
65
+ changed = true;
66
+ }
67
+ }
68
+ if (wanted.size !== observed.size || [...wanted].some((element) => !observed.has(element))) {
69
+ resize?.disconnect();
70
+ observer.disconnect();
71
+ observer.observe(doc.documentElement, observation);
72
+ observed.clear();
73
+ for (const element of wanted) {
74
+ resize?.observe(element);
75
+ observer.observe(element, { attributes: true, attributeFilter: ['style', 'class'] });
76
+ observed.add(element);
77
+ }
78
+ }
79
+ if (changed) win.dispatchEvent(new Event('nativeUIShellRefresh'));
80
+ if (
81
+ top &&
82
+ (pointerActive ||
83
+ Array.from(wanted).some((element) => element.getAnimations().some((animation) => animation.playState === 'running')))
84
+ )
85
+ schedule();
86
+ };
87
+ const schedule = () => {
88
+ if (!stopped && !frame) frame = win.requestAnimationFrame(refresh);
89
+ };
90
+ const resize = win.ResizeObserver ? new win.ResizeObserver(schedule) : undefined;
91
+ const observer = new MutationObserver(schedule);
92
+ const observation = { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'style'] };
93
+ observer.observe(doc.documentElement, observation);
94
+ for (const name of ['ionModalWillPresent', 'ionModalDidPresent', 'ionModalWillDismiss', 'ionModalDidDismiss', 'ionBreakpointDidChange']) {
95
+ doc.addEventListener(
96
+ name,
97
+ (event) => {
98
+ const modal = event.target as HTMLIonModalElement;
99
+ if (modal.localName !== 'ion-modal') return;
100
+ if (name === 'ionModalDidDismiss') {
101
+ presenting.delete(modal);
102
+ dismissed.add(modal);
103
+ } else {
104
+ presenting.add(modal);
105
+ dismissed.delete(modal);
106
+ }
107
+ refresh();
108
+ },
109
+ { capture: true, signal: listeners.signal },
110
+ );
111
+ }
112
+ doc.addEventListener(
113
+ 'pointerdown',
114
+ (event) => {
115
+ if ((event.target as Element).closest('ion-modal')) {
116
+ pointerActive = true;
117
+ schedule();
118
+ }
119
+ },
120
+ { capture: true, signal: listeners.signal },
121
+ );
122
+ for (const name of ['pointerup', 'pointercancel'])
123
+ doc.addEventListener(
124
+ name,
125
+ () => {
126
+ pointerActive = false;
127
+ schedule();
128
+ },
129
+ { capture: true, signal: listeners.signal },
130
+ );
131
+ win.addEventListener('resize', schedule, { signal: listeners.signal });
132
+ win.addEventListener('nativeUIShellRefresh', schedule, { signal: listeners.signal });
133
+ refresh();
134
+ return () => {
135
+ stopped = true;
136
+ win.cancelAnimationFrame(frame);
137
+ observer.disconnect();
138
+ resize?.disconnect();
139
+ listeners.abort();
140
+ if (states.get(doc) !== state) return;
141
+ states.delete(doc);
142
+ doc.querySelectorAll<HTMLElement>(`.${modalRailClass}`).forEach((modal) => {
143
+ modal.classList.remove(modalRailClass);
144
+ modal.style.removeProperty(toolbarTop);
145
+ });
146
+ };
147
+ };
148
+
149
+ /** Visible modal bounds, clipped at the viewport for Ionic's translated sheets. */
150
+ export const modalVerticalBarFrame = (modal: Element): Frame | undefined => {
151
+ const rect = modal.shadowRoot?.querySelector('[part~="content"]')?.getBoundingClientRect();
152
+ const win = modal.ownerDocument.defaultView!;
153
+ if (!rect) return;
154
+ const y = Math.max(0, rect.top);
155
+ const height = Math.min(win.innerHeight, rect.bottom) - y;
156
+ return height > 0 ? { x: 0, y, width: win.innerWidth, height } : undefined;
157
+ };
@@ -1,3 +1,4 @@
1
+ import { inVerticalBarsSurface, topModal } from './shared/modal';
1
2
  import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellStatus } from './definitions';
2
3
  import { VERTICAL_BARS_TRANSITION_CANCELED } from '../native-integration';
3
4
  import {
@@ -74,11 +75,13 @@ export const createVerticalBarsWebProjection = (
74
75
  });
75
76
  const inEligibleToolbar = (element: HTMLElement) =>
76
77
  !!verticalBarsRoot()?.contains(element) &&
78
+ inVerticalBarsSurface(element) &&
77
79
  verticalBarsActionCandidate(element) &&
78
80
  !isExcluded(element, verticalBarsEnteringPage(element)) &&
79
81
  !verticalBarsPages.isDeparted(element);
80
82
  const isEligibleBack = (element: HTMLIonBackButtonElement) =>
81
83
  !!verticalBarsRoot()?.contains(element) &&
84
+ inVerticalBarsSurface(element) &&
82
85
  verticalBarsOwned(element) &&
83
86
  verticalBarsBackCandidate(element) &&
84
87
  !isExcluded(element, verticalBarsEnteringPage(element)) &&
@@ -151,7 +154,10 @@ export const createVerticalBarsWebProjection = (
151
154
  const syncAction = (target: HTMLElement, original: HTMLElement) => {
152
155
  copyAttributes(target, original);
153
156
  target.classList.add('ios-theme-vertical-bars-toolbar-action', 'ion-cloned-element');
154
- const label = original.getAttribute('aria-label') ?? original.textContent?.trim();
157
+ const label =
158
+ original.getAttribute('aria-label') ??
159
+ original.shadowRoot?.querySelector('[part~=native]')?.getAttribute('aria-label') ??
160
+ original.textContent?.trim();
155
161
  if (label) target.setAttribute('aria-label', label);
156
162
  if ('disabled' in original) (target as HTMLIonButtonElement).disabled = (original as HTMLIonButtonElement).disabled;
157
163
  if (original.matches('ion-button')) {
@@ -185,7 +191,7 @@ export const createVerticalBarsWebProjection = (
185
191
  }
186
192
  };
187
193
  const project = (nextBack: HTMLIonBackButtonElement | undefined, groups: ToolbarSource[]) => {
188
- root = verticalBarsRoot()!;
194
+ root = topModal(doc) ?? verticalBarsRoot()!;
189
195
  let topOffset = 0;
190
196
  if (nextBack) {
191
197
  backSource = nextBack;
@@ -269,7 +275,7 @@ export const createVerticalBarsWebProjection = (
269
275
  const nextBack = findBack();
270
276
  const groups = findToolbarGroups();
271
277
  if (!nextBack && !groups.length) return restore();
272
- if (sameSources(nextBack, groups)) return syncExisting();
278
+ if (root === (topModal(doc) ?? currentRoot) && sameSources(nextBack, groups)) return syncExisting();
273
279
  restore();
274
280
  project(nextBack, groups);
275
281
  updates++;
@@ -287,6 +293,8 @@ export const createVerticalBarsWebProjection = (
287
293
  };
288
294
  const schedule = (): Promise<void> => {
289
295
  if (stopped) return Promise.resolve();
296
+ // Release Web ownership before the native runtime can acknowledge its next frame.
297
+ if (!enabled()) restore();
290
298
  const done = new Promise<void>((resolve) => waiters.push(resolve));
291
299
  if (!frame) frame = win.requestAnimationFrame(update);
292
300
  return done;
@@ -336,6 +344,7 @@ export const createVerticalBarsWebProjection = (
336
344
  doc.addEventListener(VERTICAL_BARS_TRANSITION_CANCELED, pageLifecycle, { capture: true, signal: listeners.signal });
337
345
  for (const name of ['ionModalWillPresent', 'ionModalDidDismiss'])
338
346
  doc.addEventListener(name, schedule, { capture: true, signal: listeners.signal });
347
+ win.addEventListener('resize', schedule, { signal: listeners.signal });
339
348
  win.addEventListener('nativeUIShellRefresh', schedule, { signal: listeners.signal });
340
349
  schedule();
341
350