@rdlabo/capacitor-native-ui 0.0.0-stage → 1.2.1
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/LICENSE +21 -0
- package/Package.swift +23 -0
- package/README.md +53 -2
- package/RdlaboCapacitorNativeUi.podspec +17 -0
- package/android/build.gradle +69 -0
- package/android/proguard-rules.pro +0 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUI.kt +51 -0
- package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUIPlugin.kt +192 -0
- package/android/src/main/java/dev/rdlabo/capacitor/nativeui/NativeUISnapshot.kt +57 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/core.d.ts +4 -0
- package/dist/core.js +3 -0
- package/dist/native/activation.d.ts +14 -0
- package/dist/native/activation.d.ts.map +1 -0
- package/dist/native/activation.js +305 -0
- package/dist/native/activation.js.map +1 -0
- package/dist/native/components/index.d.ts +19 -0
- package/dist/native/components/index.d.ts.map +1 -0
- package/dist/native/components/index.js +38 -0
- package/dist/native/components/index.js.map +1 -0
- package/dist/native/components/ion-back-button.d.ts +10 -0
- package/dist/native/components/ion-back-button.d.ts.map +1 -0
- package/dist/native/components/ion-back-button.js +21 -0
- package/dist/native/components/ion-back-button.js.map +1 -0
- package/dist/native/components/ion-button.d.ts +10 -0
- package/dist/native/components/ion-button.d.ts.map +1 -0
- package/dist/native/components/ion-button.js +25 -0
- package/dist/native/components/ion-button.js.map +1 -0
- package/dist/native/components/ion-buttons.d.ts +11 -0
- package/dist/native/components/ion-buttons.d.ts.map +1 -0
- package/dist/native/components/ion-buttons.js +30 -0
- package/dist/native/components/ion-buttons.js.map +1 -0
- package/dist/native/components/ion-fab.d.ts +10 -0
- package/dist/native/components/ion-fab.d.ts.map +1 -0
- package/dist/native/components/ion-fab.js +132 -0
- package/dist/native/components/ion-fab.js.map +1 -0
- package/dist/native/components/ion-menu-button.d.ts +13 -0
- package/dist/native/components/ion-menu-button.d.ts.map +1 -0
- package/dist/native/components/ion-menu-button.js +44 -0
- package/dist/native/components/ion-menu-button.js.map +1 -0
- package/dist/native/components/ion-segment.d.ts +11 -0
- package/dist/native/components/ion-segment.d.ts.map +1 -0
- package/dist/native/components/ion-segment.js +30 -0
- package/dist/native/components/ion-segment.js.map +1 -0
- package/dist/native/components/ion-tab-bar.d.ts +11 -0
- package/dist/native/components/ion-tab-bar.d.ts.map +1 -0
- package/dist/native/components/ion-tab-bar.js +50 -0
- package/dist/native/components/ion-tab-bar.js.map +1 -0
- package/dist/native/components/searchable-tabs.d.ts +20 -0
- package/dist/native/components/searchable-tabs.d.ts.map +1 -0
- package/dist/native/components/searchable-tabs.js +413 -0
- package/dist/native/components/searchable-tabs.js.map +1 -0
- package/dist/native/definitions.d.ts +255 -0
- package/dist/native/definitions.d.ts.map +1 -0
- package/dist/native/definitions.js +0 -0
- package/dist/native/extensions.d.ts +31 -0
- package/dist/native/extensions.d.ts.map +1 -0
- package/dist/native/extensions.js +122 -0
- package/dist/native/extensions.js.map +1 -0
- package/dist/native/foldable/fold-state.d.ts +5 -0
- package/dist/native/foldable/fold-state.d.ts.map +1 -0
- package/dist/native/foldable/fold-state.js +15 -0
- package/dist/native/foldable/fold-state.js.map +1 -0
- package/dist/native/foldable/types.d.ts +12 -0
- package/dist/native/foldable/types.d.ts.map +1 -0
- package/dist/native/foldable/types.js +0 -0
- package/dist/native/html.d.ts +9 -0
- package/dist/native/html.d.ts.map +1 -0
- package/dist/native/html.js +188 -0
- package/dist/native/html.js.map +1 -0
- package/dist/native/index.d.ts +10 -0
- package/dist/native/index.d.ts.map +1 -0
- package/dist/native/index.js +28 -0
- package/dist/native/index.js.map +1 -0
- package/dist/native/legacy-layout.d.ts +5 -0
- package/dist/native/legacy-layout.d.ts.map +1 -0
- package/dist/native/legacy-layout.js +27 -0
- package/dist/native/legacy-layout.js.map +1 -0
- package/dist/native/overlays/content.d.ts +4 -0
- package/dist/native/overlays/content.d.ts.map +1 -0
- package/dist/native/overlays/content.js +51 -0
- package/dist/native/overlays/content.js.map +1 -0
- package/dist/native/overlays/controller.d.ts +8 -0
- package/dist/native/overlays/controller.d.ts.map +1 -0
- package/dist/native/overlays/controller.js +285 -0
- package/dist/native/overlays/controller.js.map +1 -0
- package/dist/native/overlays/dialog-animation.d.ts +9 -0
- package/dist/native/overlays/dialog-animation.d.ts.map +1 -0
- package/dist/native/overlays/dialog-animation.js +53 -0
- package/dist/native/overlays/dialog-animation.js.map +1 -0
- package/dist/native/overlays/dismiss-animation.d.ts +4 -0
- package/dist/native/overlays/dismiss-animation.d.ts.map +1 -0
- package/dist/native/overlays/dismiss-animation.js +18 -0
- package/dist/native/overlays/dismiss-animation.js.map +1 -0
- package/dist/native/overlays/focus.d.ts +5 -0
- package/dist/native/overlays/focus.d.ts.map +1 -0
- package/dist/native/overlays/focus.js +59 -0
- package/dist/native/overlays/focus.js.map +1 -0
- package/dist/native/overlays/popover.d.ts +12 -0
- package/dist/native/overlays/popover.d.ts.map +1 -0
- package/dist/native/overlays/popover.js +110 -0
- package/dist/native/overlays/popover.js.map +1 -0
- package/dist/native/overlays/styles.d.ts +11 -0
- package/dist/native/overlays/styles.d.ts.map +1 -0
- package/dist/native/overlays/styles.js +167 -0
- package/dist/native/overlays/styles.js.map +1 -0
- package/dist/native/prehide.d.ts +8 -0
- package/dist/native/prehide.d.ts.map +1 -0
- package/dist/native/prehide.js +238 -0
- package/dist/native/prehide.js.map +1 -0
- package/dist/native/runtime.d.ts +7 -0
- package/dist/native/runtime.d.ts.map +1 -0
- package/dist/native/runtime.js +788 -0
- package/dist/native/runtime.js.map +1 -0
- package/dist/native/shared/bridge.d.ts +6 -0
- package/dist/native/shared/bridge.d.ts.map +1 -0
- package/dist/native/shared/bridge.js +18 -0
- package/dist/native/shared/bridge.js.map +1 -0
- package/dist/native/shared/candidate.d.ts +25 -0
- package/dist/native/shared/candidate.d.ts.map +1 -0
- package/dist/native/shared/candidate.js +106 -0
- package/dist/native/shared/candidate.js.map +1 -0
- package/dist/native/shared/clipping.d.ts +5 -0
- package/dist/native/shared/clipping.d.ts.map +1 -0
- package/dist/native/shared/clipping.js +26 -0
- package/dist/native/shared/clipping.js.map +1 -0
- package/dist/native/shared/crossfade.d.ts +12 -0
- package/dist/native/shared/crossfade.d.ts.map +1 -0
- package/dist/native/shared/crossfade.js +67 -0
- package/dist/native/shared/crossfade.js.map +1 -0
- package/dist/native/shared/diagnostics.d.ts +4 -0
- package/dist/native/shared/diagnostics.d.ts.map +1 -0
- package/dist/native/shared/diagnostics.js +18 -0
- package/dist/native/shared/diagnostics.js.map +1 -0
- package/dist/native/shared/dom.d.ts +46 -0
- package/dist/native/shared/dom.d.ts.map +1 -0
- package/dist/native/shared/dom.js +180 -0
- package/dist/native/shared/dom.js.map +1 -0
- package/dist/native/shared/events.d.ts +15 -0
- package/dist/native/shared/events.d.ts.map +1 -0
- package/dist/native/shared/events.js +17 -0
- package/dist/native/shared/events.js.map +1 -0
- package/dist/native/shared/icons.d.ts +8 -0
- package/dist/native/shared/icons.d.ts.map +1 -0
- package/dist/native/shared/icons.js +61 -0
- package/dist/native/shared/icons.js.map +1 -0
- package/dist/native/shared/modal.d.ts +10 -0
- package/dist/native/shared/modal.d.ts.map +1 -0
- package/dist/native/shared/modal.js +160 -0
- package/dist/native/shared/modal.js.map +1 -0
- package/dist/native/shared/projection-id.d.ts +4 -0
- package/dist/native/shared/projection-id.d.ts.map +1 -0
- package/dist/native/shared/projection-id.js +7 -0
- package/dist/native/shared/projection-id.js.map +1 -0
- package/dist/native/shared/toolbar-options.d.ts +9 -0
- package/dist/native/shared/toolbar-options.d.ts.map +1 -0
- package/dist/native/shared/toolbar-options.js +36 -0
- package/dist/native/shared/toolbar-options.js.map +1 -0
- package/dist/native/shared/vertical-bars-geometry.d.ts +10 -0
- package/dist/native/shared/vertical-bars-geometry.d.ts.map +1 -0
- package/dist/native/shared/vertical-bars-geometry.js +85 -0
- package/dist/native/shared/vertical-bars-geometry.js.map +1 -0
- package/dist/native/startup.d.ts +8 -0
- package/dist/native/startup.d.ts.map +1 -0
- package/dist/native/startup.js +86 -0
- package/dist/native/startup.js.map +1 -0
- package/dist/native/vertical-bars-web-search.d.ts +10 -0
- package/dist/native/vertical-bars-web-search.d.ts.map +1 -0
- package/dist/native/vertical-bars-web-search.js +90 -0
- package/dist/native/vertical-bars-web-search.js.map +1 -0
- package/dist/native/vertical-bars-web.d.ts +5 -0
- package/dist/native/vertical-bars-web.d.ts.map +1 -0
- package/dist/native/vertical-bars-web.js +421 -0
- package/dist/native/vertical-bars-web.js.map +1 -0
- package/dist/native-integration/index.d.ts +25 -0
- package/dist/native-integration/index.d.ts.map +1 -0
- package/dist/native-integration/index.js +33 -0
- package/dist/native-integration/index.js.map +1 -0
- package/dist/vertical-bars.d.ts +6 -0
- package/dist/vertical-bars.js +5 -0
- package/docs/configuration.md +187 -0
- package/docs/framework-independent.md +82 -0
- package/docs/migration.md +206 -0
- package/docs/native-mini-player.md +47 -0
- package/docs/native-ui-extensions.md +365 -0
- package/docs/native-ui.md +409 -0
- package/docs/setup.md +205 -0
- package/docs/vertical-bars.md +379 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIButton.swift +71 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIButtons.swift +24 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIComponents.swift +19 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIFab.swift +80 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift +439 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUISegment.swift +91 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift +218 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +590 -0
- package/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalSearch.swift +118 -0
- package/ios/Sources/NativeUIPlugin/Extensions/NativeUI.swift +178 -0
- package/ios/Sources/NativeUIPlugin/Extensions/NativeUIRendering.swift +38 -0
- package/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +615 -0
- package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayController.swift +119 -0
- package/ios/Sources/NativeUIPlugin/Overlays/NativeUIOverlayHost.swift +75 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUIControlRendering.swift +21 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUICrossfade.swift +35 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUIHost.swift +8 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift +161 -0
- package/ios/Sources/NativeUIPlugin/Shared/NativeUIVerticalBarsGeometry.swift +52 -0
- package/ios/Tests/NativeUIPluginTests/NativeUIExtensionTests.swift +165 -0
- package/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +805 -0
- package/package.json +89 -4
- package/src/core.ts +15 -0
- package/src/native/activation.ts +394 -0
- package/src/native/components/index.ts +45 -0
- package/src/native/components/ion-back-button.ts +22 -0
- package/src/native/components/ion-button.ts +25 -0
- package/src/native/components/ion-buttons.ts +37 -0
- package/src/native/components/ion-fab.ts +151 -0
- package/src/native/components/ion-menu-button.ts +37 -0
- package/src/native/components/ion-segment.ts +27 -0
- package/src/native/components/ion-tab-bar.ts +50 -0
- package/src/native/components/searchable-tabs.ts +499 -0
- package/src/native/definitions.ts +324 -0
- package/src/native/extensions.ts +176 -0
- package/src/native/foldable/fold-state.ts +10 -0
- package/src/native/foldable/types.ts +4 -0
- package/src/native/html.ts +193 -0
- package/src/native/index.ts +41 -0
- package/src/native/legacy-layout.ts +21 -0
- package/src/native/overlays/content.ts +51 -0
- package/src/native/overlays/controller.ts +349 -0
- package/src/native/overlays/dialog-animation.ts +53 -0
- package/src/native/overlays/dismiss-animation.ts +15 -0
- package/src/native/overlays/focus.ts +64 -0
- package/src/native/overlays/popover.ts +120 -0
- package/src/native/overlays/styles.ts +181 -0
- package/src/native/prehide.ts +285 -0
- package/src/native/runtime.ts +988 -0
- package/src/native/shared/bridge.ts +18 -0
- package/src/native/shared/candidate.ts +162 -0
- package/src/native/shared/clipping.ts +24 -0
- package/src/native/shared/crossfade.ts +67 -0
- package/src/native/shared/diagnostics.ts +13 -0
- package/src/native/shared/dom.ts +304 -0
- package/src/native/shared/events.ts +14 -0
- package/src/native/shared/icons.ts +57 -0
- package/src/native/shared/modal.ts +157 -0
- package/src/native/shared/projection-id.ts +2 -0
- package/src/native/shared/toolbar-options.ts +53 -0
- package/src/native/shared/vertical-bars-geometry.ts +80 -0
- package/src/native/startup.ts +84 -0
- package/src/native/vertical-bars-web-search.ts +86 -0
- package/src/native/vertical-bars-web.ts +498 -0
- package/src/native-integration/index.ts +49 -0
- package/src/vertical-bars.ts +11 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PluginListenerHandle } from '@capacitor/core';
|
|
2
|
+
|
|
3
|
+
// A failed bridge must not leave the source inaccessible indefinitely.
|
|
4
|
+
export const bounded = <T>(promise: Promise<T>): Promise<T> =>
|
|
5
|
+
new Promise((resolve, reject) => {
|
|
6
|
+
const timer = setTimeout(() => reject(new Error('Native UI bridge timed out')), 5000);
|
|
7
|
+
promise.then(resolve, reject).finally(() => clearTimeout(timer));
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/** A timed-out registration still owns a native resource if it eventually succeeds. */
|
|
11
|
+
export const boundedListener = async (registration: Promise<PluginListenerHandle>): Promise<PluginListenerHandle> => {
|
|
12
|
+
try {
|
|
13
|
+
return await bounded(registration);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
void registration.then((listener) => listener.remove()).catch(() => {});
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { NativeUIControl, NativeUIItem, NativeUIControlKind, NativeUIComponent, VerticalControlAreaOptions } from '../definitions';
|
|
2
|
+
import { frame, isDark, isVerticalBarsSource, text, visible } from './dom';
|
|
3
|
+
import { iconSource, type createIconRenderer } from './icons';
|
|
4
|
+
|
|
5
|
+
export interface Candidate {
|
|
6
|
+
assets?: { signature: string; render(renderer: ReturnType<typeof createIconRenderer>): Promise<void> };
|
|
7
|
+
buttonProjection?: VerticalControlAreaOptions['buttonProjection'];
|
|
8
|
+
buttonDefaultFill?: VerticalControlAreaOptions['buttonDefaultFill'];
|
|
9
|
+
element: HTMLElement;
|
|
10
|
+
sources?: HTMLElement[];
|
|
11
|
+
control: NativeUIControl;
|
|
12
|
+
actions: Map<string, HTMLElement>;
|
|
13
|
+
icons: { item: NativeUIItem; source: string; field?: 'closeIcon' }[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type Identify = (element: HTMLElement) => string;
|
|
17
|
+
type ItemElement = HTMLElement & { disabled?: boolean; selected?: boolean };
|
|
18
|
+
|
|
19
|
+
export const createCandidate = (
|
|
20
|
+
element: HTMLElement,
|
|
21
|
+
kind: NativeUIComponent | NativeUIControlKind,
|
|
22
|
+
id: Identify,
|
|
23
|
+
options: VerticalControlAreaOptions = {},
|
|
24
|
+
): Candidate => {
|
|
25
|
+
const rect = element.getBoundingClientRect();
|
|
26
|
+
const style = getComputedStyle(element);
|
|
27
|
+
return {
|
|
28
|
+
element,
|
|
29
|
+
buttonProjection: options.buttonProjection,
|
|
30
|
+
buttonDefaultFill: options.buttonDefaultFill,
|
|
31
|
+
control: {
|
|
32
|
+
id: id(element),
|
|
33
|
+
kind:
|
|
34
|
+
(
|
|
35
|
+
{
|
|
36
|
+
'ion-button': 'button',
|
|
37
|
+
'ion-buttons': 'button-group',
|
|
38
|
+
'ion-back-button': 'back-button',
|
|
39
|
+
'ion-menu-button': 'menu-button',
|
|
40
|
+
'ion-tab-bar': 'tab-bar',
|
|
41
|
+
'ion-segment': 'segment',
|
|
42
|
+
'ion-fab': 'floating-action-button',
|
|
43
|
+
} as Partial<Record<string, NativeUIControlKind>>
|
|
44
|
+
)[kind] ?? (kind as NativeUIControlKind),
|
|
45
|
+
...frame(rect),
|
|
46
|
+
items: [],
|
|
47
|
+
dark: isDark(style),
|
|
48
|
+
rtl: style.direction === 'rtl',
|
|
49
|
+
},
|
|
50
|
+
actions: new Map(),
|
|
51
|
+
icons: [],
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Shared text, artwork, geometry and action data; component rules stay with their readers.
|
|
56
|
+
export const appendItem = (
|
|
57
|
+
candidate: Candidate,
|
|
58
|
+
child: HTMLElement,
|
|
59
|
+
id: Identify,
|
|
60
|
+
content: ParentNode = child,
|
|
61
|
+
label = text(child),
|
|
62
|
+
): NativeUIItem | undefined => {
|
|
63
|
+
if (
|
|
64
|
+
!visible(child, isVerticalBarsSource(candidate.element)) ||
|
|
65
|
+
child.querySelector('input, button, a, img, canvas, video, ion-spinner, ion-avatar')
|
|
66
|
+
)
|
|
67
|
+
return;
|
|
68
|
+
const icons = Array.from(content.querySelectorAll<HTMLElement>('ion-icon'));
|
|
69
|
+
const directSVGs = Array.from(content.querySelectorAll<SVGElement>('svg'));
|
|
70
|
+
if (icons.length + directSVGs.length > 1) return undefined;
|
|
71
|
+
const svg = directSVGs[0] ?? icons[0]?.shadowRoot?.querySelector<SVGElement>('svg');
|
|
72
|
+
if (icons.length && !svg) return undefined; // ion-icon has not finished loading.
|
|
73
|
+
const labelElement = content.querySelector('[part="text"], ion-label') ?? child;
|
|
74
|
+
const native = child.shadowRoot?.querySelector('[part="native"]');
|
|
75
|
+
const labelStyle = getComputedStyle(labelElement);
|
|
76
|
+
const badge = child.querySelector<HTMLElement>('ion-badge');
|
|
77
|
+
const badgeStyle = badge && visible(badge) ? getComputedStyle(badge) : undefined;
|
|
78
|
+
const sourceFill = (child as HTMLIonButtonElement).fill;
|
|
79
|
+
const projectionOwner = [child, child.closest('ion-buttons')].find((element) =>
|
|
80
|
+
element?.matches('[data-projection="source"], [data-projection="system"], .ios-theme-projection-source, .ios-theme-projection-system'),
|
|
81
|
+
);
|
|
82
|
+
const attributeProjection = projectionOwner?.getAttribute('data-projection');
|
|
83
|
+
const projection =
|
|
84
|
+
attributeProjection === 'source' || attributeProjection === 'system'
|
|
85
|
+
? attributeProjection
|
|
86
|
+
: projectionOwner
|
|
87
|
+
? projectionOwner.classList.contains('ios-theme-projection-system')
|
|
88
|
+
? 'system'
|
|
89
|
+
: 'source'
|
|
90
|
+
: candidate.buttonProjection;
|
|
91
|
+
const systemButton = projection !== 'source' && isVerticalBarsSource(child) && child.matches('ion-button, ion-menu-button');
|
|
92
|
+
// Ionic defaults buttons inside ion-buttons to clear, even when the app defaults to solid.
|
|
93
|
+
const defaultFill = child.closest('ion-buttons') ? 'clear' : candidate.buttonDefaultFill;
|
|
94
|
+
const fill = !sourceFill || sourceFill === 'default' ? defaultFill : sourceFill;
|
|
95
|
+
const buttonFill =
|
|
96
|
+
!systemButton &&
|
|
97
|
+
isVerticalBarsSource(child) &&
|
|
98
|
+
child.matches('ion-button') &&
|
|
99
|
+
(fill === 'clear' || fill === 'solid' || fill === 'outline')
|
|
100
|
+
? fill
|
|
101
|
+
: undefined;
|
|
102
|
+
const outline = buttonFill === 'outline' ? getComputedStyle(native ?? child) : undefined;
|
|
103
|
+
const item: NativeUIItem = {
|
|
104
|
+
id: id(child),
|
|
105
|
+
...frame(child.getBoundingClientRect(), candidate.element.getBoundingClientRect()),
|
|
106
|
+
label,
|
|
107
|
+
accessibilityLabel:
|
|
108
|
+
child.getAttribute('aria-label') ??
|
|
109
|
+
native?.getAttribute('aria-label') ??
|
|
110
|
+
(icons[0]?.getAttribute('aria-hidden') === 'true' ? null : icons[0]?.getAttribute('aria-label')) ??
|
|
111
|
+
label,
|
|
112
|
+
disabled:
|
|
113
|
+
!!(child as ItemElement).disabled ||
|
|
114
|
+
!!(candidate.element as ItemElement).disabled ||
|
|
115
|
+
getComputedStyle(child).pointerEvents === 'none',
|
|
116
|
+
selected: !!(child as ItemElement).selected,
|
|
117
|
+
fontSize: parseFloat(labelStyle.fontSize),
|
|
118
|
+
fontWeight: parseInt(labelStyle.fontWeight, 10) || 400,
|
|
119
|
+
color: systemButton ? 'currentColor' : getComputedStyle(native ?? child).color,
|
|
120
|
+
iconTemplate: systemButton ? true : undefined,
|
|
121
|
+
buttonFill,
|
|
122
|
+
backgroundColor: buttonFill === 'solid' ? getComputedStyle(native ?? child).backgroundColor : undefined,
|
|
123
|
+
borderColor: outline?.borderTopColor,
|
|
124
|
+
borderWidth: outline ? parseFloat(outline.borderTopWidth) : undefined,
|
|
125
|
+
badge: badgeStyle
|
|
126
|
+
? { value: badge!.textContent?.trim() ?? '', color: badgeStyle.backgroundColor, textColor: badgeStyle.color }
|
|
127
|
+
: undefined,
|
|
128
|
+
};
|
|
129
|
+
if (svg) {
|
|
130
|
+
const source = iconSource(svg);
|
|
131
|
+
if (!source) return undefined;
|
|
132
|
+
const size = (icons[0] ?? svg).getBoundingClientRect();
|
|
133
|
+
item.iconWidth = size.width;
|
|
134
|
+
item.iconHeight = size.height;
|
|
135
|
+
if (!size.width || !size.height) return undefined;
|
|
136
|
+
item.iconPosition = (icons[0] ?? svg).getAttribute('slot') === 'end' ? 'trailing' : 'leading';
|
|
137
|
+
candidate.icons.push({ item, source });
|
|
138
|
+
}
|
|
139
|
+
if (native && child.matches('ion-button, ion-back-button')) {
|
|
140
|
+
const style = getComputedStyle(native);
|
|
141
|
+
const icon = icons[0] ?? svg;
|
|
142
|
+
const iconStyle = icon && getComputedStyle(icon);
|
|
143
|
+
const leadingMargin = parseFloat(iconStyle?.marginInlineStart ?? '') || 0;
|
|
144
|
+
const trailingMargin = parseFloat(iconStyle?.marginInlineEnd ?? '') || 0;
|
|
145
|
+
const trailing = item.iconPosition === 'trailing';
|
|
146
|
+
const inner = native.querySelector('.button-inner');
|
|
147
|
+
const gap = inner ? parseFloat(getComputedStyle(inner).columnGap) || 0 : 0;
|
|
148
|
+
// UIKit centers the title/image pair using its insets. Include Ionic's
|
|
149
|
+
// border and negative outer icon margin instead of applying a fixed offset.
|
|
150
|
+
item.contentInsetLeading =
|
|
151
|
+
(parseFloat(style.paddingInlineStart) || 0) +
|
|
152
|
+
(parseFloat(style.borderInlineStartWidth) || 0) +
|
|
153
|
+
(!trailing || !label ? leadingMargin : 0);
|
|
154
|
+
item.contentInsetTrailing =
|
|
155
|
+
(parseFloat(style.paddingInlineEnd) || 0) + (parseFloat(style.borderInlineEndWidth) || 0) + (trailing || !label ? trailingMargin : 0);
|
|
156
|
+
item.imagePadding = label && icon ? gap + (trailing ? leadingMargin : trailingMargin) : 0;
|
|
157
|
+
}
|
|
158
|
+
if (!label && !svg) return undefined;
|
|
159
|
+
candidate.control.items.push(item);
|
|
160
|
+
candidate.actions.set(item.id, child);
|
|
161
|
+
return item;
|
|
162
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function* projectionAncestors(element: HTMLElement): Generator<HTMLElement> {
|
|
2
|
+
let current: HTMLElement | null = element;
|
|
3
|
+
while (current) {
|
|
4
|
+
const root = current.getRootNode();
|
|
5
|
+
current = current.assignedSlot ?? current.parentElement ?? (root instanceof ShadowRoot ? (root.host as HTMLElement) : null);
|
|
6
|
+
if (current) yield current;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const fitsClip = (element: HTMLElement): boolean => {
|
|
11
|
+
const rect = element.getBoundingClientRect();
|
|
12
|
+
for (const ancestor of projectionAncestors(element)) {
|
|
13
|
+
const style = getComputedStyle(ancestor);
|
|
14
|
+
if (style.transform !== 'none' && !new DOMMatrixReadOnly(style.transform).isIdentity) return false;
|
|
15
|
+
const bounds = ancestor.getBoundingClientRect();
|
|
16
|
+
const left = bounds.left + ancestor.clientLeft;
|
|
17
|
+
const top = bounds.top + ancestor.clientTop;
|
|
18
|
+
if (/^(hidden|clip|scroll|auto)$/.test(style.overflowX) && (rect.left < left - 1 || rect.right > left + ancestor.clientWidth + 1))
|
|
19
|
+
return false;
|
|
20
|
+
if (/^(hidden|clip|scroll|auto)$/.test(style.overflowY) && (rect.top < top - 1 || rect.bottom > top + ancestor.clientHeight + 1))
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const handoffDuration = 180;
|
|
2
|
+
export const fadeMarker = 'data-native-ui-fading';
|
|
3
|
+
|
|
4
|
+
/** Only visual opacity animates; the runtime transfers input/accessibility ownership once. */
|
|
5
|
+
export const createCrossfade = (win: Window) => {
|
|
6
|
+
const active = new Map<HTMLElement, Animation>();
|
|
7
|
+
const retired = new Set<HTMLElement>();
|
|
8
|
+
const reduced = win.matchMedia('(prefers-reduced-motion: reduce)');
|
|
9
|
+
const finish = (element: HTMLElement, animation: Animation) => {
|
|
10
|
+
if (active.get(element) !== animation) return;
|
|
11
|
+
active.delete(element);
|
|
12
|
+
element.removeAttribute(fadeMarker);
|
|
13
|
+
animation.cancel();
|
|
14
|
+
if (retired.delete(element)) element.remove();
|
|
15
|
+
};
|
|
16
|
+
const cancelAll = () => {
|
|
17
|
+
for (const [element, animation] of active) finish(element, animation);
|
|
18
|
+
};
|
|
19
|
+
reduced.addEventListener('change', cancelAll);
|
|
20
|
+
const api = {
|
|
21
|
+
duration: (instant = false) => (reduced.matches || instant ? 0 : handoffDuration),
|
|
22
|
+
play(element: HTMLElement, toNative: boolean, instant = false) {
|
|
23
|
+
const previous = active.get(element);
|
|
24
|
+
const current = Number(win.getComputedStyle(element).opacity);
|
|
25
|
+
previous?.cancel();
|
|
26
|
+
active.delete(element);
|
|
27
|
+
const opacity = Number(win.getComputedStyle(element).opacity);
|
|
28
|
+
// Tab switches skip the opacity handoff so a retiring native snapshot cannot linger.
|
|
29
|
+
if (reduced.matches || instant || !element.isConnected) {
|
|
30
|
+
element.removeAttribute(fadeMarker);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
element.setAttribute(fadeMarker, '');
|
|
34
|
+
const animation = element.animate([{ opacity: previous ? current : toNative ? opacity : 0 }, { opacity: toNative ? 0 : opacity }], {
|
|
35
|
+
duration: handoffDuration,
|
|
36
|
+
easing: 'ease-in-out',
|
|
37
|
+
fill: 'both',
|
|
38
|
+
});
|
|
39
|
+
active.set(element, animation);
|
|
40
|
+
void animation.finished.then(
|
|
41
|
+
() => finish(element, animation),
|
|
42
|
+
() => finish(element, animation),
|
|
43
|
+
);
|
|
44
|
+
},
|
|
45
|
+
retire(element: HTMLElement, instant = false) {
|
|
46
|
+
element.inert = true;
|
|
47
|
+
element.setAttribute('aria-hidden', 'true');
|
|
48
|
+
element.style.setProperty('pointer-events', 'none', 'important');
|
|
49
|
+
if (!api.duration(instant) || !element.isConnected) {
|
|
50
|
+
const previous = active.get(element);
|
|
51
|
+
if (previous) finish(element, previous);
|
|
52
|
+
element.remove();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
retired.add(element);
|
|
56
|
+
api.play(element, true);
|
|
57
|
+
},
|
|
58
|
+
async settled() {
|
|
59
|
+
await Promise.all(Array.from(active.values(), (animation) => animation.finished.catch(() => {})));
|
|
60
|
+
},
|
|
61
|
+
destroy() {
|
|
62
|
+
reduced.removeEventListener('change', cancelAll);
|
|
63
|
+
cancelAll();
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
return api;
|
|
67
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Opt-in diagnostics. Never log props, action data, or application exception messages. */
|
|
2
|
+
export const createDiagnostics = (enabled = false) => {
|
|
3
|
+
const elements = new WeakMap<HTMLElement, Set<string>>();
|
|
4
|
+
const global = new Set<string>();
|
|
5
|
+
return (reason: string, element?: HTMLElement): void => {
|
|
6
|
+
if (!enabled) return;
|
|
7
|
+
const seen = element ? (elements.get(element) ?? new Set<string>()) : global;
|
|
8
|
+
if (seen.has(reason)) return;
|
|
9
|
+
seen.add(reason);
|
|
10
|
+
if (element) elements.set(element, seen);
|
|
11
|
+
console.info(`[Native UI] ${reason}`, ...(element ? [element] : []));
|
|
12
|
+
};
|
|
13
|
+
};
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { fadeMarker } from './crossfade';
|
|
2
|
+
import { modalUsesVerticalBars } from './modal';
|
|
3
|
+
|
|
4
|
+
import type { Frame } from '../definitions';
|
|
5
|
+
|
|
6
|
+
export const marker = 'data-native-ui-projected';
|
|
7
|
+
// Scoped runtimes (Stencil slot emulation) patch child accessors on upgraded
|
|
8
|
+
// elements and may report none before hydration; the prototype getter still
|
|
9
|
+
// reports the real light DOM.
|
|
10
|
+
const childNodesGet = typeof Node === 'undefined' ? undefined : Object.getOwnPropertyDescriptor(Node.prototype, 'childNodes')?.get;
|
|
11
|
+
export const childNodesOf = (element: Element): Node[] =>
|
|
12
|
+
childNodesGet ? Array.from(childNodesGet.call(element)) : Array.from(element.childNodes);
|
|
13
|
+
export const childElements = (element: Element): HTMLElement[] =>
|
|
14
|
+
childNodesOf(element).filter((node) => node.nodeType === 1) as HTMLElement[];
|
|
15
|
+
export const prehiddenClass = 'native-ui-prehidden';
|
|
16
|
+
export const prehideRootClass = 'native-ui-prehide';
|
|
17
|
+
export const rejectedClass = 'native-ui-rejected';
|
|
18
|
+
export const verticalBarsBackWebClass = 'ios-theme-vertical-bars-back-web-owned';
|
|
19
|
+
const prehideClasses = new Set([prehiddenClass, prehideRootClass]);
|
|
20
|
+
export const prehideOnlyMutation = (record: MutationRecord): boolean => {
|
|
21
|
+
if (record.attributeName !== 'class' || record.oldValue === null) return false;
|
|
22
|
+
const withoutPrehide = (value: string) =>
|
|
23
|
+
value
|
|
24
|
+
.split(/\s+/)
|
|
25
|
+
.filter((name) => name && !prehideClasses.has(name))
|
|
26
|
+
.join(' ');
|
|
27
|
+
return withoutPrehide(record.oldValue) === withoutPrehide((record.target as Element).getAttribute('class') ?? '');
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const withoutPrehide = <T>(element: HTMLElement, read: () => T): T => {
|
|
31
|
+
const changed: HTMLElement[] = [];
|
|
32
|
+
for (let current: HTMLElement | null = element; current; current = current.parentElement) {
|
|
33
|
+
if (current.classList.contains(prehiddenClass)) {
|
|
34
|
+
current.classList.remove(prehiddenClass);
|
|
35
|
+
changed.push(current);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const root = element.ownerDocument.documentElement;
|
|
39
|
+
if (element.matches('ion-back-button') && root.classList.contains(prehideRootClass)) {
|
|
40
|
+
root.classList.remove(prehideRootClass);
|
|
41
|
+
changed.push(root);
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return read();
|
|
45
|
+
} finally {
|
|
46
|
+
changed.forEach((current) => current.classList.add(current === root ? prehideRootClass : prehiddenClass));
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
export const isDark = (style: CSSStyleDeclaration): boolean => {
|
|
50
|
+
const themeScheme = style.getPropertyValue('--ios27-color-scheme').trim();
|
|
51
|
+
if (themeScheme) return themeScheme === 'dark';
|
|
52
|
+
const background = style.getPropertyValue('--ion-background-color-rgb').match(/\d+/g)?.slice(0, 3).map(Number);
|
|
53
|
+
if (background?.length === 3) return background[0] * 0.2126 + background[1] * 0.7152 + background[2] * 0.0722 < 128;
|
|
54
|
+
const schemes = (style.colorScheme ?? '').split(/\s+/);
|
|
55
|
+
if (schemes.includes('dark') && !schemes.includes('light')) return true;
|
|
56
|
+
if (schemes.includes('light') && !schemes.includes('dark')) return false;
|
|
57
|
+
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
|
58
|
+
};
|
|
59
|
+
const permanentlyExcluded = '.ios-theme-disabled, .ios26-disabled, .ion-cloned-element, [hidden], [inert]';
|
|
60
|
+
export const excluded = `${permanentlyExcluded}, .ion-page-hidden, .ion-page-invisible`;
|
|
61
|
+
const enteringPages = new WeakSet<HTMLElement>();
|
|
62
|
+
export const setVerticalBarsEnteringPage = (page: HTMLElement, entering: boolean): void => {
|
|
63
|
+
if (entering) enteringPages.add(page);
|
|
64
|
+
else enteringPages.delete(page);
|
|
65
|
+
};
|
|
66
|
+
export const verticalBarsEnteringPage = (element: HTMLElement): HTMLElement | undefined => {
|
|
67
|
+
const page = element.closest<HTMLElement>('.ion-page-invisible');
|
|
68
|
+
return page && enteringPages.has(page) && page.closest('ion-app.ios-theme-vertical-bars') ? page : undefined;
|
|
69
|
+
};
|
|
70
|
+
export const createVerticalBarsPageState = () => {
|
|
71
|
+
const departed = new WeakSet<HTMLElement>();
|
|
72
|
+
return {
|
|
73
|
+
isDeparted(element: HTMLElement): boolean {
|
|
74
|
+
const page = element.closest<HTMLElement>('.ion-page');
|
|
75
|
+
return !!page && departed.has(page) && !!element.closest('ion-app.ios-theme-vertical-bars');
|
|
76
|
+
},
|
|
77
|
+
lifecycle(event: Event): void {
|
|
78
|
+
// Avoid instanceof so stale listeners stay safe after global teardown.
|
|
79
|
+
const page = event.target && (event.target as Node).nodeType === 1 ? (event.target as HTMLElement) : undefined;
|
|
80
|
+
if (!page?.matches('.ion-page')) return;
|
|
81
|
+
const entering = event.type === 'ionViewWillEnter';
|
|
82
|
+
if (entering || event.type === 'ionViewDidEnter') departed.delete(page);
|
|
83
|
+
else if (event.type === 'ionViewWillLeave' || event.type === 'ionViewDidLeave') departed.add(page);
|
|
84
|
+
setVerticalBarsEnteringPage(page, entering);
|
|
85
|
+
},
|
|
86
|
+
cancel(entering?: HTMLElement, leaving?: HTMLElement): void {
|
|
87
|
+
if (entering) {
|
|
88
|
+
departed.add(entering);
|
|
89
|
+
setVerticalBarsEnteringPage(entering, false);
|
|
90
|
+
}
|
|
91
|
+
if (leaving) departed.delete(leaving);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
const disabledButtonGroup = 'ion-buttons:is(.ios-theme-disabled, .ios26-disabled)';
|
|
96
|
+
const nativeUIDisabledSelector = '.native-ui-disabled, [data-native-ui="disabled"]';
|
|
97
|
+
|
|
98
|
+
export const isDisabledButtonGroupChild = (element: HTMLElement): boolean =>
|
|
99
|
+
element.matches('ion-button') && element.parentElement?.matches(disabledButtonGroup) === true;
|
|
100
|
+
|
|
101
|
+
const verticalBarsTags = new Set(['ion-button', 'ion-back-button', 'ion-buttons', 'ion-menu-button', 'ion-tab-bar']);
|
|
102
|
+
|
|
103
|
+
export const isVerticalBarsSource = (element: HTMLElement): boolean =>
|
|
104
|
+
!element.closest('[data-native-ui-placement="element"]') &&
|
|
105
|
+
(element.matches(
|
|
106
|
+
'[data-native-ui-placement="rail"][data-native-ui="button"], [data-native-ui-placement="rail"][data-native-ui="back-button"]',
|
|
107
|
+
) ||
|
|
108
|
+
(verticalBarsTags.has(element.localName) &&
|
|
109
|
+
(element.matches('ion-tab-bar') || verticalBarsOwned(element)) &&
|
|
110
|
+
!element.closest('ion-menu, ion-popover') &&
|
|
111
|
+
modalUsesVerticalBars(element) &&
|
|
112
|
+
!!element.closest('ion-app.ios-theme-vertical-bars')));
|
|
113
|
+
|
|
114
|
+
export const isVerticalBarsBackPosition = (element: HTMLElement): boolean => {
|
|
115
|
+
if (element.closest('ion-header[collapse], ion-footer[collapse]')) return false;
|
|
116
|
+
const page = element.closest('.ion-page');
|
|
117
|
+
if (!page) return true;
|
|
118
|
+
const candidates = Array.from(page.querySelectorAll<HTMLElement>('ion-back-button')).filter(
|
|
119
|
+
(back) =>
|
|
120
|
+
back.closest('.ion-page') === page &&
|
|
121
|
+
!back.matches('.ion-cloned-element') &&
|
|
122
|
+
!back.closest('ion-header[collapse], ion-footer[collapse], ion-menu, ion-popover') &&
|
|
123
|
+
modalUsesVerticalBars(back),
|
|
124
|
+
);
|
|
125
|
+
const rank = (back: HTMLElement) =>
|
|
126
|
+
inFixedToolbar(back) ? 0 : back.closest('ion-header ion-toolbar, ion-footer ion-toolbar') ? 1 : back.closest('ion-toolbar') ? 3 : 2;
|
|
127
|
+
const best = candidates.reduce<HTMLElement | undefined>(
|
|
128
|
+
(winner, back) => (!winner || rank(back) < rank(winner) ? back : winner),
|
|
129
|
+
undefined,
|
|
130
|
+
);
|
|
131
|
+
return best === element;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const preferredVerticalBarsBack = <T extends HTMLElement>(elements: T[], doc: Document): T | undefined => {
|
|
135
|
+
const pages = Array.from(doc.querySelectorAll('.ion-page'));
|
|
136
|
+
const pageOrder = (element: Element) => pages.indexOf(element.closest('.ion-page')!);
|
|
137
|
+
return elements.sort((a, b) => pageOrder(b) - pageOrder(a) || Number(!!b.closest('ion-header')) - Number(!!a.closest('ion-header')))[0];
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const excludedBy = (element: HTMLElement, selector: string): boolean => {
|
|
141
|
+
const owner = element.closest<HTMLElement>(selector);
|
|
142
|
+
return !!owner && !(element.parentElement === owner && isDisabledButtonGroupChild(element));
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const isPermanentlyExcluded = (element: HTMLElement): boolean => excludedBy(element, permanentlyExcluded);
|
|
146
|
+
export const isExcluded = (element: HTMLElement, enteringPage?: HTMLElement): boolean =>
|
|
147
|
+
excludedBy(element, `${permanentlyExcluded}, .ion-page-hidden`) || (!!element.closest('.ion-page-invisible') && !enteringPage);
|
|
148
|
+
|
|
149
|
+
// A shared native surface must not cover an opted-out descendant either.
|
|
150
|
+
export const isNativeUIDisabled = (element: Element): boolean =>
|
|
151
|
+
!!element.closest(nativeUIDisabledSelector) || !!element.querySelector(nativeUIDisabledSelector);
|
|
152
|
+
|
|
153
|
+
export const isVerticalBarsToolbarActionShape = (element: HTMLElement): boolean =>
|
|
154
|
+
element.matches('ion-menu-button') ||
|
|
155
|
+
(element.matches('ion-button') && !!element.querySelector('ion-icon[slot="icon-only"], svg[slot="icon-only"]'));
|
|
156
|
+
|
|
157
|
+
// Placement belongs to the DOM identity for one routed-page epoch. Changes to
|
|
158
|
+
// content/disabled state affect rendering, never its chosen surface.
|
|
159
|
+
const verticalBarsPlacement = new WeakMap<HTMLElement, boolean>();
|
|
160
|
+
export const setVerticalBarsPlacement = (element: HTMLElement, rail: boolean): void => {
|
|
161
|
+
verticalBarsPlacement.set(element, rail);
|
|
162
|
+
};
|
|
163
|
+
export const clearVerticalBarsPlacement = (element: HTMLElement): void => {
|
|
164
|
+
verticalBarsPlacement.delete(element);
|
|
165
|
+
};
|
|
166
|
+
// Explicit Web placement remains authoritative even when added after initial capture.
|
|
167
|
+
export const verticalBarsOwned = (element: HTMLElement): boolean =>
|
|
168
|
+
verticalBarsPlacement.get(element) === true &&
|
|
169
|
+
!element.closest('ion-buttons.ios-theme-horizontal-only, ion-button.ios-theme-horizontal-only');
|
|
170
|
+
|
|
171
|
+
export const isVerticalBarsToolbarAction = (element: HTMLElement): boolean =>
|
|
172
|
+
verticalBarsOwned(element) && !isExcluded(element, verticalBarsEnteringPage(element)) && !isNativeUIDisabled(element);
|
|
173
|
+
|
|
174
|
+
const overlays = 'ion-menu, ion-popover';
|
|
175
|
+
|
|
176
|
+
/** Shared eligibility for a toolbar action candidate, before placement is decided (prehide) or consumed (projection). */
|
|
177
|
+
export const verticalBarsActionCandidate = (element: HTMLElement): boolean =>
|
|
178
|
+
!element.matches('[data-native-ui="button"], [data-native-ui="back-button"]') &&
|
|
179
|
+
!element.querySelector('[data-native-ui="button"], [data-native-ui="back-button"]') &&
|
|
180
|
+
inFixedToolbar(element) &&
|
|
181
|
+
!element.closest('ion-buttons.ios-theme-horizontal-only, ion-button.ios-theme-horizontal-only') &&
|
|
182
|
+
!isPermanentlyExcluded(element) &&
|
|
183
|
+
!isNativeUIDisabled(element) &&
|
|
184
|
+
!element.closest(overlays) &&
|
|
185
|
+
modalUsesVerticalBars(element);
|
|
186
|
+
|
|
187
|
+
/** Shared eligibility for a back-button candidate; the back button may sit outside a fixed toolbar. */
|
|
188
|
+
export const verticalBarsBackCandidate = (element: HTMLElement): boolean =>
|
|
189
|
+
!element.hasAttribute('data-native-ui') &&
|
|
190
|
+
isVerticalBarsBackPosition(element) &&
|
|
191
|
+
!element.closest('ion-buttons.ios-theme-horizontal-only') &&
|
|
192
|
+
!isPermanentlyExcluded(element) &&
|
|
193
|
+
!isNativeUIDisabled(element) &&
|
|
194
|
+
!element.closest(overlays) &&
|
|
195
|
+
modalUsesVerticalBars(element);
|
|
196
|
+
|
|
197
|
+
export const verticalBarsToolbarActions = (element: HTMLElement): HTMLElement[] =>
|
|
198
|
+
// Back buttons use the dedicated rail slot; they are never toolbar actions.
|
|
199
|
+
childElements(element).filter((child) => !child.matches('ion-back-button') && isVerticalBarsToolbarAction(child));
|
|
200
|
+
|
|
201
|
+
export const isVerticalBarsToolbarGroup = (element: HTMLElement): boolean => {
|
|
202
|
+
return (
|
|
203
|
+
element.matches('ion-buttons') &&
|
|
204
|
+
verticalBarsOwned(element) &&
|
|
205
|
+
!element.matches('.ios-theme-disabled, .ios26-disabled') &&
|
|
206
|
+
!isNativeUIDisabled(element)
|
|
207
|
+
);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const toolbarTextMarker = 'data-native-ui-toolbar-text';
|
|
211
|
+
|
|
212
|
+
export const syncToolbarText = (doc: Document): void => {
|
|
213
|
+
for (const toolbar of Array.from(
|
|
214
|
+
doc.querySelectorAll<HTMLElement>(`ion-app.ios-theme-vertical-bars ion-toolbar, ion-toolbar[${toolbarTextMarker}]`),
|
|
215
|
+
)) {
|
|
216
|
+
toolbar.toggleAttribute(
|
|
217
|
+
toolbarTextMarker,
|
|
218
|
+
!!toolbar.closest('ion-app.ios-theme-vertical-bars') &&
|
|
219
|
+
childNodesOf(toolbar).some((node) => node.nodeType === Node.TEXT_NODE && !!node.textContent?.trim()),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const activateProjectedElement = (element: HTMLElement): void => {
|
|
225
|
+
const target = element.matches('ion-button, ion-back-button, ion-menu-button')
|
|
226
|
+
? element.shadowRoot?.querySelector<HTMLElement>('[part~="native"]')
|
|
227
|
+
: undefined;
|
|
228
|
+
(target ?? element).click();
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
export const unprojected = <T>(elements: Iterable<HTMLElement>, read: () => T): T => {
|
|
232
|
+
const list = Array.from(elements);
|
|
233
|
+
const hidden = list.filter((element) => element.hasAttribute(marker));
|
|
234
|
+
const webHidden = list.filter((element) => element.hasAttribute('data-native-ui-web-source'));
|
|
235
|
+
hidden.forEach((element) => element.removeAttribute(marker));
|
|
236
|
+
webHidden.forEach((element) => element.removeAttribute('data-native-ui-web-source'));
|
|
237
|
+
try {
|
|
238
|
+
return read();
|
|
239
|
+
} finally {
|
|
240
|
+
hidden.forEach((element) => element.setAttribute(marker, ''));
|
|
241
|
+
webHidden.forEach((element) => element.setAttribute('data-native-ui-web-source', ''));
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const readVisible = (element: HTMLElement, allowOutsideViewport: boolean): boolean => {
|
|
246
|
+
const enteringPage = isVerticalBarsSource(element) ? verticalBarsEnteringPage(element) : undefined;
|
|
247
|
+
if (!element.isConnected || isExcluded(element, enteringPage) || isNativeUIDisabled(element)) return false;
|
|
248
|
+
for (let current: HTMLElement | null = element; current; current = current.parentElement) {
|
|
249
|
+
const style = getComputedStyle(current);
|
|
250
|
+
if (
|
|
251
|
+
style.display === 'none' ||
|
|
252
|
+
style.visibility !== 'visible' ||
|
|
253
|
+
(Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker) && current !== enteringPage)
|
|
254
|
+
)
|
|
255
|
+
return false;
|
|
256
|
+
// Ordinary controls on moving/collapsing/custom transformed surfaces stay in Web coordinates.
|
|
257
|
+
// VerticalBars rail controls are placed independently of their Web coordinates and must remain
|
|
258
|
+
// owned while Ionic transforms the content behind an open menu.
|
|
259
|
+
if (
|
|
260
|
+
!allowOutsideViewport &&
|
|
261
|
+
style.transform !== 'none' &&
|
|
262
|
+
!new DOMMatrixReadOnly(style.transform).isIdentity &&
|
|
263
|
+
current !== element &&
|
|
264
|
+
!current.matches('ion-tab-bar')
|
|
265
|
+
)
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
const rect = element.getBoundingClientRect();
|
|
269
|
+
return (
|
|
270
|
+
rect.width > 0 &&
|
|
271
|
+
rect.height > 0 &&
|
|
272
|
+
(allowOutsideViewport || (rect.left >= -1 && rect.top >= -1 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1))
|
|
273
|
+
);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export const visible = (element: HTMLElement, allowOutsideViewport = false): boolean =>
|
|
277
|
+
withoutPrehide(element, () => readVisible(element, allowOutsideViewport));
|
|
278
|
+
|
|
279
|
+
export const frame = (rect: DOMRect, origin?: DOMRect): Frame => ({
|
|
280
|
+
x: rect.x - (origin?.x ?? 0),
|
|
281
|
+
y: rect.y - (origin?.y ?? 0),
|
|
282
|
+
width: rect.width,
|
|
283
|
+
height: rect.height,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
export const text = (element: Element): string => {
|
|
287
|
+
if (element.matches('ion-icon, svg, ion-badge, .ios27-segment-lens, .ion-cloned-element')) return '';
|
|
288
|
+
return childNodesOf(element)
|
|
289
|
+
.map((node) => (node.nodeType === 3 ? node.textContent : node.nodeType === 1 ? text(node as Element) : ''))
|
|
290
|
+
.join('')
|
|
291
|
+
.replace(/\s+/g, ' ')
|
|
292
|
+
.trim();
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
export const inFixedToolbar = (element: Element): boolean => {
|
|
296
|
+
const edge = element.closest('ion-toolbar')?.parentElement;
|
|
297
|
+
const verticalBars = !!element.closest('ion-app.ios-theme-vertical-bars');
|
|
298
|
+
return (
|
|
299
|
+
!!edge?.matches('ion-header, ion-footer') &&
|
|
300
|
+
!element.closest('ion-content') &&
|
|
301
|
+
!edge.hasAttribute('collapse') &&
|
|
302
|
+
(verticalBars || !edge.matches('.header-collapse-main, .header-collapse-condense'))
|
|
303
|
+
);
|
|
304
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const CSS_MOTION_EVENTS = {
|
|
2
|
+
transitionRun: 'transitionrun',
|
|
3
|
+
transitionEnd: 'transitionend',
|
|
4
|
+
transitionCancel: 'transitioncancel',
|
|
5
|
+
animationStart: 'animationstart',
|
|
6
|
+
animationEnd: 'animationend',
|
|
7
|
+
animationCancel: 'animationcancel',
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
// Ionic adapter event names. Generic DOM sources do not require this lifecycle.
|
|
11
|
+
export const LIFECYCLE_WILL_ENTER = 'ionViewWillEnter';
|
|
12
|
+
export const LIFECYCLE_WILL_LEAVE = 'ionViewWillLeave';
|
|
13
|
+
export const LIFECYCLE_DID_ENTER = 'ionViewDidEnter';
|
|
14
|
+
export const LIFECYCLE_DID_LEAVE = 'ionViewDidLeave';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const iconSource = (svg: SVGElement, mirrored?: boolean): string | undefined => {
|
|
2
|
+
// A small, local SVG subset. Never load external content while projecting UI.
|
|
3
|
+
if (
|
|
4
|
+
svg.querySelector('script, foreignObject, image, use, animate, animateTransform, set, style, text') ||
|
|
5
|
+
/url\((?!["']?#)/.test(svg.outerHTML)
|
|
6
|
+
)
|
|
7
|
+
return undefined;
|
|
8
|
+
// Ionicons flips the wrapper, not the SVG, for directional icons in RTL.
|
|
9
|
+
if (mirrored === undefined && svg.parentElement?.matches('.icon-inner')) {
|
|
10
|
+
const transform = new DOMMatrixReadOnly(getComputedStyle(svg.parentElement).transform);
|
|
11
|
+
mirrored = transform.is2D && transform.a === -1 && transform.d === 1 && !transform.b && !transform.c && !transform.e && !transform.f;
|
|
12
|
+
}
|
|
13
|
+
const copy = svg.cloneNode(true) as SVGElement;
|
|
14
|
+
const originals = [svg, ...Array.from(svg.querySelectorAll('*'))];
|
|
15
|
+
const copies = [copy, ...Array.from(copy.querySelectorAll('*'))];
|
|
16
|
+
originals.forEach((node, index) => {
|
|
17
|
+
const style = getComputedStyle(node);
|
|
18
|
+
for (const property of ['fill', 'stroke', 'stroke-width', 'opacity', 'fill-opacity', 'stroke-opacity', 'color']) {
|
|
19
|
+
(copies[index] as SVGElement).style.setProperty(property, style.getPropertyValue(property));
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
23
|
+
if (mirrored) {
|
|
24
|
+
copy.style.transform = 'scaleX(-1)';
|
|
25
|
+
copy.style.transformOrigin = 'center';
|
|
26
|
+
}
|
|
27
|
+
return new XMLSerializer().serializeToString(copy);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const createIconRenderer = () => {
|
|
31
|
+
const cache = new Map<string, string>();
|
|
32
|
+
return {
|
|
33
|
+
clear: () => cache.clear(),
|
|
34
|
+
async render(source: string, width: number, height: number): Promise<string> {
|
|
35
|
+
const scale = devicePixelRatio || 1;
|
|
36
|
+
const key = `${width}:${height}:${scale}:${source}`;
|
|
37
|
+
const cached = cache.get(key);
|
|
38
|
+
if (cached) return cached;
|
|
39
|
+
const url = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml' }));
|
|
40
|
+
try {
|
|
41
|
+
const image = new Image();
|
|
42
|
+
image.src = url;
|
|
43
|
+
await image.decode();
|
|
44
|
+
const canvas = document.createElement('canvas');
|
|
45
|
+
canvas.width = Math.ceil(width * scale);
|
|
46
|
+
canvas.height = Math.ceil(height * scale);
|
|
47
|
+
canvas.getContext('2d')!.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
48
|
+
const data = canvas.toDataURL('image/png').split(',')[1];
|
|
49
|
+
if (cache.size >= 128) cache.delete(cache.keys().next().value!);
|
|
50
|
+
cache.set(key, data);
|
|
51
|
+
return data;
|
|
52
|
+
} finally {
|
|
53
|
+
URL.revokeObjectURL(url);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|