@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,5 +1,5 @@
1
1
  import type { Candidate } from '../shared/candidate';
2
- import { excluded, isDark, text } from '../shared/dom';
2
+ import { childElements, excluded, isDark, text } from '../shared/dom';
3
3
  import { iconSource } from '../shared/icons';
4
4
  import type { ShellItem } from '../definitions';
5
5
 
@@ -13,12 +13,12 @@ const hasHostMotion = (style: CSSStyleDeclaration): boolean =>
13
13
  // and resolved artwork, without inventing layout for display:none descendants.
14
14
  export const read = (fab: HTMLElement, id: (element: HTMLElement) => string): Candidate | undefined => {
15
15
  if (fab.slot !== 'fixed' || !fab.parentElement?.matches('ion-content') || fab.contains(fab.ownerDocument.activeElement)) return;
16
- const children = Array.from(fab.children);
16
+ const children = childElements(fab);
17
17
  const main = children.filter((child) => child.matches('ion-fab-button'));
18
18
  const lists = children.filter((child) => child.matches('ion-fab-list'));
19
19
  if (main.length !== 1 || main.length + lists.length !== children.length) return;
20
- if (lists.some((list) => list.closest(excluded) || Array.from(list.children).some((child) => !child.matches('ion-fab-button')))) return;
21
- const buttons = [main[0], ...lists.flatMap((list) => Array.from(list.children))] as HTMLIonFabButtonElement[];
20
+ if (lists.some((list) => list.closest(excluded) || childElements(list).some((child) => !child.matches('ion-fab-button')))) return;
21
+ const buttons = [main[0], ...lists.flatMap((list) => childElements(list))] as HTMLIonFabButtonElement[];
22
22
  const style = getComputedStyle(fab);
23
23
  if (hasHostMotion(style)) return;
24
24
  const transform = new DOMMatrixReadOnly(style.transform);
@@ -1,6 +1,6 @@
1
1
  import { createCandidate, appendItem } from '../shared/candidate';
2
2
  import type { Candidate, Identify } from '../shared/candidate';
3
- import { inFixedToolbar } from '../shared/dom';
3
+ import { childElements, inFixedToolbar } from '../shared/dom';
4
4
 
5
5
  export const tag = 'ion-menu-button';
6
6
  // The glass and projection ownership belong to ion-buttons, even for one menu button.
@@ -14,9 +14,10 @@ export const append = (candidate: Candidate, button: HTMLIonMenuButtonElement, i
14
14
  };
15
15
 
16
16
  export const read = (group: HTMLElement, id: Identify): Candidate | undefined => {
17
- if (!inFixedToolbar(group) || !group.matches('ion-buttons') || group.children.length !== 1) return;
18
- const button = group.firstElementChild;
19
- if (!button?.matches(`${tag}.ios`)) return;
17
+ const children = group.matches('ion-buttons') ? childElements(group) : [];
18
+ if (!inFixedToolbar(group) || children.length !== 1) return;
19
+ const button = children[0];
20
+ if (!button?.matches(`${tag}${group.closest('ion-app.ios-theme-vertical-bars') ? '' : '.ios'}`)) return;
20
21
  const candidate = createCandidate(group, tag, id);
21
22
  return append(candidate, button as HTMLIonMenuButtonElement, id) ? candidate : undefined;
22
23
  };
@@ -202,7 +202,15 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
202
202
  };
203
203
  for (const binding of bindings) {
204
204
  const candidate = candidates.find((c) => c.element === binding.tabBar);
205
- if (!candidate || candidate.control.search || binding.tabBar.getAttribute('slot') !== 'bottom') continue;
205
+ // The vertical rail has no search surface; hiding the FAB/footer there
206
+ // would leave searchable tabs without any search control.
207
+ if (
208
+ !candidate ||
209
+ candidate.control.search ||
210
+ candidate.control.placement === 'vertical-bars' ||
211
+ binding.tabBar.getAttribute('slot') !== 'bottom'
212
+ )
213
+ continue;
206
214
  const fab = binding.trigger.parentElement;
207
215
  // Search also supports the existing page-level fixed FAB, outside scrolling content.
208
216
  if (
@@ -15,6 +15,8 @@ export interface NativeUIShellOptions {
15
15
  enabled?: boolean;
16
16
  /** Controls eligible for native projection. Omit to enable every control; when present, only `true` controls are enabled. */
17
17
  controls?: NativeUIShellControls;
18
+ /** Internal: limit native projection to the Vertical Control Area. */
19
+ verticalBarsOnly?: boolean;
18
20
  }
19
21
 
20
22
  export interface NativeUIShellControls {
@@ -37,6 +39,26 @@ export interface NativeUIShellHandle {
37
39
  destroy(): Promise<void>;
38
40
  }
39
41
 
42
+ /** Logical edge in the reading direction, matching UIVerticalBarEdge and capacitor-foldable. */
43
+ export type VerticalBarEdge = 'leading' | 'trailing' | null;
44
+
45
+ export interface VerticalBarPlacement {
46
+ edge: VerticalBarEdge;
47
+ /** UIKit safe-area inset on the vertical-bar edge, in points. */
48
+ inset: number;
49
+ }
50
+
51
+ export enum HingeStatus {
52
+ Closed = 'closed',
53
+ PartiallyOpen = 'partiallyOpen',
54
+ FullyOpen = 'fullyOpen',
55
+ }
56
+
57
+ export interface VerticalControlAreaHandle extends NativeUIShellHandle {
58
+ /** Applies the application's chosen placement to both Web and native controls. */
59
+ setPlacement(placement: VerticalBarEdge | VerticalBarPlacement, rtl?: boolean): void;
60
+ }
61
+
40
62
  export interface NativeUIShellSuspension {
41
63
  /** Releases this suspension. Native projection resumes after all active suspensions are released. */
42
64
  resume(): Promise<void>;
@@ -83,6 +105,10 @@ export interface ShellItem extends Frame {
83
105
  export interface ShellControl extends Frame {
84
106
  id: string;
85
107
  kind: NativeUIShellComponent;
108
+ /** Lets the native host own adaptive placement instead of mirroring the DOM frame. */
109
+ placement?: 'vertical-bars';
110
+ /** Logical Ionic toolbar slot, preserved when projecting to the vertical rail. */
111
+ toolbarSlot?: 'start' | 'end';
86
112
  items: ShellItem[];
87
113
  dark: boolean;
88
114
  rtl: boolean;
@@ -116,6 +142,8 @@ export interface ShellSnapshot {
116
142
  revision: number;
117
143
  transitionDuration?: number;
118
144
  viewportWidth: number;
145
+ /** Physical side the native rail is drawn on; the runtime resolves the logical edge through the document direction. */
146
+ verticalBarEdge?: 'left' | 'right';
119
147
  controls: ShellControl[];
120
148
  }
121
149
 
@@ -130,12 +158,21 @@ export interface WebViewMetrics {
130
158
  radius: number;
131
159
  }
132
160
 
161
+ export interface DeviceLayout {
162
+ placement: VerticalBarPlacement;
163
+ /** Fold hinge posture, or `null` when the device reports no hinge. */
164
+ hingeStatus: HingeStatus | null;
165
+ webViewMetrics: WebViewMetrics;
166
+ }
167
+
133
168
  export interface NativeUIShellPlugin {
134
- configure(): Promise<{ supported: boolean }>;
135
- getWebViewMetrics(): Promise<WebViewMetrics>;
169
+ configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>;
170
+ getDeviceLayout(): Promise<DeviceLayout>;
171
+ startDeviceLayoutMonitoring(): Promise<void>;
172
+ stopDeviceLayoutMonitoring(): Promise<void>;
136
173
  update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>;
137
174
  clear(options: { revision: number }): Promise<void>;
138
175
  addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise<PluginListenerHandle>;
139
176
  addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise<PluginListenerHandle>;
140
- addListener(name: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise<PluginListenerHandle>;
177
+ addListener(name: 'deviceLayoutChange', listener: (event: DeviceLayout) => void): Promise<PluginListenerHandle>;
141
178
  }
@@ -1,8 +1,17 @@
1
1
  import { Capacitor, registerPlugin } from '@capacitor/core';
2
2
  import { setConfig } from '../transition/ios.transition';
3
- import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellPlugin, WebViewMetrics } from './definitions';
4
- import { bindMetricsLifecycle } from './lifecycle';
3
+ import type {
4
+ NativeUIShellHandle,
5
+ NativeUIShellOptions,
6
+ NativeUIShellPlugin,
7
+ VerticalBarEdge,
8
+ VerticalBarPlacement,
9
+ VerticalControlAreaHandle,
10
+ WebViewMetrics,
11
+ } from './definitions';
5
12
  import { createRuntime } from './runtime';
13
+ import { createVerticalBarsWebProjection } from './vertical-bars-web';
14
+ import { prehideVerticalBarsToolbarSources } from './prehide';
6
15
  export type {
7
16
  NativeUIShellComponent,
8
17
  NativeUIShellControls,
@@ -10,29 +19,128 @@ export type {
10
19
  NativeUIShellOptions,
11
20
  NativeUIShellStatus,
12
21
  NativeUIShellSuspension,
22
+ DeviceLayout,
23
+ VerticalBarEdge,
24
+ VerticalBarPlacement,
25
+ VerticalControlAreaHandle,
13
26
  WebViewMetrics,
14
27
  } from './definitions';
28
+ export { HingeStatus } from './definitions';
15
29
 
16
30
  const plugin = registerPlugin<NativeUIShellPlugin>('IonicNativeUIShell');
31
+ export const IonicNativeUIShell = plugin;
17
32
  let active: Promise<NativeUIShellHandle> | undefined;
33
+ let activeConfiguration: string | undefined;
18
34
  const web = (reason: string): NativeUIShellHandle => ({
19
35
  getStatus: () => ({ state: 'web', projected: 0, updates: 0, reason }),
20
36
  suspend: async () => ({ resume: async () => {} }),
21
37
  destroy: async () => {},
22
38
  });
23
39
 
40
+ const combine = (native: NativeUIShellHandle, fallback: NativeUIShellHandle): NativeUIShellHandle => ({
41
+ getStatus() {
42
+ const nativeStatus = native.getStatus();
43
+ const fallbackStatus = fallback.getStatus();
44
+ return {
45
+ ...nativeStatus,
46
+ state:
47
+ nativeStatus.state === 'stopped' && fallbackStatus.state === 'stopped' ? 'stopped' : nativeStatus.projected > 0 ? 'native' : 'web',
48
+ projected: nativeStatus.projected + fallbackStatus.projected,
49
+ updates: nativeStatus.updates + fallbackStatus.updates,
50
+ };
51
+ },
52
+ async suspend() {
53
+ const [nativeLease, fallbackLease] = await Promise.all([native.suspend(), fallback.suspend()]);
54
+ return { resume: async () => void (await Promise.all([nativeLease.resume(), fallbackLease.resume()])) };
55
+ },
56
+ async destroy() {
57
+ await Promise.all([native.destroy(), fallback.destroy()]);
58
+ },
59
+ });
60
+
61
+ /** Wraps a runtime handle with the shared enable-lifecycle: reason, suspend hooks and idempotent destroy. */
62
+ const manage = (
63
+ handle: NativeUIShellHandle,
64
+ lifecycle: {
65
+ reason?: string;
66
+ suspend?: () => (() => void) | undefined;
67
+ /** Runs after teardown; clears the shared slot only while this activation owns it. */
68
+ release?: () => void;
69
+ destroy?: () => void | Promise<void>;
70
+ } = {},
71
+ ): NativeUIShellHandle => {
72
+ let destroyed = false;
73
+ return {
74
+ getStatus: () => (lifecycle.reason ? { ...handle.getStatus(), reason: lifecycle.reason } : handle.getStatus()),
75
+ async suspend() {
76
+ const lease = await handle.suspend();
77
+ const resume = lifecycle.suspend?.();
78
+ return {
79
+ async resume() {
80
+ await lease.resume();
81
+ resume?.();
82
+ },
83
+ };
84
+ },
85
+ async destroy() {
86
+ if (destroyed) return;
87
+ destroyed = true;
88
+ try {
89
+ await handle.destroy();
90
+ } finally {
91
+ await lifecycle.destroy?.();
92
+ lifecycle.release?.();
93
+ }
94
+ },
95
+ };
96
+ };
97
+
24
98
  /** Reads the current native WebView geometry and applies it to page transitions. */
25
99
  export const configureNativeTransition = async (): Promise<WebViewMetrics> => {
26
- const metrics = typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? await plugin.getWebViewMetrics() : { radius: 0 };
100
+ const metrics =
101
+ typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? (await plugin.getDeviceLayout()).webViewMetrics : { radius: 0 };
27
102
  setConfig({ radius: metrics.radius });
28
103
  return metrics;
29
104
  };
30
105
 
106
+ /** Resolves a logical vertical-bar edge to the physical side for the given direction. */
107
+ const physicalVerticalBarEdge = (edge: Exclude<VerticalBarEdge, null>, rtl: boolean): 'left' | 'right' =>
108
+ (edge === 'leading') !== rtl ? 'left' : 'right';
109
+
110
+ const elementRtl = (element: Element): boolean => element.closest('[dir]')?.getAttribute('dir') === 'rtl';
111
+
112
+ /**
113
+ * Applies one placement to the CSS layout and both Web/native projections.
114
+ * Pass `rtl` when the document direction is known; otherwise the nearest `dir` attribute is used.
115
+ */
116
+ export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | VerticalBarPlacement, rtl?: boolean): void => {
117
+ if (typeof document === 'undefined') return;
118
+ const app = document.querySelector<HTMLElement>('ion-app');
119
+ if (!app) throw new Error('Vertical Control Area requires ion-app');
120
+ const { edge, inset } = placement && typeof placement === 'object' ? placement : { edge: placement, inset: 0 };
121
+ app.classList.toggle('ios-theme-vertical-bars', edge !== null);
122
+ app.classList.toggle('ios-theme-vertical-bars-left', edge !== null && physicalVerticalBarEdge(edge, rtl ?? elementRtl(app)) === 'left');
123
+ if (edge && Number.isFinite(inset) && inset > 0) app.style.setProperty('--ios-theme-vertical-bars-native-inset', `${inset}px`);
124
+ else app.style.removeProperty('--ios-theme-vertical-bars-native-inset');
125
+ };
126
+
127
+ /** Call once at application startup. Ionic markup remains the source of truth. */
128
+ export const enableVerticalControlArea = async (): Promise<VerticalControlAreaHandle> => {
129
+ const handle = await enableNativeUIShell({ controls: { tabs: true, toolbar: true }, verticalBarsOnly: true });
130
+ return {
131
+ getStatus: () => handle.getStatus(),
132
+ suspend: () => handle.suspend(),
133
+ destroy: () => handle.destroy(),
134
+ setPlacement: setVerticalControlAreaPlacement,
135
+ };
136
+ };
137
+
31
138
  /** Call once at application startup. Ionic markup remains the source of truth. */
32
139
  export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise<NativeUIShellHandle> => {
33
140
  if (options.enabled === false) {
34
141
  const current = active;
35
142
  active = undefined;
143
+ activeConfiguration = undefined;
36
144
  return current
37
145
  ? current.then(async (handle) => {
38
146
  await handle.destroy();
@@ -40,23 +148,83 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
40
148
  })
41
149
  : Promise.resolve(web('Disabled'));
42
150
  }
43
- if (typeof document === 'undefined' || Capacitor.getPlatform() !== 'ios') return Promise.resolve(web('Requires Capacitor iOS'));
44
- return (active ??= (async () => {
45
- try {
46
- await configureNativeTransition().catch(() => undefined);
47
- if (!(await plugin.configure()).supported) {
48
- active = undefined;
49
- return web('Requires iOS 26 or later');
50
- }
51
- const runtime = await createRuntime(document, plugin, options);
52
- return await bindMetricsLifecycle(
53
- runtime,
54
- () => plugin.addListener('webViewMetricsChange', (metrics) => setConfig({ radius: metrics.radius })),
55
- () => (active = undefined),
56
- );
57
- } catch (error) {
151
+ if (typeof document === 'undefined') return Promise.resolve(web('Requires a document'));
152
+ const controls = options.controls;
153
+ const configuration = JSON.stringify([
154
+ options.verticalBarsOnly === true,
155
+ ...(['tabs', 'toolbar', 'segment', 'fab'] as const).map((component) => !controls || controls[component] === true),
156
+ ]);
157
+ if (active && activeConfiguration !== configuration)
158
+ return Promise.reject(
159
+ new Error('Native UI Shell is already running with different controls; destroy it before changing configuration.'),
160
+ );
161
+ if (active) return active;
162
+ activeConfiguration = configuration;
163
+ const prehide =
164
+ options.controls === undefined || options.controls.toolbar === true ? prehideVerticalBarsToolbarSources(document) : undefined;
165
+ // A slow destroy of a superseded activation must not release a newer one.
166
+ let start: Promise<NativeUIShellHandle>;
167
+ const release = () => {
168
+ if (active === start) {
58
169
  active = undefined;
59
- return web(error instanceof Error ? error.message : String(error));
170
+ activeConfiguration = undefined;
60
171
  }
61
- })());
172
+ };
173
+ const fallback = (reason: string) =>
174
+ manage(createVerticalBarsWebProjection(document, options), {
175
+ reason,
176
+ suspend: () => prehide?.suspend(),
177
+ destroy: () => prehide?.stop(),
178
+ release,
179
+ });
180
+ return (start = active =
181
+ (async () => {
182
+ if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS');
183
+ let runtime: NativeUIShellHandle | undefined;
184
+ let placementListener: Awaited<ReturnType<typeof plugin.addListener>> | undefined;
185
+ let monitoring = false;
186
+ try {
187
+ if (!options.verticalBarsOnly) await configureNativeTransition().catch(() => undefined);
188
+ const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true });
189
+ if (!capabilities.supported) return fallback('Requires iOS 26 or later');
190
+ let nativeEdge: VerticalBarEdge = null;
191
+ 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;
200
+ };
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;
209
+ const native = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true);
210
+ runtime = combine(
211
+ native,
212
+ createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars() || native.getStatus().state === 'stopped'),
213
+ );
214
+ return manage(runtime, {
215
+ suspend: () => prehide?.suspend(),
216
+ release,
217
+ destroy: async () => {
218
+ await placementListener?.remove().catch(() => {});
219
+ if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {});
220
+ prehide?.stop();
221
+ },
222
+ });
223
+ } catch (error) {
224
+ await runtime?.destroy();
225
+ await placementListener?.remove().catch(() => {});
226
+ if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {});
227
+ return fallback(error instanceof Error ? error.message : String(error));
228
+ }
229
+ })());
62
230
  };
@@ -0,0 +1,255 @@
1
+ import { LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE, LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE } from '@ionic/core';
2
+ import { VERTICAL_BARS_TRANSITION_CANCELED } from '../native-integration';
3
+ import {
4
+ childElements,
5
+ clearVerticalBarsPlacement,
6
+ createVerticalBarsPageState,
7
+ verticalBarsActionCandidate,
8
+ verticalBarsBackCandidate,
9
+ verticalBarsBackWebClass,
10
+ verticalBarsOwned,
11
+ isVerticalBarsToolbarActionShape,
12
+ isPermanentlyExcluded,
13
+ isShellDisabled,
14
+ prehiddenClass,
15
+ prehideRootClass,
16
+ rejectedClass,
17
+ setVerticalBarsPlacement,
18
+ } from './shared/dom';
19
+
20
+ const overlays = 'ion-menu, ion-modal, ion-popover';
21
+ const sourceSelector = 'ion-back-button, ion-menu-button, ion-buttons, ion-button';
22
+ // Realm- and teardown-safe: globals such as Element/HTMLElement may be gone
23
+ // when a queued mutation microtask or a stale listener still runs.
24
+ const isHtmlElement = (node: unknown): node is HTMLElement => !!node && (node as Node).nodeType === 1;
25
+ const asElement = (node: Node | EventTarget | null): HTMLElement | undefined => (isHtmlElement(node) ? node : undefined);
26
+ const backSupported = (element: HTMLElement): boolean => {
27
+ const back = element as HTMLIonBackButtonElement;
28
+ return back.icon === undefined && back.color === undefined && !!back.shadowRoot;
29
+ };
30
+ const eligibleBack = verticalBarsBackCandidate;
31
+ const eligible = verticalBarsActionCandidate;
32
+
33
+ /** Capture toolbar placement once per routed-page epoch, before Ionic paints its transition. */
34
+ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () => () => void; stop: () => void } => {
35
+ doc.documentElement.classList.add(prehideRootClass);
36
+ const scopes = new Map<HTMLElement, Set<HTMLElement>>();
37
+ const owner = new WeakMap<HTMLElement, HTMLElement>();
38
+ const pendingBacks = new Map<HTMLElement, { scope: HTMLElement; timer: ReturnType<typeof setTimeout> }>();
39
+ const verticalBarsPages = createVerticalBarsPageState();
40
+ const listeners = new AbortController();
41
+ const root = () => doc.querySelector<HTMLElement>('ion-app.ios-theme-vertical-bars');
42
+ let suspended = 0;
43
+ let stopped = false;
44
+ const routedPage = (element: HTMLElement) => element.closest<HTMLElement>('.ion-page:not(ion-app, body)');
45
+ const release = (scope: HTMLElement) => {
46
+ for (const [element, pending] of pendingBacks) {
47
+ if (pending.scope !== scope) continue;
48
+ clearTimeout(pending.timer);
49
+ pendingBacks.delete(element);
50
+ }
51
+ const owned = scopes.get(scope);
52
+ if (!owned) return;
53
+ owned.forEach((element) => {
54
+ if (owner.get(element) !== scope) return;
55
+ owner.delete(element);
56
+ element.classList.remove(prehiddenClass);
57
+ element.classList.remove(verticalBarsBackWebClass);
58
+ clearVerticalBarsPlacement(element);
59
+ });
60
+ scopes.delete(scope);
61
+ };
62
+ const capture = (scope: HTMLElement) => {
63
+ if (!root()?.contains(scope) || scope.closest(overlays) || verticalBarsPages.isDeparted(scope)) return;
64
+ const owned = scopes.get(scope) ?? new Set<HTMLElement>();
65
+ const place = (element: HTMLElement, rail: boolean) => {
66
+ if (owned.has(element)) return;
67
+ const previousScope = owner.get(element);
68
+ if (previousScope) scopes.get(previousScope)?.delete(element);
69
+ owner.set(element, scope);
70
+ setVerticalBarsPlacement(element, rail);
71
+ if (element.matches('ion-back-button')) element.classList.toggle(verticalBarsBackWebClass, !rail);
72
+ owned.add(element);
73
+ };
74
+ const sources = scope.matches('ion-back-button') ? [scope] : Array.from(scope.querySelectorAll<HTMLElement>(sourceSelector));
75
+ sources.forEach((element) => {
76
+ if (element.closest(overlays) || (scope.matches('.ion-page') && routedPage(element) !== scope)) return;
77
+ if (element.matches('ion-buttons')) {
78
+ const children = childElements(element);
79
+ const actions = children.filter((child) => eligible(child) && isVerticalBarsToolbarActionShape(child));
80
+ const group =
81
+ eligible(element) &&
82
+ actions.length === children.length &&
83
+ (actions.length > 1 || (actions.length === 1 && actions[0].matches('ion-menu-button')));
84
+ const freshGroup = !owned.has(element);
85
+ place(element, group);
86
+ children.forEach((child) => {
87
+ if (!child.matches('ion-back-button')) place(child, freshGroup && actions.includes(child));
88
+ });
89
+ } else if (element.matches('ion-back-button') || !element.parentElement?.matches('ion-buttons')) {
90
+ if (owned.has(element)) return;
91
+ if (
92
+ element.matches('ion-back-button') &&
93
+ eligibleBack(element) &&
94
+ (element as HTMLIonBackButtonElement).icon === undefined &&
95
+ (element as HTMLIonBackButtonElement).color === undefined &&
96
+ !element.shadowRoot &&
97
+ !element.classList.contains('hydrated')
98
+ ) {
99
+ const previousPending = pendingBacks.get(element);
100
+ if (previousPending && previousPending.scope !== scope) {
101
+ clearTimeout(previousPending.timer);
102
+ pendingBacks.delete(element);
103
+ }
104
+ if (!pendingBacks.has(element)) {
105
+ const timer = setTimeout(() => {
106
+ if (pendingBacks.get(element)?.scope !== scope) return;
107
+ pendingBacks.delete(element);
108
+ // A custom element that never hydrates must remain usable on Web.
109
+ place(element, false);
110
+ reconcile();
111
+ }, 1500);
112
+ pendingBacks.set(element, { scope, timer });
113
+ }
114
+ return;
115
+ }
116
+ const pending = pendingBacks.get(element);
117
+ if (pending) {
118
+ clearTimeout(pending.timer);
119
+ pendingBacks.delete(element);
120
+ }
121
+ place(
122
+ element,
123
+ element.matches('ion-back-button')
124
+ ? eligibleBack(element) && backSupported(element)
125
+ : eligible(element) && isVerticalBarsToolbarActionShape(element),
126
+ );
127
+ }
128
+ });
129
+ scopes.set(scope, owned);
130
+ };
131
+ const reconcile = () => {
132
+ const verticalBars = root();
133
+ if (!verticalBars) {
134
+ Array.from(scopes.keys()).forEach(release);
135
+ return;
136
+ }
137
+ for (const scope of scopes.keys()) if (!scope.isConnected) release(scope);
138
+ for (const [element, pending] of pendingBacks) if (element.shadowRoot || element.classList.contains('hydrated')) capture(pending.scope);
139
+ // Ionic inserts the destination as invisible before WillEnter. Hide its
140
+ // sources in that same mutation microtask; capture new DOM identities on active pages too.
141
+ verticalBars.querySelectorAll<HTMLElement>('.ion-page:not(ion-app, body, .ion-page-hidden)').forEach(capture);
142
+ // Root toolbars can mount after startup; each new DOM identity is captured once.
143
+ verticalBars.querySelectorAll<HTMLElement>(sourceSelector).forEach((element) => {
144
+ if (routedPage(element) || element.closest(overlays)) return;
145
+ const scope = element.closest<HTMLElement>('ion-toolbar') ?? (element.matches('ion-back-button') ? element : null);
146
+ if (scope) capture(scope);
147
+ });
148
+ for (const owned of scopes.values()) {
149
+ for (const element of owned) {
150
+ const groupOwnsChildren =
151
+ element.parentElement?.matches('ion-buttons') &&
152
+ verticalBarsOwned(element.parentElement) &&
153
+ childElements(element.parentElement).every((child) => verticalBarsOwned(child));
154
+ const hide =
155
+ !suspended &&
156
+ verticalBarsOwned(element) &&
157
+ !groupOwnsChildren &&
158
+ element.isConnected &&
159
+ !element.closest(`.${rejectedClass}`) &&
160
+ !isPermanentlyExcluded(element) &&
161
+ !isShellDisabled(element) &&
162
+ (!element.matches('ion-back-button') || backSupported(element) || !element.classList.contains('hydrated')) &&
163
+ (!element.matches('ion-buttons') || childElements(element).every((child) => verticalBarsOwned(child)));
164
+ element.classList.toggle(prehiddenClass, hide);
165
+ if (element.matches('ion-back-button')) element.classList.toggle(verticalBarsBackWebClass, !hide);
166
+ }
167
+ }
168
+ };
169
+ const onWillEnter = (event: Event) => {
170
+ const page = asElement(event.target);
171
+ if (page?.matches('.ion-page')) {
172
+ verticalBarsPages.lifecycle(event);
173
+ release(page); // Cached pages begin a new placement epoch.
174
+ capture(page);
175
+ reconcile();
176
+ }
177
+ };
178
+ const onPageLifecycle: EventListener = (event) => {
179
+ verticalBarsPages.lifecycle(event);
180
+ if (event.type !== LIFECYCLE_DID_LEAVE) return;
181
+ const page = asElement(event.target);
182
+ if (page) release(page);
183
+ };
184
+ // A canceled transition keeps the leaving page active and abandons the entering one.
185
+ const onTransitionCanceled: EventListener = (event) => {
186
+ const leaving = asElement(event.target);
187
+ if (!leaving) return;
188
+ const entering = (event as CustomEvent<{ entering?: HTMLElement }>).detail?.entering;
189
+ verticalBarsPages.cancel(entering, leaving);
190
+ reconcile();
191
+ };
192
+ doc.addEventListener(LIFECYCLE_WILL_ENTER, onWillEnter, { capture: true, signal: listeners.signal });
193
+ for (const name of [LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE])
194
+ doc.addEventListener(name, onPageLifecycle, { capture: true, signal: listeners.signal });
195
+ doc.addEventListener(VERTICAL_BARS_TRANSITION_CANCELED, onTransitionCanceled, { capture: true, signal: listeners.signal });
196
+ reconcile();
197
+ const mutationRelevant = (record: MutationRecord) => {
198
+ if (record.type === 'childList')
199
+ return [...Array.from(record.addedNodes), ...Array.from(record.removedNodes)].some(
200
+ (node) =>
201
+ isHtmlElement(node) &&
202
+ (node.matches(`${sourceSelector}, .ion-page, ion-toolbar`) || !!node.querySelector(`${sourceSelector}, .ion-page, ion-toolbar`)),
203
+ );
204
+ if (record.attributeName !== 'class') return true;
205
+ const current = (record.target as Element).className;
206
+ const previous = record.oldValue ?? '';
207
+ const withoutOwned = (value: string) =>
208
+ value
209
+ .split(/\s+/)
210
+ .filter((name) => name && ![prehiddenClass, prehideRootClass, verticalBarsBackWebClass].includes(name))
211
+ .join(' ');
212
+ return typeof current !== 'string' || withoutOwned(previous) !== withoutOwned(current);
213
+ };
214
+ let scheduled = false;
215
+ const observer = new (doc.defaultView?.MutationObserver ?? MutationObserver)((records) => {
216
+ if (scheduled || stopped || !records.some(mutationRelevant)) return;
217
+ scheduled = true;
218
+ queueMicrotask(() => {
219
+ scheduled = false;
220
+ if (!stopped) reconcile();
221
+ });
222
+ });
223
+ observer.observe(doc.documentElement, {
224
+ subtree: true,
225
+ childList: true,
226
+ attributes: true,
227
+ attributeOldValue: true,
228
+ attributeFilter: ['class', 'hidden', 'inert', 'icon', 'color'],
229
+ });
230
+ return {
231
+ suspend: () => {
232
+ if (stopped) return () => {};
233
+ suspended++;
234
+ doc.documentElement.classList.remove(prehideRootClass);
235
+ reconcile();
236
+ let resumed = false;
237
+ return () => {
238
+ if (resumed || stopped) return;
239
+ resumed = true;
240
+ if (--suspended === 0) {
241
+ doc.documentElement.classList.add(prehideRootClass);
242
+ reconcile();
243
+ }
244
+ };
245
+ },
246
+ stop: () => {
247
+ if (stopped) return;
248
+ stopped = true;
249
+ observer.disconnect();
250
+ listeners.abort();
251
+ doc.documentElement.classList.remove(prehideRootClass);
252
+ Array.from(scopes.keys()).forEach(release);
253
+ },
254
+ };
255
+ };