@rdlabo/ionic-theme-ios27 1.2.0-0 → 1.2.0-2

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 (78) hide show
  1. package/README.md +12 -2
  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 +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +32 -12
  8. package/dist/native/components/index.d.ts.map +1 -1
  9. package/dist/native/components/index.js +4 -1
  10. package/dist/native/components/ion-button.d.ts.map +1 -1
  11. package/dist/native/components/ion-button.js +3 -3
  12. package/dist/native/components/ion-buttons.d.ts.map +1 -1
  13. package/dist/native/components/ion-buttons.js +3 -2
  14. package/dist/native/definitions.d.ts +8 -15
  15. package/dist/native/definitions.d.ts.map +1 -1
  16. package/dist/native/definitions.js +1 -6
  17. package/dist/native/index.d.ts +1 -2
  18. package/dist/native/index.d.ts.map +1 -1
  19. package/dist/native/index.js +64 -28
  20. package/dist/native/prehide.d.ts.map +1 -1
  21. package/dist/native/prehide.js +6 -4
  22. package/dist/native/runtime.d.ts.map +1 -1
  23. package/dist/native/runtime.js +27 -12
  24. package/dist/native/shared/candidate.d.ts.map +1 -1
  25. package/dist/native/shared/candidate.js +8 -0
  26. package/dist/native/shared/dom.d.ts.map +1 -1
  27. package/dist/native/shared/dom.js +11 -10
  28. package/dist/native/shared/modal.d.ts +8 -0
  29. package/dist/native/shared/modal.d.ts.map +1 -0
  30. package/dist/native/shared/modal.js +143 -0
  31. package/dist/native/vertical-bars-web.d.ts.map +1 -1
  32. package/dist/native/vertical-bars-web.js +11 -3
  33. package/dist/native-integration/index.d.ts.map +1 -1
  34. package/dist/native-integration/index.js +7 -2
  35. package/dist/native-integration/transition.d.ts +3 -0
  36. package/dist/native-integration/transition.d.ts.map +1 -0
  37. package/dist/native-integration/transition.js +7 -0
  38. package/dist/sheets-of-glass/animations.d.ts +13 -4
  39. package/dist/sheets-of-glass/animations.d.ts.map +1 -1
  40. package/dist/sheets-of-glass/animations.js +56 -40
  41. package/dist/sheets-of-glass/index.d.ts +1 -1
  42. package/dist/sheets-of-glass/index.d.ts.map +1 -1
  43. package/dist/sheets-of-glass/index.js +64 -20
  44. package/dist/transition/ios.transition.d.ts.map +1 -1
  45. package/dist/transition/ios.transition.js +2 -3
  46. package/dist/vertical-bars.d.ts +2 -2
  47. package/dist/vertical-bars.d.ts.map +1 -1
  48. package/dist/vertical-bars.js +1 -1
  49. package/docs/iphone-duo-with-original-theme.md +171 -0
  50. package/docs/iphone-duo.md +105 -50
  51. package/docs/migration.md +23 -0
  52. package/docs/native-ui-shell.md +10 -6
  53. package/docs/special-markup.md +3 -14
  54. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +23 -4
  55. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +15 -171
  56. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +5 -0
  57. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +30 -0
  58. package/package.json +1 -1
  59. package/src/index.ts +41 -9
  60. package/src/native/components/index.ts +6 -1
  61. package/src/native/components/ion-button.ts +4 -3
  62. package/src/native/components/ion-buttons.ts +4 -2
  63. package/src/native/definitions.ts +11 -19
  64. package/src/native/index.ts +67 -30
  65. package/src/native/prehide.ts +7 -4
  66. package/src/native/runtime.ts +28 -11
  67. package/src/native/shared/candidate.ts +10 -0
  68. package/src/native/shared/dom.ts +11 -10
  69. package/src/native/shared/modal.ts +157 -0
  70. package/src/native/vertical-bars-web.ts +12 -3
  71. package/src/native-integration/index.ts +8 -2
  72. package/src/native-integration/transition.ts +15 -0
  73. package/src/sheets-of-glass/animations.ts +66 -41
  74. package/src/sheets-of-glass/index.ts +82 -16
  75. package/src/styles/components/ion-tabs.scss +7 -4
  76. package/src/styles/vertical-bars.scss +102 -35
  77. package/src/transition/ios.transition.ts +4 -4
  78. package/src/vertical-bars.ts +2 -2
@@ -12,6 +12,7 @@ import type {
12
12
  import { createRuntime } from './runtime';
13
13
  import { createVerticalBarsWebProjection } from './vertical-bars-web';
14
14
  import { prehideVerticalBarsToolbarSources } from './prehide';
15
+ import { observeVerticalBarsModals } from './shared/modal';
15
16
  export type {
16
17
  NativeUIShellComponent,
17
18
  NativeUIShellControls,
@@ -19,13 +20,11 @@ export type {
19
20
  NativeUIShellOptions,
20
21
  NativeUIShellStatus,
21
22
  NativeUIShellSuspension,
22
- DeviceLayout,
23
23
  VerticalBarEdge,
24
24
  VerticalBarPlacement,
25
25
  VerticalControlAreaHandle,
26
26
  WebViewMetrics,
27
27
  } from './definitions';
28
- export { HingeStatus } from './definitions';
29
28
 
30
29
  const plugin = registerPlugin<NativeUIShellPlugin>('IonicNativeUIShell');
31
30
  export const IonicNativeUIShell = plugin;
@@ -97,8 +96,7 @@ const manage = (
97
96
 
98
97
  /** Reads the current native WebView geometry and applies it to page transitions. */
99
98
  export const configureNativeTransition = async (): Promise<WebViewMetrics> => {
100
- const metrics =
101
- typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? (await plugin.getDeviceLayout()).webViewMetrics : { radius: 0 };
99
+ const metrics = typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? await plugin.getWebViewMetrics() : { radius: 0 };
102
100
  setConfig({ radius: metrics.radius });
103
101
  return metrics;
104
102
  };
@@ -109,6 +107,39 @@ const physicalVerticalBarEdge = (edge: Exclude<VerticalBarEdge, null>, rtl: bool
109
107
 
110
108
  const elementRtl = (element: Element): boolean => element.closest('[dir]')?.getAttribute('dir') === 'rtl';
111
109
 
110
+ // Device facts are supplied by the application; the theme only compares placement.
111
+ const nativePlacements = new WeakMap<HTMLElement, { edge: VerticalBarEdge; rtl?: boolean }>();
112
+ const horizontalFallbackAttribute = 'data-native-ui-shell-vertical-bars-suspended';
113
+
114
+ // Keep the requested rail while ordinary Native UI Shell temporarily uses its
115
+ // horizontal layout. Removing the effective class restores normal measurements,
116
+ // toolbar ownership and safe areas throughout the existing rendering pipeline.
117
+ const observeNativeVerticalBarsLayout = (doc: Document): (() => void) => {
118
+ const reconcile = () => {
119
+ const app = doc.querySelector<HTMLElement>('ion-app');
120
+ if (!app) return;
121
+ const requested = app.classList.contains('ios-theme-vertical-bars') || app.hasAttribute(horizontalFallbackAttribute);
122
+ const suspended = requested && nativePlacements.get(app)?.edge == null;
123
+ if (app.hasAttribute(horizontalFallbackAttribute) !== suspended) app.toggleAttribute(horizontalFallbackAttribute, suspended);
124
+ if (app.classList.contains('ios-theme-vertical-bars') !== (requested && !suspended)) {
125
+ app.classList.toggle('ios-theme-vertical-bars', requested && !suspended);
126
+ }
127
+ };
128
+ const observer = new MutationObserver(reconcile);
129
+ observer.observe(doc.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
130
+ doc.defaultView?.addEventListener('nativeUIShellRefresh', reconcile);
131
+ reconcile();
132
+ return () => {
133
+ observer.disconnect();
134
+ doc.defaultView?.removeEventListener('nativeUIShellRefresh', reconcile);
135
+ const app = doc.querySelector<HTMLElement>(`ion-app[${horizontalFallbackAttribute}]`);
136
+ if (app) {
137
+ app.removeAttribute(horizontalFallbackAttribute);
138
+ app.classList.add('ios-theme-vertical-bars');
139
+ }
140
+ };
141
+ };
142
+
112
143
  /**
113
144
  * Applies one placement to the CSS layout and both Web/native projections.
114
145
  * Pass `rtl` when the document direction is known; otherwise the nearest `dir` attribute is used.
@@ -117,11 +148,16 @@ export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | Ver
117
148
  if (typeof document === 'undefined') return;
118
149
  const app = document.querySelector<HTMLElement>('ion-app');
119
150
  if (!app) throw new Error('Vertical Control Area requires ion-app');
120
- const { edge, inset } = placement && typeof placement === 'object' ? placement : { edge: placement, inset: 0 };
151
+ const { edge, inset = 0 } = placement && typeof placement === 'object' ? placement : { edge: placement, inset: 0 };
152
+ if (placement && typeof placement === 'object' && placement.nativeEdge !== undefined) {
153
+ nativePlacements.set(app, { edge: placement.nativeEdge, rtl });
154
+ }
155
+ app.removeAttribute(horizontalFallbackAttribute);
121
156
  app.classList.toggle('ios-theme-vertical-bars', edge !== null);
122
157
  app.classList.toggle('ios-theme-vertical-bars-left', edge !== null && physicalVerticalBarEdge(edge, rtl ?? elementRtl(app)) === 'left');
123
158
  if (edge && Number.isFinite(inset) && inset > 0) app.style.setProperty('--ios-theme-vertical-bars-native-inset', `${inset}px`);
124
159
  else app.style.removeProperty('--ios-theme-vertical-bars-native-inset');
160
+ document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh'));
125
161
  };
126
162
 
127
163
  /** Call once at application startup. Ionic markup remains the source of truth. */
@@ -160,6 +196,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
160
196
  );
161
197
  if (active) return active;
162
198
  activeConfiguration = configuration;
199
+ const stopModals = observeVerticalBarsModals(document);
163
200
  const prehide =
164
201
  options.controls === undefined || options.controls.toolbar === true ? prehideVerticalBarsToolbarSources(document) : undefined;
165
202
  // A slow destroy of a superseded activation must not release a newer one.
@@ -174,38 +211,37 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
174
211
  manage(createVerticalBarsWebProjection(document, options), {
175
212
  reason,
176
213
  suspend: () => prehide?.suspend(),
177
- destroy: () => prehide?.stop(),
214
+ destroy: () => {
215
+ prehide?.stop();
216
+ stopModals();
217
+ },
178
218
  release,
179
219
  });
180
220
  return (start = active =
181
221
  (async () => {
182
222
  if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS');
183
223
  let runtime: NativeUIShellHandle | undefined;
184
- let placementListener: Awaited<ReturnType<typeof plugin.addListener>> | undefined;
185
- let monitoring = false;
224
+ let metricsListener: Awaited<ReturnType<typeof plugin.addListener>> | undefined;
225
+ let stopVerticalBarsLayout: (() => void) | undefined;
186
226
  try {
187
- if (!options.verticalBarsOnly) await configureNativeTransition().catch(() => undefined);
188
227
  const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true });
189
228
  if (!capabilities.supported) return fallback('Requires iOS 26 or later');
190
- let nativeEdge: VerticalBarEdge = null;
229
+ if (!options.verticalBarsOnly) stopVerticalBarsLayout = observeNativeVerticalBarsLayout(document);
230
+ // The application owns device state and selects the rail through placement classes.
191
231
  const nativeVerticalBars = () => {
192
- const root = document.querySelector('ion-app.ios-theme-vertical-bars');
193
- if (!root) return false;
194
- // The trait stays unspecified when the OS cannot report a rail — for
195
- // example an app linked against an SDK older than 27.1 — so the DOM
196
- // class is trusted there. When the OS does report an edge, the native
197
- // rail only takes over once the app has applied the matching class.
198
- const domEdge = root.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right';
199
- return nativeEdge === null || physicalVerticalBarEdge(nativeEdge, elementRtl(root)) === domEdge;
232
+ const app = document.querySelector<HTMLElement>('ion-app.ios-theme-vertical-bars');
233
+ if (!app) return false;
234
+ const reported = nativePlacements.get(app);
235
+ if (!reported?.edge) return false;
236
+ const physicalEdge = app.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right';
237
+ return physicalEdge === physicalVerticalBarEdge(reported.edge, reported.rtl ?? elementRtl(app));
200
238
  };
201
- await plugin.startDeviceLayoutMonitoring();
202
- monitoring = true;
203
- placementListener = await plugin.addListener('deviceLayoutChange', ({ placement, webViewMetrics }) => {
204
- nativeEdge = placement.edge;
205
- if (!options.verticalBarsOnly) setConfig({ radius: webViewMetrics.radius });
206
- document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh'));
207
- });
208
- nativeEdge = (await plugin.getDeviceLayout()).placement.edge;
239
+ if (!options.verticalBarsOnly) {
240
+ metricsListener = await plugin.addListener('webViewMetricsChange', (metrics) => {
241
+ setConfig({ radius: metrics.radius });
242
+ });
243
+ await configureNativeTransition().catch(() => undefined);
244
+ }
209
245
  const native = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true);
210
246
  runtime = combine(
211
247
  native,
@@ -215,15 +251,16 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
215
251
  suspend: () => prehide?.suspend(),
216
252
  release,
217
253
  destroy: async () => {
218
- await placementListener?.remove().catch(() => {});
219
- if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {});
254
+ await metricsListener?.remove().catch(() => {});
255
+ stopVerticalBarsLayout?.();
220
256
  prehide?.stop();
257
+ stopModals();
221
258
  },
222
259
  });
223
260
  } catch (error) {
224
261
  await runtime?.destroy();
225
- await placementListener?.remove().catch(() => {});
226
- if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {});
262
+ await metricsListener?.remove().catch(() => {});
263
+ stopVerticalBarsLayout?.();
227
264
  return fallback(error instanceof Error ? error.message : String(error));
228
265
  }
229
266
  })());
@@ -1,3 +1,4 @@
1
+ import { modalUsesVerticalBars } from './shared/modal';
1
2
  import { LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE, LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE } from '@ionic/core';
2
3
  import { VERTICAL_BARS_TRANSITION_CANCELED } from '../native-integration';
3
4
  import {
@@ -17,7 +18,7 @@ import {
17
18
  setVerticalBarsPlacement,
18
19
  } from './shared/dom';
19
20
 
20
- const overlays = 'ion-menu, ion-modal, ion-popover';
21
+ const overlays = 'ion-menu, ion-popover';
21
22
  const sourceSelector = 'ion-back-button, ion-menu-button, ion-buttons, ion-button';
22
23
  // Realm- and teardown-safe: globals such as Element/HTMLElement may be gone
23
24
  // when a queued mutation microtask or a stale listener still runs.
@@ -60,7 +61,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
60
61
  scopes.delete(scope);
61
62
  };
62
63
  const capture = (scope: HTMLElement) => {
63
- if (!root()?.contains(scope) || scope.closest(overlays) || verticalBarsPages.isDeparted(scope)) return;
64
+ if (!root()?.contains(scope) || scope.closest(overlays) || !modalUsesVerticalBars(scope) || verticalBarsPages.isDeparted(scope)) return;
64
65
  const owned = scopes.get(scope) ?? new Set<HTMLElement>();
65
66
  const place = (element: HTMLElement, rail: boolean) => {
66
67
  if (owned.has(element)) return;
@@ -73,7 +74,8 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
73
74
  };
74
75
  const sources = scope.matches('ion-back-button') ? [scope] : Array.from(scope.querySelectorAll<HTMLElement>(sourceSelector));
75
76
  sources.forEach((element) => {
76
- if (element.closest(overlays) || (scope.matches('.ion-page') && routedPage(element) !== scope)) return;
77
+ if (!modalUsesVerticalBars(element) || element.closest(overlays) || (scope.matches('.ion-page') && routedPage(element) !== scope))
78
+ return;
77
79
  if (element.matches('ion-buttons')) {
78
80
  const children = childElements(element);
79
81
  const actions = children.filter((child) => eligible(child) && isVerticalBarsToolbarActionShape(child));
@@ -134,7 +136,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
134
136
  Array.from(scopes.keys()).forEach(release);
135
137
  return;
136
138
  }
137
- for (const scope of scopes.keys()) if (!scope.isConnected) release(scope);
139
+ for (const scope of scopes.keys()) if (!scope.isConnected || !modalUsesVerticalBars(scope)) release(scope);
138
140
  for (const [element, pending] of pendingBacks) if (element.shadowRoot || element.classList.contains('hydrated')) capture(pending.scope);
139
141
  // Ionic inserts the destination as invisible before WillEnter. Hide its
140
142
  // sources in that same mutation microtask; capture new DOM identities on active pages too.
@@ -193,6 +195,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
193
195
  for (const name of [LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE])
194
196
  doc.addEventListener(name, onPageLifecycle, { capture: true, signal: listeners.signal });
195
197
  doc.addEventListener(VERTICAL_BARS_TRANSITION_CANCELED, onTransitionCanceled, { capture: true, signal: listeners.signal });
198
+ doc.defaultView?.addEventListener('nativeUIShellRefresh', reconcile, { signal: listeners.signal });
196
199
  reconcile();
197
200
  const mutationRelevant = (record: MutationRecord) => {
198
201
  if (record.type === 'childList')
@@ -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 = [];
@@ -675,7 +692,7 @@ export const createRuntime = async (
675
692
  event.revision < acceptedRevision ||
676
693
  event.revision > revision ||
677
694
  event.sequence <= lastSequence ||
678
- overlayOpen(false)
695
+ overlayOpen(false, true)
679
696
  )
680
697
  return;
681
698
  // Native may send input before update() resolves on the JS bridge.
@@ -688,7 +705,7 @@ export const createRuntime = async (
688
705
  const element = actions.get(event.id);
689
706
  if (!element) return;
690
707
  const owner = Array.from(sources.keys()).find((source) => source === element || source.contains(element));
691
- if (!owner) return;
708
+ if (!owner || !inVerticalBarsSurface(owner)) return;
692
709
  const direct = !blocked(owner) && unprojected(sources.keys(), () => readEnabledCandidate(owner));
693
710
  const candidate = direct || read().find((candidate) => candidate.actions.has(event.id));
694
711
  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