@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.
- package/README.md +8 -1
- package/dist/css/components/ion-list.css +1 -1
- package/dist/css/components/ion-menu.css +1 -1
- package/dist/css/ionic-theme-ios27.css +1 -1
- package/dist/css/vertical-bars.css +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -8
- package/dist/native/components/index.d.ts +1 -0
- package/dist/native/components/index.d.ts.map +1 -1
- package/dist/native/components/index.js +8 -3
- package/dist/native/components/ion-back-button.d.ts.map +1 -1
- package/dist/native/components/ion-back-button.js +5 -2
- package/dist/native/components/ion-button.d.ts.map +1 -1
- package/dist/native/components/ion-button.js +10 -2
- package/dist/native/components/ion-buttons.d.ts.map +1 -1
- package/dist/native/components/ion-buttons.js +12 -4
- package/dist/native/components/ion-fab.js +4 -4
- package/dist/native/components/ion-menu-button.d.ts.map +1 -1
- package/dist/native/components/ion-menu-button.js +5 -4
- package/dist/native/components/searchable-tabs.d.ts.map +1 -1
- package/dist/native/components/searchable-tabs.js +4 -1
- package/dist/native/definitions.d.ts +29 -3
- package/dist/native/definitions.d.ts.map +1 -1
- package/dist/native/definitions.js +6 -1
- package/dist/native/index.d.ts +6 -2
- package/dist/native/index.d.ts.map +1 -1
- package/dist/native/index.js +154 -17
- package/dist/native/prehide.d.ts +5 -0
- package/dist/native/prehide.d.ts.map +1 -0
- package/dist/native/prehide.js +246 -0
- package/dist/native/runtime.d.ts +1 -1
- package/dist/native/runtime.d.ts.map +1 -1
- package/dist/native/runtime.js +162 -28
- package/dist/native/shared/candidate.d.ts.map +1 -1
- package/dist/native/shared/candidate.js +7 -3
- package/dist/native/shared/dom.d.ts +33 -2
- package/dist/native/shared/dom.d.ts.map +1 -1
- package/dist/native/shared/dom.js +171 -10
- package/dist/native/vertical-bars-web.d.ts +3 -0
- package/dist/native/vertical-bars-web.d.ts.map +1 -0
- package/dist/native/vertical-bars-web.js +341 -0
- package/dist/native-integration/index.d.ts +3 -2
- package/dist/native-integration/index.d.ts.map +1 -1
- package/dist/native-integration/index.js +11 -5
- package/dist/transition/index.d.ts +2 -2
- package/dist/transition/index.d.ts.map +1 -1
- package/dist/transition/index.js +1 -1
- package/dist/transition/ios.transition.d.ts.map +1 -1
- package/dist/transition/ios.transition.js +1 -0
- package/dist/utils.d.ts +3 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +1 -1
- package/dist/vertical-bars.d.ts +4 -0
- package/dist/vertical-bars.d.ts.map +1 -0
- package/dist/vertical-bars.js +2 -0
- package/docs/iphone-duo.md +255 -0
- package/docs/migration.md +2 -2
- package/docs/native-ui-shell.md +15 -3
- package/docs/special-markup.md +6 -0
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +15 -1
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +358 -0
- package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +215 -35
- package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +10 -1
- package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +29 -0
- package/package.json +6 -2
- package/src/index.ts +15 -8
- package/src/native/components/index.ts +12 -3
- package/src/native/components/ion-back-button.ts +8 -2
- package/src/native/components/ion-button.ts +12 -2
- package/src/native/components/ion-buttons.ts +9 -4
- package/src/native/components/ion-fab.ts +4 -4
- package/src/native/components/ion-menu-button.ts +5 -4
- package/src/native/components/searchable-tabs.ts +9 -1
- package/src/native/definitions.ts +40 -3
- package/src/native/index.ts +188 -20
- package/src/native/prehide.ts +255 -0
- package/src/native/runtime.ts +173 -27
- package/src/native/shared/candidate.ts +11 -3
- package/src/native/shared/dom.ts +212 -11
- package/src/native/vertical-bars-web.ts +374 -0
- package/src/native-integration/index.ts +13 -8
- package/src/styles/components/ion-list.scss +2 -2
- package/src/styles/components/ion-menu.scss +2 -2
- package/src/styles/ionic-theme-ios27.scss +1 -0
- package/src/styles/vertical-bars.scss +342 -0
- package/src/transition/index.ts +11 -6
- package/src/transition/ios.transition.ts +1 -0
- package/src/utils.ts +8 -8
- package/src/vertical-bars.ts +3 -0
- package/dist/native/lifecycle.d.ts +0 -4
- package/dist/native/lifecycle.d.ts.map +0 -1
- package/dist/native/lifecycle.js +0 -34
- 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 =
|
|
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) ||
|
|
21
|
-
const buttons = [main[0], ...lists.flatMap((list) =>
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
177
|
+
addListener(name: 'deviceLayoutChange', listener: (event: DeviceLayout) => void): Promise<PluginListenerHandle>;
|
|
141
178
|
}
|
package/src/native/index.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
|
2
2
|
import { setConfig } from '../transition/ios.transition';
|
|
3
|
-
import type {
|
|
4
|
-
|
|
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 =
|
|
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'
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
+
};
|