diff --git a/demo/e2e/animation.spec.ts b/demo/e2e/animation.spec.ts
index 57d253de..87f48bdb 100644
--- a/demo/e2e/animation.spec.ts
+++ b/demo/e2e/animation.spec.ts
@@ -60,7 +60,7 @@ test.describe('Animation Tests', () => {
});
test('runs and completes the iOS page transition', async ({ page }) => {
- const shade = page.locator('.ios27-transition-shade');
+ const shade = page.locator('.ios-transition-shade');
const checkDimming = async (back: boolean) => {
const opacity: number[] = [];
for (const progress of [0.2, 0.4]) {
diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts
index feef1e95..1e1742c0 100644
--- a/demo/e2e/native-ui-shell.spec.ts
+++ b/demo/e2e/native-ui-shell.spec.ts
@@ -1,9 +1,11 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
-import { compile } from 'sass';
+import { compile, NodePackageImporter } from 'sass';
import { resolve } from 'node:path';
import * as overlayTypes from '../src/app/overlay-types';
+const importer = new NodePackageImporter(resolve(__dirname, '../../'));
+
const mockNative = async (page: Page, fail = false) => {
await page.addInitScript((fail) => {
const state = {
@@ -1730,7 +1732,9 @@ for (const theme of ['light', 'class', 'system', 'always'] as const) {
document.documentElement.style.colorScheme = 'normal';
});
if (theme !== 'light') {
- await page.addStyleTag({ content: compile(resolve(__dirname, `../../src/styles/ionic-theme-ios27-dark-${theme}.scss`)).css });
+ await page.addStyleTag({
+ content: compile(resolve(__dirname, `../../src/styles/ionic-theme-ios27-dark-${theme}.scss`), { importers: [importer] }).css,
+ });
}
for (const dark of [true, false, true]) {
if (theme === 'class') {
diff --git a/demo/e2e/popover.spec.ts b/demo/e2e/popover.spec.ts
index 5882c837..a7d6e513 100644
--- a/demo/e2e/popover.spec.ts
+++ b/demo/e2e/popover.spec.ts
@@ -136,7 +136,7 @@ for (const width of [390, 1210]) {
const measure = el.getBoundingClientRect;
el.getBoundingClientRect = () => {
const rect = measure.call(el);
- if (!el.classList.contains('ios27-replace-element')) el.presentationBounds = rect;
+ if (!el.classList.contains('ios-theme-replace-element')) el.presentationBounds = rect;
return rect;
};
el.restoreMeasurement = () => {
@@ -249,7 +249,7 @@ for (const tag of ['button', 'ion-button']) {
originY: rect.top + origin[1],
expected: horizontal ? 320.5 : 520.5,
callout: root.querySelectorAll('[part="callout-glass"]').length,
- replacing: anchor.classList.contains('ios27-replace-element'),
+ replacing: anchor.classList.contains('ios-theme-replace-element'),
};
await popover.dismiss();
popover.remove();
diff --git a/demo/e2e/tab-bar-preferences.spec.ts b/demo/e2e/tab-bar-preferences.spec.ts
index ee597327..1a0ef3bc 100644
--- a/demo/e2e/tab-bar-preferences.spec.ts
+++ b/demo/e2e/tab-bar-preferences.spec.ts
@@ -1,14 +1,18 @@
import { expect, test } from '@playwright/test';
-import { compile } from 'sass';
+import { compile, NodePackageImporter } from 'sass';
import { resolve } from 'node:path';
test.use({ viewport: { width: 402, height: 874 }, hasTouch: true });
+const importer = new NodePackageImporter(resolve(__dirname, '../../'));
+
for (const theme of ['light', 'class', 'system', 'always']) {
test(`tab selection color fallbacks in ${theme} mode`, async ({ page }) => {
const styles = resolve(__dirname, '../../src/styles');
- const css = compile(`${styles}/default-variables.scss`).css + compile(`${styles}/ionic-theme-ios27.scss`).css;
- const darkCss = theme === 'light' ? '' : compile(`${styles}/ionic-theme-ios27-dark-${theme}.scss`).css;
+ const css =
+ compile(`${styles}/default-variables.scss`, { importers: [importer] }).css +
+ compile(`${styles}/ionic-theme-ios27.scss`, { importers: [importer] }).css;
+ const darkCss = theme === 'light' ? '' : compile(`${styles}/ionic-theme-ios27-dark-${theme}.scss`, { importers: [importer] }).css;
await page.emulateMedia({ colorScheme: 'dark', reducedMotion: 'reduce' });
const html = `
diff --git a/demo/playwright.config.ts b/demo/playwright.config.ts
index 02892146..5b1a3f3f 100644
--- a/demo/playwright.config.ts
+++ b/demo/playwright.config.ts
@@ -7,6 +7,7 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
+ workers: process.env['CI'] ? 4 : undefined,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
reporter: 'html',
diff --git a/package-lock.json b/package-lock.json
index ed5aa79c..63ded223 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,9 @@
"name": "@rdlabo/ionic-theme-ios27",
"version": "0.1.0",
"license": "MIT",
+ "dependencies": {
+ "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#d27e4d9bc1bc53f932bcc653d420936553847f4c"
+ },
"devDependencies": {
"@capacitor/core": "^8.5.2",
"@ionic/angular": "^9.0.0",
@@ -2216,6 +2219,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@rdlabo/ionic-theme-utils": {
+ "version": "0.1.0",
+ "resolved": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#d27e4d9bc1bc53f932bcc653d420936553847f4c",
+ "integrity": "sha512-0/aRkysY012ByvBJFaKBNiU5BFeR7uHCxuQ/BBprq1eHjOgS2AoyZwcBqhqP3SZDDDhnGKLK/SfLQYRySeFAng==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@ionic/core": ">=8.8.1 <10"
+ }
+ },
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz",
diff --git a/package.json b/package.json
index 64783e9c..d7b75f0f 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
],
"scripts": {
"build": "npm run build:css && npm run build:ts",
- "build:css": "rm -rf dist/css && sass src/styles:dist/css --style=compressed --no-source-map",
+ "build:css": "rm -rf dist/css && sass src/styles:dist/css --style=compressed --no-source-map --pkg-importer=node",
"build:ts": "tsc",
"build:demo": "npm run build && cd demo && npm install && npm run build -- --configuration=production",
"lint": "prettier --check \"./**/*.{scss,ts}\" && prettier --parser angular --check \"./**/*.html\"",
@@ -53,6 +53,9 @@
"url": "https://github.com/rdlabo-dev/ionic-theme-ios27/issues"
},
"homepage": "https://docs.rdlabo.dev/projects/ionic-theme-ios27",
+ "dependencies": {
+ "@rdlabo/ionic-theme-utils": "git+ssh://git@github.com/rdlabo-dev/ionic-theme-utils.git#d27e4d9bc1bc53f932bcc653d420936553847f4c"
+ },
"devDependencies": {
"@capacitor/core": "^8.5.2",
"@ionic/angular": "^9.0.0",
diff --git a/src/index.ts b/src/index.ts
index db5d4d25..4a90cebe 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,8 +1,10 @@
import { registeredEffect } from './sheets-of-glass/interfaces';
import { registerEffect } from './sheets-of-glass';
export * from './sheets-of-glass/interfaces';
-export { iosEnterAnimation as popoverEnterAnimation } from './popover/animations/ios.enter';
-export { iosLeaveAnimation as popoverLeaveAnimation } from './popover/animations/ios.leave';
+export {
+ iosPopoverEnterAnimation as popoverEnterAnimation,
+ iosPopoverLeaveAnimation as popoverLeaveAnimation,
+} from '@rdlabo/ionic-theme-utils';
export * from './tab-bar-searchable';
export * from './searchbar';
export * from './transition/ios.transition';
diff --git a/src/popover/animations/ios.enter.ts b/src/popover/animations/ios.enter.ts
deleted file mode 100644
index e1cfde71..00000000
--- a/src/popover/animations/ios.enter.ts
+++ /dev/null
@@ -1,257 +0,0 @@
-import { createAnimation } from '@ionic/core';
-import type { Animation } from '@ionic/core';
-import { getElementRoot } from '../../utils';
-import { calculateWindowAdjustment, getPopoverDimensions, getPopoverPosition } from '../utils';
-import { createCalloutSurface } from '../callout-surface';
-
-const POPOVER_IOS_BODY_PADDING = 5;
-export const POPOVER_IOS_BODY_MARGIN = 8;
-
-/**
- * iOS Popover Enter Animation
- */
-// TODO(FW-2832): types
-export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => {
- const { event: ev, size, trigger, reference, side, align } = opts;
- const doc = baseEl.ownerDocument as any;
- const isRTL = doc.dir === 'rtl';
- const bodyWidth = doc.defaultView.innerWidth;
- const bodyHeight = doc.defaultView.innerHeight;
-
- const root = getElementRoot(baseEl);
- const contentEl = root.querySelector('.popover-content') as HTMLElement;
- const arrowEl = root.querySelector('.popover-arrow');
-
- const referenceSizeEl = trigger || ev?.detail?.ionShadowTarget || ev?.target;
- const anchorBounds = referenceSizeEl?.getBoundingClientRect();
- // Overlays live outside the page, but must stay in the pane that opened them.
- let paneAnchor = referenceSizeEl as HTMLElement | undefined;
- let pane: HTMLElement | null = null;
- while (paneAnchor && !pane) {
- pane = paneAnchor.closest('ion-content, .ion-page, ion-menu');
- paneAnchor = (paneAnchor.getRootNode() as ShadowRoot).host as HTMLElement | undefined;
- }
- const paneBounds = pane?.getBoundingClientRect();
- const scroll = pane?.shadowRoot?.querySelector('[part="scroll"]');
- const scrollStyle = scroll ? getComputedStyle(scroll) : null;
- let paneLeft = Math.max(0, (paneBounds?.left ?? 0) + (parseFloat(scrollStyle?.paddingLeft ?? '0') || 0));
- let paneRight = Math.min(bodyWidth, (paneBounds?.right ?? bodyWidth) - (parseFloat(scrollStyle?.paddingRight ?? '0') || 0));
- // Toolbars may be siblings of ion-content; exclude the visible menu there too.
- const splitPane = pane?.closest('ion-split-pane.split-pane-visible');
- if (splitPane && pane?.closest('.split-pane-main')) {
- for (const menu of Array.from(splitPane.querySelectorAll(':scope > ion-menu.menu-pane-visible'))) {
- const rect = menu.getBoundingClientRect();
- if (rect.left <= paneLeft && rect.right > paneLeft) paneLeft = rect.right;
- if (rect.right >= paneRight && rect.left < paneRight) paneRight = rect.left;
- }
- }
- const paneMargin = size === 'cover' ? 0 : POPOVER_IOS_BODY_MARGIN;
- const availableWidth = Math.max(0, paneRight - paneLeft - paneMargin * 2);
- if (pane && availableWidth > 0 && contentEl.getBoundingClientRect().width > availableWidth) {
- contentEl.dataset['previousMaxWidth'] = contentEl.style.maxWidth;
- contentEl.style.maxWidth = `${availableWidth}px`;
- }
- const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, referenceSizeEl);
-
- const isReplace = ((): boolean => {
- if (reference === 'event' || !referenceSizeEl || !['ion-button', 'ion-buttons'].includes(referenceSizeEl.localName)) {
- return false;
- }
- if (referenceSizeEl.matches('.ios-theme-disabled, .ios26-disabled')) {
- return false;
- }
- return true;
- })();
-
- const defaultPosition = {
- top: bodyHeight / 2 - contentHeight / 2,
- left: bodyWidth / 2 - contentWidth / 2,
- originX: isRTL ? 'right' : 'left',
- originY: 'top',
- };
-
- const results = getPopoverPosition(isRTL, contentWidth, contentHeight, reference, side, align, defaultPosition, trigger, ev);
-
- // Use the same reference for placement, the callout and the animation origin.
- const anchor = reference === 'event' ? results.referenceCoordinates : anchorBounds;
-
- const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING;
- const margin = size === 'cover' ? 0 : POPOVER_IOS_BODY_MARGIN;
-
- const {
- originX,
- originY,
- top,
- left: windowLeft,
- bottom,
- checkSafeAreaLeft,
- checkSafeAreaRight,
- addPopoverBottomClass,
- } = calculateWindowAdjustment(
- side,
- results.top,
- results.left,
- padding,
- bodyWidth,
- bodyHeight,
- contentWidth,
- contentHeight,
- margin,
- results.originX,
- results.originY,
- results.referenceCoordinates,
- referenceSizeEl?.getBoundingClientRect(),
- isReplace,
- );
- // A replacing surface grows inward from the button's edge, not its center.
- const preferredLeft =
- isReplace && anchorBounds
- ? anchorBounds.left + anchorBounds.width / 2 <= (paneLeft + paneRight) / 2
- ? anchorBounds.left
- : anchorBounds.right - contentWidth
- : windowLeft;
- const left = pane ? Math.max(paneLeft + paneMargin, Math.min(paneRight - paneMargin - contentWidth, preferredLeft)) : preferredLeft;
- const physicalSide = side === 'start' ? (isRTL ? 'right' : 'left') : side === 'end' ? (isRTL ? 'left' : 'right') : side;
- const horizontal = physicalSide === 'left' || physicalSide === 'right';
- const above = addPopoverBottomClass || physicalSide === 'top';
- const hasCallout = !!arrowEl && !isReplace && !!anchor && size !== 'cover' && bottom === undefined;
- const surfaceTop = hasCallout && !horizontal ? top + (above ? -5 : 5) : top;
- const contentOrigin = anchor
- ? `${anchor.left + anchor.width / 2 - left}px ${anchor.top + anchor.height / 2 - surfaceTop}px`
- : `${originX} ${originY}`;
-
- const baseAnimation = createAnimation();
- const backdropAnimation = createAnimation();
- const contentAnimation = createAnimation();
- const targetAnimation = createAnimation();
- const arrowAnimation = createAnimation();
- const surfaceAnimation = createAnimation();
- if (arrowEl) {
- arrowAnimation.addElement(arrowEl).delay(300).duration(200).fromTo('opacity', 0, 1);
- }
- if (hasCallout && anchor) {
- const length = horizontal ? contentHeight : contentWidth;
- const inset = Math.min(48, length / 2);
- const center = Math.max(
- inset,
- Math.min(length - inset, horizontal ? anchor.top + anchor.height / 2 - top : anchor.left + anchor.width / 2 - left),
- );
- const arrowSide = horizontal ? (physicalSide === 'left' ? 'right' : 'left') : above ? 'bottom' : 'top';
- const layers = createCalloutSurface(root, contentWidth, contentHeight, arrowSide, center);
- baseEl.classList.add('ios-theme-callout');
- for (const layer of layers) {
- layer.style.left = `calc(${left - 32}px + var(--offset-x, 0))`;
- layer.style.top = `calc(${surfaceTop - 32}px + var(--offset-y, 0))`;
- const [originLeft, originTop] = contentOrigin.split(' ').map(parseFloat);
- layer.style.transformOrigin = `${originLeft + 32}px ${originTop + 32}px`;
- }
- surfaceAnimation
- .addElement(layers)
- .delay(100)
- .duration(400)
- .easing('cubic-bezier(0, 1, 0.22, 1)')
- .fromTo('transform', 'scale(0)', 'scale(1)')
- .fromTo('opacity', 0.01, 1);
- }
-
- backdropAnimation
- .delay(100)
- .duration(300)
- .addElement(root.querySelector('ion-backdrop')!)
- .fromTo('opacity', 0.01, 'var(--backdrop-opacity)')
- .beforeStyles({
- 'pointer-events': 'none',
- })
- .afterClearStyles(['pointer-events']);
-
- // In Chromium, if the wrapper animates, the backdrop filter doesn't work.
- // The Chromium team stated that this behavior is expected and not a bug. The element animating opacity creates a backdrop root for the backdrop-filter.
- // To get around this, instead of animating the wrapper, animate content.
- // https://bugs.chromium.org/p/chromium/issues/detail?id=1148826
- contentAnimation
- .easing('cubic-bezier(0, 1, 0.22, 1)')
- .delay(100)
- .duration(400)
- .addElement(root.querySelector('.popover-content')!)
- .beforeStyles({ 'transform-origin': contentOrigin })
- .beforeAddWrite(() => {
- /**
- * 'transformOrigin' use for leave animation.
- */
- root.querySelector('.popover-content')!.dataset['transformOrigin'] = contentOrigin;
- })
- .fromTo('transform', 'scale(0)', 'scale(1)')
- .fromTo('opacity', 0.01, 1);
-
- if (isReplace) {
- targetAnimation
- .delay(0)
- .duration(200)
- .addElement(referenceSizeEl)
- .beforeStyles({ 'transform-origin': `${originY} ${originX}` })
- .beforeAddClass('ios27-replace-element')
- .fromTo('transform', 'scale(1)', 'scale(1.05)')
- .fromTo('opacity', 1, 0);
- }
-
- return baseAnimation
- .easing('ease')
- .delay(100)
- .duration(100)
- .beforeAddWrite(() => {
- if (size === 'cover') {
- baseEl.style.setProperty('--width', `${contentWidth}px`);
- }
-
- if (addPopoverBottomClass) {
- baseEl.classList.add('popover-bottom');
- }
-
- if (bottom !== undefined) {
- contentEl.style.setProperty('bottom', `${bottom}px`);
- }
-
- const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
- const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
-
- let leftValue = `${left}px`;
-
- if (checkSafeAreaLeft && !pane) {
- leftValue = `${left}px${safeAreaLeft}`;
- }
- if (checkSafeAreaRight && !pane) {
- leftValue = `${left}px${safeAreaRight}`;
- }
-
- contentEl.style.setProperty('top', `calc(${surfaceTop}px + var(--offset-y, 0))`);
- contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`);
- contentEl.style.setProperty('transform-origin', contentOrigin);
-
- // Morphing buttons replace their anchor; ordinary anchored popovers point to it.
- if (arrowEl) {
- arrowEl.style.display = 'none';
- if (hasCallout && anchor) {
- const inset = Math.min(48, (horizontal ? contentHeight : contentWidth) / 2);
- const clamp = (value: number, length: number) => Math.max(inset, Math.min(length - inset, value));
- let arrowLeft: number;
- let arrowTop: number;
- let rotation: number;
- if (horizontal) {
- arrowLeft = physicalSide === 'left' ? left + contentWidth - 10 : left - 24;
- arrowTop = top + clamp(anchor.top + anchor.height / 2 - top, contentHeight) - 7;
- rotation = physicalSide === 'left' ? 90 : -90;
- } else {
- arrowLeft = left + clamp(anchor.left + anchor.width / 2 - left, contentWidth) - 17;
- arrowTop = above ? surfaceTop + contentHeight - 1 : surfaceTop - 13;
- rotation = above ? 180 : 0;
- }
- arrowEl.style.setProperty('display', 'block');
- arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0))`);
- arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0))`);
- arrowEl.style.setProperty('bottom', 'auto');
- arrowEl.style.setProperty('transform', `rotate(${rotation}deg)`);
- }
- }
- })
- .addAnimation([backdropAnimation, contentAnimation, targetAnimation, arrowAnimation, surfaceAnimation]);
-};
diff --git a/src/popover/animations/ios.leave.ts b/src/popover/animations/ios.leave.ts
deleted file mode 100644
index b0ff4436..00000000
--- a/src/popover/animations/ios.leave.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { createAnimation } from '@ionic/core';
-import { getElementRoot } from '../../utils';
-
-import type { Animation } from '@ionic/core';
-
-/**
- * iOS Popover Leave Animation
- */
-export const iosLeaveAnimation = (baseEl: HTMLElement): Animation => {
- const root = getElementRoot(baseEl);
- const contentEl = root.querySelector('.popover-content') as HTMLElement;
- const arrowEl = root.querySelector('.popover-arrow') as HTMLElement | null;
-
- const baseAnimation = createAnimation();
- const backdropAnimation = createAnimation();
- const contentAnimation = createAnimation();
- const targetAnimation = createAnimation();
- const surfaceAnimation = createAnimation()
- .addElement(Array.from(root.querySelectorAll('.ios-theme-callout-layer')))
- .duration(400)
- .easing('ease')
- .fromTo('opacity', 0.99, 0)
- .fromTo('transform', 'scale(1)', 'scale(0)');
-
- const doc = baseEl.ownerDocument as any;
- const replaceElement = doc.querySelector('.ios27-replace-element') as HTMLElement | null;
-
- if (replaceElement) {
- const ratio = contentEl.getBoundingClientRect().width / contentEl.getBoundingClientRect().height;
- const scale = ratio > 1 ? `${Math.min(1.2, 1.05 * ratio)}, 1.05` : `1.05, ${Math.min(1.2, 1.05 * ratio)}`;
-
- targetAnimation
- .addElement(replaceElement)
- .delay(100)
- .duration(300)
- .afterRemoveClass('ios27-replace-element')
- .fromTo('transform', `scale(${scale})`, 'scale(1)')
- .fromTo('opacity', 0, 0.9);
- }
-
- backdropAnimation.addElement(root.querySelector('ion-backdrop')!).fromTo('opacity', 'var(--backdrop-opacity)', 0);
-
- contentAnimation
- .duration(400)
- .easing('ease')
- .addElement(root.querySelector('.popover-arrow')!)
- .addElement(root.querySelector('.popover-content')!)
- .fromTo('opacity', 0.99, 0);
-
- const popoverContentDataset = root.querySelector('.popover-content')!.dataset['transformOrigin'];
- if (popoverContentDataset) {
- contentAnimation.beforeStyles({ 'transform-origin': popoverContentDataset }).afterAddRead(() => {
- root.querySelector('.popover-content')!.dataset['transformOrigin'] = '';
- });
-
- if (replaceElement) {
- contentAnimation.fromTo('transform', 'scale(1)', 'scale(0.55)');
- } else {
- contentAnimation.fromTo('transform', 'scale(1)', 'scale(0)');
- }
- }
-
- return baseAnimation
- .easing('ease')
- .afterAddWrite(() => {
- baseEl.style.removeProperty('--width');
- baseEl.classList.remove('popover-bottom');
- baseEl.classList.remove('ios-theme-callout');
- root.querySelectorAll('.ios-theme-callout-layer').forEach((layer) => layer.remove());
-
- contentEl.style.removeProperty('top');
- contentEl.style.removeProperty('left');
- contentEl.style.removeProperty('bottom');
- contentEl.style.removeProperty('transform-origin');
- if (contentEl.dataset['previousMaxWidth'] !== undefined) {
- contentEl.style.maxWidth = contentEl.dataset['previousMaxWidth'];
- delete contentEl.dataset['previousMaxWidth'];
- }
-
- if (arrowEl) {
- arrowEl.style.removeProperty('top');
- arrowEl.style.removeProperty('left');
- arrowEl.style.removeProperty('display');
- arrowEl.style.removeProperty('bottom');
- arrowEl.style.removeProperty('transform');
- }
- })
- .duration(300)
- .addAnimation([backdropAnimation, contentAnimation, targetAnimation, surfaceAnimation]);
-};
diff --git a/src/popover/callout-surface.ts b/src/popover/callout-surface.ts
deleted file mode 100644
index 619604c4..00000000
--- a/src/popover/callout-surface.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/** Draw one glass silhouette so the arrow cannot sample the body's own border/shadow. */
-export const createCalloutSurface = (
- root: ShadowRoot | HTMLElement,
- width: number,
- height: number,
- side: 'top' | 'right' | 'bottom' | 'left',
- center: number,
-): HTMLElement[] => {
- const inset = 32;
- const x = inset;
- const y = inset;
- const right = x + width;
- const bottom = y + height;
- const radius = Math.max(0, Math.min(34, width / 2, height / 2, (side === 'left' || side === 'right' ? height : width) / 2 - 17));
- const point = (u: number, v: number): string => {
- switch (side) {
- case 'top':
- return `${x + center - 17 + u} ${y - 13 + v}`;
- case 'right':
- return `${right + 13 - v} ${y + center - 17 + u}`;
- case 'bottom':
- return `${x + center + 17 - u} ${bottom + 13 - v}`;
- case 'left':
- return `${x - 13 + v} ${y + center + 17 - u}`;
- }
- };
- const arrow = `L ${point(0, 13)} C ${point(3, 13)} ${point(4.5, 12)} ${point(6.5, 10)} L ${point(14.5, 1.5)} C ${point(16, -0.5)} ${point(18, -0.5)} ${point(19.5, 1.5)} L ${point(27.5, 10)} C ${point(29.5, 12)} ${point(31, 13)} ${point(34, 13)}`;
- const path = `M ${x + radius} ${y} ${side === 'top' ? arrow : ''}
- L ${right - radius} ${y} A ${radius} ${radius} 0 0 1 ${right} ${y + radius} ${side === 'right' ? arrow : ''}
- L ${right} ${bottom - radius} A ${radius} ${radius} 0 0 1 ${right - radius} ${bottom} ${side === 'bottom' ? arrow : ''}
- L ${x + radius} ${bottom} A ${radius} ${radius} 0 0 1 ${x} ${bottom - radius} ${side === 'left' ? arrow : ''}
- L ${x} ${y + radius} A ${radius} ${radius} 0 0 1 ${x + radius} ${y} Z`;
- const svg = (body: string) =>
- `url("data:image/svg+xml,${encodeURIComponent(``)}")`;
- const fill = svg(``);
- const stroke = svg(``);
- const shadow = svg(
- ``,
- );
- const wrapper = root.querySelector('.popover-wrapper')!;
- return [
- ['callout-shadow', shadow],
- ['callout-glass', fill],
- ['callout-border', stroke],
- ].map(([part, mask]) => {
- const element = document.createElement('div');
- element.setAttribute('part', part);
- element.className = 'ios-theme-callout-layer';
- element.setAttribute('aria-hidden', 'true');
- element.style.cssText = `position:absolute;pointer-events:none;z-index:9;width:${width + inset * 2}px;height:${height + inset * 2}px;mask-image:${mask};-webkit-mask-image:${mask};mask-repeat:no-repeat;`;
- wrapper.append(element);
- return element;
- });
-};
diff --git a/src/popover/popover-interface.ts b/src/popover/popover-interface.ts
deleted file mode 100644
index b633da3f..00000000
--- a/src/popover/popover-interface.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import type { AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, Mode, OverlayInterface } from '@ionic/core';
-
-export interface PopoverInterface extends OverlayInterface {
- present: (event?: MouseEvent | TouchEvent | PointerEvent) => Promise;
-}
-
-export interface PopoverOptions {
- component: T;
- componentProps?: ComponentProps;
- showBackdrop?: boolean;
- backdropDismiss?: boolean;
- translucent?: boolean;
- cssClass?: string | string[];
- event?: Event;
- delegate?: FrameworkDelegate;
- animated?: boolean;
- focusTrap?: boolean;
-
- mode?: Mode;
- keyboardClose?: boolean;
- id?: string;
- htmlAttributes?: { [key: string]: any };
-
- enterAnimation?: AnimationBuilder;
- leaveAnimation?: AnimationBuilder;
-
- size?: PopoverSize;
- dismissOnSelect?: boolean;
- reference?: PositionReference;
- side?: PositionSide;
- alignment?: PositionAlign;
- arrow?: boolean;
-
- trigger?: string;
- triggerAction?: string;
-}
-
-export type PopoverSize = 'cover' | 'auto';
-
-export type TriggerAction = 'click' | 'hover' | 'context-menu';
-
-export type PositionReference = 'trigger' | 'event';
-export type PositionSide = 'top' | 'right' | 'bottom' | 'left' | 'start' | 'end';
-export type PositionAlign = 'start' | 'center' | 'end';
diff --git a/src/popover/utils.ts b/src/popover/utils.ts
deleted file mode 100644
index e38fe13b..00000000
--- a/src/popover/utils.ts
+++ /dev/null
@@ -1,696 +0,0 @@
-import { getElementRoot, raf } from '../utils';
-
-import type { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from './popover-interface';
-import { POPOVER_IOS_BODY_MARGIN } from './animations/ios.enter';
-
-interface InteractionCallback {
- eventName: string;
- callback: (ev: any) => void; // TODO(FW-2832): type
-}
-
-export interface ReferenceCoordinates {
- top: number;
- left: number;
- width: number;
- height: number;
-}
-
-interface PopoverPosition {
- top: number;
- left: number;
- referenceCoordinates?: ReferenceCoordinates;
- originX: string;
- originY: string;
-}
-
-export interface PopoverStyles {
- top: number;
- left: number;
- bottom?: number;
- originX: string;
- originY: string;
- checkSafeAreaLeft: boolean;
- checkSafeAreaRight: boolean;
- addPopoverBottomClass: boolean;
-}
-
-/**
- * Returns the recommended dimensions of the popover
- * that takes into account whether or not the width
- * should match the trigger width.
- */
-export const getPopoverDimensions = (size: PopoverSize, contentEl: HTMLElement, triggerEl?: HTMLElement) => {
- const contentDimentions = contentEl.getBoundingClientRect();
- const contentHeight = contentDimentions.height;
- let contentWidth = contentDimentions.width;
-
- if (size === 'cover' && triggerEl) {
- const triggerDimensions = triggerEl.getBoundingClientRect();
- contentWidth = triggerDimensions.width;
- }
-
- return {
- contentWidth,
- contentHeight,
- };
-};
-
-export const configureDismissInteraction = (
- triggerEl: HTMLElement,
- triggerAction: TriggerAction,
- popoverEl: HTMLIonPopoverElement,
- parentPopoverEl: HTMLIonPopoverElement,
-) => {
- let dismissCallbacks: InteractionCallback[] = [];
- const root = getElementRoot(parentPopoverEl);
- const parentContentEl = root.querySelector('.popover-content') as HTMLElement;
-
- switch (triggerAction) {
- case 'hover':
- dismissCallbacks = [
- {
- /**
- * Do not use mouseover here
- * as this will causes the event to
- * be dispatched on each underlying
- * element rather than on the popover
- * content as a whole.
- */
- eventName: 'mouseenter',
- callback: (ev: MouseEvent) => {
- /**
- * Do not dismiss the popover is we
- * are hovering over its trigger.
- * This would be easier if we used mouseover
- * but this would cause the event to be dispatched
- * more often than we would like, potentially
- * causing performance issues.
- */
- const element = document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null;
- if (element === triggerEl) {
- return;
- }
-
- popoverEl.dismiss(undefined, undefined, false);
- },
- },
- ];
- break;
- case 'context-menu':
- case 'click':
- default:
- dismissCallbacks = [
- {
- eventName: 'click',
- callback: (ev: MouseEvent) => {
- /**
- * Do not dismiss the popover is we
- * are hovering over its trigger.
- */
- const target = ev.target as HTMLElement;
- const closestTrigger = target.closest('[data-ion-popover-trigger]');
- if (closestTrigger === triggerEl) {
- /**
- * stopPropagation here so if the
- * popover has dismissOnSelect="true"
- * the popover does not dismiss since
- * we just clicked a trigger element.
- */
- ev.stopPropagation();
- return;
- }
-
- popoverEl.dismiss(undefined, undefined, false);
- },
- },
- ];
- break;
- }
-
- dismissCallbacks.forEach(({ eventName, callback }) => parentContentEl.addEventListener(eventName, callback));
-
- return () => {
- dismissCallbacks.forEach(({ eventName, callback }) => parentContentEl.removeEventListener(eventName, callback));
- };
-};
-
-/**
- * Configures the triggerEl to respond
- * to user interaction based upon the triggerAction
- * prop that devs have defined.
- */
-export const configureTriggerInteraction = (triggerEl: HTMLElement, triggerAction: TriggerAction, popoverEl: HTMLIonPopoverElement) => {
- let triggerCallbacks: InteractionCallback[] = [];
-
- /**
- * Based upon the kind of trigger interaction
- * the user wants, we setup the correct event
- * listeners.
- */
- switch (triggerAction) {
- case 'hover':
- let hoverTimeout: ReturnType | undefined;
-
- triggerCallbacks = [
- {
- eventName: 'mouseenter',
- callback: async (ev: Event) => {
- ev.stopPropagation();
-
- if (hoverTimeout) {
- clearTimeout(hoverTimeout);
- }
-
- /**
- * Hovering over a trigger should not
- * immediately open the next popover.
- */
- hoverTimeout = setTimeout(() => {
- raf(() => {
- popoverEl.presentFromTrigger(ev);
- hoverTimeout = undefined;
- });
- }, 100);
- },
- },
- {
- eventName: 'mouseleave',
- callback: (ev: MouseEvent) => {
- if (hoverTimeout) {
- clearTimeout(hoverTimeout);
- }
-
- /**
- * If mouse is over another popover
- * that is not this popover then we should
- * close this popover.
- */
- const target = ev.relatedTarget as HTMLElement | null;
- if (!target) {
- return;
- }
-
- if (target.closest('ion-popover') !== popoverEl) {
- popoverEl.dismiss(undefined, undefined, false);
- }
- },
- },
- {
- /**
- * stopPropagation here prevents the popover
- * from dismissing when dismiss-on-select="true".
- */
- eventName: 'click',
- callback: (ev: Event) => ev.stopPropagation(),
- },
- {
- eventName: 'ionPopoverActivateTrigger',
- callback: (ev: Event) => popoverEl.presentFromTrigger(ev, true),
- },
- ];
-
- break;
- case 'context-menu':
- triggerCallbacks = [
- {
- eventName: 'contextmenu',
- callback: (ev: Event) => {
- /**
- * Prevents the platform context
- * menu from appearing.
- */
- ev.preventDefault();
- popoverEl.presentFromTrigger(ev);
- },
- },
- {
- eventName: 'click',
- callback: (ev: Event) => ev.stopPropagation(),
- },
- {
- eventName: 'ionPopoverActivateTrigger',
- callback: (ev: Event) => popoverEl.presentFromTrigger(ev, true),
- },
- ];
-
- break;
- case 'click':
- default:
- triggerCallbacks = [
- {
- /**
- * Do not do a stopPropagation() here
- * because if you had two click triggers
- * then clicking the first trigger and then
- * clicking the second trigger would not cause
- * the first popover to dismiss.
- */
- eventName: 'click',
- callback: (ev: Event) => popoverEl.presentFromTrigger(ev),
- },
- {
- eventName: 'ionPopoverActivateTrigger',
- callback: (ev: Event) => popoverEl.presentFromTrigger(ev, true),
- },
- ];
- break;
- }
-
- triggerCallbacks.forEach(({ eventName, callback }) => triggerEl.addEventListener(eventName, callback));
- triggerEl.setAttribute('data-ion-popover-trigger', 'true');
-
- return () => {
- triggerCallbacks.forEach(({ eventName, callback }) => triggerEl.removeEventListener(eventName, callback));
- triggerEl.removeAttribute('data-ion-popover-trigger');
- };
-};
-
-/**
- * Returns the index of an ion-item in an array of ion-items.
- */
-export const getIndexOfItem = (items: HTMLIonItemElement[], item: HTMLElement | null) => {
- if (!item || item.tagName !== 'ION-ITEM') {
- return -1;
- }
-
- return items.findIndex((el) => el === item);
-};
-
-/**
- * Given an array of elements and a currently focused ion-item
- * returns the next ion-item relative to the focused one or
- * undefined.
- */
-export const getNextItem = (items: HTMLIonItemElement[], currentItem: HTMLElement | null) => {
- const currentItemIndex = getIndexOfItem(items, currentItem);
- return items[currentItemIndex + 1];
-};
-
-/**
- * Given an array of elements and a currently focused ion-item
- * returns the previous ion-item relative to the focused one or
- * undefined.
- */
-export const getPrevItem = (items: HTMLIonItemElement[], currentItem: HTMLElement | null) => {
- const currentItemIndex = getIndexOfItem(items, currentItem);
- return items[currentItemIndex - 1];
-};
-
-/** Focus the internal button of the ion-item */
-const focusItem = (item: HTMLIonItemElement) => {
- const root = getElementRoot(item);
- const button = root.querySelector('button');
-
- if (button) {
- raf(() => button.focus());
- }
-};
-
-/**
- * Positions a popover by taking into account
- * the reference point, preferred side, alignment
- * and viewport dimensions.
- */
-export const getPopoverPosition = (
- isRTL: boolean,
- contentWidth: number,
- contentHeight: number,
- reference: PositionReference,
- side: PositionSide,
- align: PositionAlign,
- defaultPosition: PopoverPosition,
- triggerEl?: HTMLElement,
- event?: MouseEvent | CustomEvent,
-): PopoverPosition => {
- let referenceCoordinates = {
- top: 0,
- left: 0,
- width: 0,
- height: 0,
- };
-
- /**
- * Calculate position relative to the
- * x-y coordinates in the event that
- * was passed in
- */
- switch (reference) {
- case 'event':
- if (!event) {
- return defaultPosition;
- }
-
- const mouseEv = event as MouseEvent;
-
- referenceCoordinates = {
- top: mouseEv.clientY,
- left: mouseEv.clientX,
- width: 1,
- height: 1,
- };
-
- break;
-
- /**
- * Calculate position relative to the bounding
- * box on either the trigger element
- * specified via the `trigger` prop or
- * the target specified on the event
- * that was passed in.
- */
- case 'trigger':
- default:
- const customEv = event as CustomEvent;
-
- /**
- * ionShadowTarget is used when we need to align the
- * popover with an element inside of the shadow root
- * of an Ionic component. Ex: Presenting a popover
- * by clicking on the collapsed indicator inside
- * of `ion-breadcrumb` and centering it relative
- * to the indicator rather than `ion-breadcrumb`
- * as a whole.
- */
- const actualTriggerEl = (triggerEl || customEv?.detail?.ionShadowTarget || customEv?.target) as HTMLElement | null;
- if (!actualTriggerEl) {
- return defaultPosition;
- }
- const triggerBoundingBox = actualTriggerEl.getBoundingClientRect();
- referenceCoordinates = {
- top: triggerBoundingBox.top,
- left: triggerBoundingBox.left,
- width: triggerBoundingBox.width,
- height: triggerBoundingBox.height,
- };
-
- break;
- }
-
- /**
- * Get top/left offset that would allow
- * popover to be positioned on the
- * preferred side of the reference.
- */
- const coordinates = calculatePopoverSide(side, referenceCoordinates, contentWidth, contentHeight, isRTL);
-
- /**
- * Get the top/left adjustments that
- * would allow the popover content
- * to have the correct alignment.
- */
- const alignedCoordinates = calculatePopoverAlign(align, side, referenceCoordinates, contentWidth, contentHeight);
-
- const top = coordinates.top + alignedCoordinates.top;
- const left = coordinates.left + alignedCoordinates.left;
-
- const { originX, originY } = calculatePopoverOrigin(side, align, isRTL);
-
- return { top, left, referenceCoordinates, originX, originY };
-};
-
-/**
- * Determines the transform-origin
- * of the popover animation so that it
- * is in line with what the side and alignment
- * prop values are. Currently only used
- * with the MD animation.
- */
-const calculatePopoverOrigin = (side: PositionSide, align: PositionAlign, isRTL: boolean) => {
- switch (side) {
- case 'top':
- return { originX: getOriginXAlignment(align), originY: 'bottom' };
- case 'bottom':
- return { originX: getOriginXAlignment(align), originY: 'top' };
- case 'left':
- return { originX: 'right', originY: getOriginYAlignment(align) };
- case 'right':
- return { originX: 'left', originY: getOriginYAlignment(align) };
- case 'start':
- return { originX: isRTL ? 'left' : 'right', originY: getOriginYAlignment(align) };
- case 'end':
- return { originX: isRTL ? 'right' : 'left', originY: getOriginYAlignment(align) };
- }
-};
-
-const getOriginXAlignment = (align: PositionAlign) => {
- switch (align) {
- case 'start':
- return 'left';
- case 'center':
- return 'center';
- case 'end':
- return 'right';
- }
-};
-
-const getOriginYAlignment = (align: PositionAlign) => {
- switch (align) {
- case 'start':
- return 'top';
- case 'center':
- return 'center';
- case 'end':
- return 'bottom';
- }
-};
-
-/**
- * Calculates the required top/left
- * values needed to position the popover
- * content on the side specified in the
- * `side` prop.
- */
-const calculatePopoverSide = (
- side: PositionSide,
- triggerBoundingBox: ReferenceCoordinates,
- contentWidth: number,
- contentHeight: number,
- isRTL: boolean,
-) => {
- const sideLeft = {
- top: triggerBoundingBox.top,
- left: triggerBoundingBox.left - contentWidth,
- };
- const sideRight = {
- top: triggerBoundingBox.top,
- left: triggerBoundingBox.left + triggerBoundingBox.width,
- };
-
- switch (side) {
- case 'top':
- return {
- top: triggerBoundingBox.top - contentHeight,
- left: triggerBoundingBox.left,
- };
- case 'right':
- return sideRight;
- case 'bottom':
- return {
- top: triggerBoundingBox.top + triggerBoundingBox.height,
- left: triggerBoundingBox.left,
- };
- case 'left':
- return sideLeft;
- case 'start':
- return isRTL ? sideRight : sideLeft;
- case 'end':
- return isRTL ? sideLeft : sideRight;
- }
-};
-
-/**
- * Calculates the required top/left
- * offset values needed to provide the
- * correct alignment regardless while taking
- * into account the side the popover is on.
- */
-const calculatePopoverAlign = (
- align: PositionAlign,
- side: PositionSide,
- triggerBoundingBox: ReferenceCoordinates,
- contentWidth: number,
- contentHeight: number,
-) => {
- switch (align) {
- case 'center':
- return calculatePopoverCenterAlign(side, triggerBoundingBox, contentWidth, contentHeight);
- case 'end':
- return calculatePopoverEndAlign(side, triggerBoundingBox, contentWidth, contentHeight);
- case 'start':
- default:
- return { top: 0, left: 0 };
- }
-};
-
-/**
- * Calculate the end alignment for
- * the popover. If side is on the x-axis
- * then the align values refer to the top
- * and bottom margins of the content.
- * If side is on the y-axis then the
- * align values refer to the left and right
- * margins of the content.
- */
-const calculatePopoverEndAlign = (
- side: PositionSide,
- triggerBoundingBox: ReferenceCoordinates,
- contentWidth: number,
- contentHeight: number,
-) => {
- switch (side) {
- case 'start':
- case 'end':
- case 'left':
- case 'right':
- return {
- top: -(contentHeight - triggerBoundingBox.height),
- left: 0,
- };
- case 'top':
- case 'bottom':
- default:
- return {
- top: 0,
- left: -(contentWidth - triggerBoundingBox.width),
- };
- }
-};
-
-/**
- * Calculate the center alignment for
- * the popover. If side is on the x-axis
- * then the align values refer to the top
- * and bottom margins of the content.
- * If side is on the y-axis then the
- * align values refer to the left and right
- * margins of the content.
- */
-const calculatePopoverCenterAlign = (
- side: PositionSide,
- triggerBoundingBox: ReferenceCoordinates,
- contentWidth: number,
- contentHeight: number,
-) => {
- switch (side) {
- case 'start':
- case 'end':
- case 'left':
- case 'right':
- return {
- top: -(contentHeight / 2 - triggerBoundingBox.height / 2),
- left: 0,
- };
- case 'top':
- case 'bottom':
- default:
- return {
- top: 0,
- left: -(contentWidth / 2 - triggerBoundingBox.width / 2),
- };
- }
-};
-
-/**
- * Adjusts popover positioning coordinates
- * such that popover does not appear offscreen
- * or overlapping safe area bounds.
- */
-export const calculateWindowAdjustment = (
- side: PositionSide,
- coordTop: number,
- coordLeft: number,
- bodyPadding: number,
- bodyWidth: number,
- bodyHeight: number,
- contentWidth: number,
- contentHeight: number,
- safeAreaMargin: number,
- contentOriginX: string,
- contentOriginY: string,
- triggerCoordinates?: ReferenceCoordinates,
- eventElementRect?: DOMRect,
- isReplace: boolean = false,
-): PopoverStyles => {
- const triggerTop = triggerCoordinates ? triggerCoordinates.top + triggerCoordinates.height : bodyHeight / 2 - contentHeight / 2;
- const triggerHeight = triggerCoordinates ? triggerCoordinates.height : 0;
- let left = coordLeft;
- let top = !isReplace ? coordTop + POPOVER_IOS_BODY_MARGIN : coordTop - triggerHeight;
- let bottom;
- let originX = contentOriginX;
- let originY = contentOriginY;
- let checkSafeAreaLeft = false;
- let checkSafeAreaRight = false;
- let addPopoverBottomClass = false;
-
- /**
- * Adjust popover so it does not
- * go off the left of the screen.
- */
- if (left < bodyPadding + safeAreaMargin) {
- left = !eventElementRect ? bodyPadding : eventElementRect.left;
- if (left === 0) {
- left = safeAreaMargin;
- }
- checkSafeAreaLeft = true;
- originX = 'left';
- /**
- * Adjust popover so it does not
- * go off the right of the screen.
- */
- } else if (contentWidth + bodyPadding + left + safeAreaMargin > bodyWidth) {
- checkSafeAreaRight = true;
- left = !eventElementRect ? bodyWidth - contentWidth - bodyPadding : eventElementRect.right - contentWidth;
- if (left + contentWidth === bodyWidth) {
- left = left - safeAreaMargin;
- }
- originX = 'right';
- }
-
- /**
- * Adjust popover so it does not
- * go off the top of the screen.
- * If popover is on the left or the right of
- * the trigger, then we should not adjust top
- * margins.
- */
- const compareTop = triggerCoordinates ? triggerCoordinates.top + triggerCoordinates.height / 2 : bodyHeight / 2 - contentHeight / 2;
- if (compareTop > bodyHeight / 2 && (side === 'top' || side === 'bottom')) {
- if (triggerTop - contentHeight > 0) {
- /**
- * While we strive to align the popover with the trigger
- * on smaller screens this is not always possible. As a result,
- * we adjust the popover up so that it does not hang
- * off the bottom of the screen. However, we do not want to move
- * the popover up so much that it goes off the top of the screen.
- *
- * We chose 12 here so that the popover position looks a bit nicer as
- * it is not right up against the edge of the screen.
- */
- if (!isReplace) {
- top = Math.max(12, triggerTop - contentHeight - triggerHeight) - POPOVER_IOS_BODY_MARGIN;
- } else {
- top = Math.max(12, triggerTop - contentHeight);
- }
- originY = 'bottom';
- addPopoverBottomClass = true;
-
- /**
- * If not enough room for popover to appear
- * above trigger, then cut it off.
- */
- } else {
- bottom = bodyPadding;
- }
- }
-
- return {
- top,
- left,
- bottom,
- originX,
- originY,
- checkSafeAreaLeft,
- checkSafeAreaRight,
- addPopoverBottomClass,
- };
-};
diff --git a/src/styles/components/ion-list.scss b/src/styles/components/ion-list.scss
index e20aac09..2b6117bd 100644
--- a/src/styles/components/ion-list.scss
+++ b/src/styles/components/ion-list.scss
@@ -1,4 +1,4 @@
-@use '../utils/structured-list';
+@use 'pkg:@rdlabo/ionic-theme-utils/styles/structured-list';
ion-list.ios:not(.ios-theme-disabled, .ios26-disabled) {
--ion-border-color: var(--ion-color-step-100, var(--ion-background-color-step-100, #e8e8e8));
@@ -11,7 +11,7 @@ ion-list.ios:not(.ios-theme-disabled, .ios26-disabled) {
}
ion-list.list-inset.ios:not(.ios-theme-disabled, .ios26-disabled) {
- @include structured-list.layout(20px, 28px);
+ @include structured-list.layout(20px, 28px, $variant: ios27);
ion-radio-group,
ion-reorder-group {
diff --git a/src/styles/md-ion-list-inset.scss b/src/styles/md-ion-list-inset.scss
index 371a4474..05faa68e 100644
--- a/src/styles/md-ion-list-inset.scss
+++ b/src/styles/md-ion-list-inset.scss
@@ -1,10 +1,10 @@
-@use 'utils/structured-list';
+@use 'pkg:@rdlabo/ionic-theme-utils/styles/structured-list';
ion-list.md {
@include structured-list.support-text;
&.list-inset {
- @include structured-list.layout(8px, 16px);
+ @include structured-list.layout(8px, 16px, $variant: ios27);
:is(#{structured-list.$groups}) ion-item {
& > ion-input[labelplacement='floating'] {
transition: transform 200ms ease;
diff --git a/src/styles/utils/dark/ion-list.scss b/src/styles/utils/dark/ion-list.scss
index d21e12d4..8a88cf50 100644
--- a/src/styles/utils/dark/ion-list.scss
+++ b/src/styles/utils/dark/ion-list.scss
@@ -1,4 +1,4 @@
-@use '../structured-list';
+@use 'pkg:@rdlabo/ionic-theme-utils/styles/structured-list';
@mixin ion-list {
ion-list.ios:not(.ios-theme-disabled, .ios26-disabled) {
diff --git a/src/styles/utils/structured-list.scss b/src/styles/utils/structured-list.scss
deleted file mode 100644
index 09bdeaf7..00000000
--- a/src/styles/utils/structured-list.scss
+++ /dev/null
@@ -1,223 +0,0 @@
-@use './group-heading';
-
-$groups: ion-item-group, ion-reorder-group, ion-accordion-group, ion-radio-group;
-
-// Derive neutral list colors from the application's Ionic medium palette.
-@mixin colors($supporting-opacity, $detail-opacity) {
- ion-item {
- --detail-icon-color: rgb(var(--ion-color-medium-rgb));
- --detail-icon-opacity: #{$detail-opacity};
- }
-
- &.list-inset {
- ion-list-header ion-label:not(.ion-color) {
- color: rgba(var(--ion-color-medium-rgb), $supporting-opacity);
- }
-
- ion-list-header ion-note:not(.ion-color),
- > ion-note:not(.ion-color) {
- --color: rgba(var(--ion-color-medium-rgb), #{$supporting-opacity});
- }
- }
-}
-
-@mixin support-text() {
- ion-item {
- &:has(ion-input.input-fill-outline),
- &:has(ion-textarea.textarea-fill-outline),
- &:has(ion-select:is([helpertext]:not([helpertext='']), [errortext]:not([errortext='']))),
- &:has(ion-input .input-bottom > :is(.helper-text:not(:empty), .error-text:not(:empty), .counter:not(:empty))),
- &:has(ion-textarea .textarea-bottom > :is(.helper-text:not(:empty), .error-text:not(:empty), .counter:not(:empty))) {
- --inner-border-width: 0;
- --inner-padding-bottom: 4px;
- }
-
- ion-input .input-bottom:not(:has(> :is(.helper-text:not(:empty), .error-text:not(:empty), .counter:not(:empty)))),
- ion-textarea .textarea-bottom:not(:has(> :is(.helper-text:not(:empty), .error-text:not(:empty), .counter:not(:empty)))) {
- display: none;
- }
- }
-}
-
-@mixin layout($outer-note-inset, $group-border-radius) {
- background: transparent;
-
- ion-list-header {
- display: flex;
- align-items: center;
-
- ion-label {
- font-size: 1rem;
- line-height: 1.29rem;
- font-weight: 600;
- margin: 8px 4px 4px;
-
- &:not(.ion-color) {
- color: var(--ion-color-medium-tint);
- }
- }
-
- ion-button {
- margin-top: 8px;
- }
-
- ion-note {
- font-size: 0.76rem;
- line-height: 1.06rem;
- font-weight: 400;
- margin: 8px 16px 4px 0;
-
- &:not(.ion-color) {
- --color: var(--ion-color-medium-tint);
- }
- }
- }
-
- ion-item-group.item-group-header {
- border-radius: $group-border-radius;
-
- > ion-item {
- --inner-padding-end: 0;
- --inner-border-width: 0;
-
- &::part(native) {
- padding-inline: 0;
- }
- }
-
- & > ion-item > ion-label {
- width: 100%;
- margin: 0;
- text-align: start;
- white-space: normal;
- padding: 16px 20px;
-
- /**
- * ion-header.header-collapse-condenseを使って、スクロールによってHeaderを変化させるトリック
- */
- ion-header.header-collapse-condense > ion-toolbar {
- --min-height: 0;
- visibility: hidden;
- }
-
- ion-icon {
- font-size: 2.82rem;
- border-radius: 20%;
- padding: 6px;
- vertical-align: top;
-
- &:not(.ion-color) {
- color: #ffffff;
- }
- }
-
- h2 {
- @include group-heading.typography;
- margin: 20px 0 8px;
- }
-
- ion-text {
- display: block;
- font-size: 1rem;
- line-height: 1.29rem;
-
- &:not(.ion-color) {
- color: var(--ion-color-step-500, var(--ion-text-color-step-500, #808080));
- }
- }
- }
- }
-
- > :is(#{$groups}) {
- display: block;
- border-radius: $group-border-radius;
- overflow: hidden;
-
- &:has(> .radio-group-top) {
- display: flex;
- flex-direction: column;
- border-radius: 0;
- overflow: visible;
-
- > .radio-group-top {
- order: 1;
- font-size: 0.9rem;
- padding-block-start: 8px;
- }
-
- > ion-item:first-of-type {
- --border-radius: #{$group-border-radius} #{$group-border-radius} 0 0;
- }
-
- > ion-item:last-of-type {
- --border-radius: 0 0 #{$group-border-radius} #{$group-border-radius};
- }
- }
-
- &:not(:first-of-type) {
- margin-top: 16px;
- }
-
- ion-item {
- &:has(> ion-label:not([slot])):has(> ion-note:not([slot])) {
- &::part(container) {
- flex-direction: column;
- justify-content: center;
- padding-top: 14px;
- padding-bottom: 14px;
- min-height: 64px;
- }
- & > ion-label,
- & > ion-note {
- width: auto;
- align-self: flex-start;
- }
- & > ion-label {
- margin: 0;
- font-weight: 500;
- font-size: 1rem;
- line-height: 1.29rem;
- }
- & > ion-note {
- font-size: 0.88rem;
- line-height: 1.18rem;
- }
- }
-
- ion-text[slot='end'] {
- padding-left: 8px;
- }
-
- &.item-disabled {
- --detail-icon-opacity: 0.1;
- opacity: 1;
-
- & > * {
- opacity: 0.4;
- }
- }
-
- ion-button[slot='end'] {
- &.ion-align-self-end {
- transform: translateY(-7px);
- }
- ion-icon[slot='icon-only'] {
- font-size: 1.2rem;
- transform: translateY(4px);
- }
- }
- }
- }
-
- & > ion-note {
- font-size: 0.76rem;
- line-height: 1.06rem;
- font-weight: 400;
- display: block;
- margin: 8px calc(var(--ion-safe-area-right, 0) + $outer-note-inset) 8px calc(var(--ion-safe-area-left, 0) + $outer-note-inset);
-
- &:not(.ion-color) {
- --color: var(--ion-color-medium-tint);
- }
- }
-}
diff --git a/src/tab-bar-searchable/animations/enter.ts b/src/tab-bar-searchable/animations/enter.ts
index 9324afdd..0fedd804 100644
--- a/src/tab-bar-searchable/animations/enter.ts
+++ b/src/tab-bar-searchable/animations/enter.ts
@@ -1,7 +1,6 @@
-import { ElementReferences, ElementSizes } from '../interfaces';
import { Animation, createAnimation } from '@ionic/core';
+import { ANIMATION_DELAY_CLOSE_BUTTONS, OPACITY_TRANSITION, type ElementReferences, type ElementSizes } from '@rdlabo/ionic-theme-utils';
import { cloneElement } from '../../utils';
-import { ANIMATION_DELAY_CLOSE_BUTTONS, OPACITY_TRANSITION } from '../utils';
export const createEffectAnimation = (references: ElementReferences, sizes: ElementSizes): Animation => {
const effectElement = cloneElement('ion-icon');
diff --git a/src/tab-bar-searchable/animations/leave.ts b/src/tab-bar-searchable/animations/leave.ts
index 41050a6d..bdfa20f2 100644
--- a/src/tab-bar-searchable/animations/leave.ts
+++ b/src/tab-bar-searchable/animations/leave.ts
@@ -1,7 +1,6 @@
-import { ElementReferences, ElementSizes } from '../interfaces';
import { Animation, createAnimation } from '@ionic/core';
+import { ANIMATION_DELAY_CLOSE_BUTTONS, OPACITY_TRANSITION, type ElementReferences, type ElementSizes } from '@rdlabo/ionic-theme-utils';
import { cloneElement } from '../../utils';
-import { ANIMATION_DELAY_CLOSE_BUTTONS, OPACITY_TRANSITION } from '../utils';
export const createReverseEffectAnimation = (
references: ElementReferences,
diff --git a/src/tab-bar-searchable/index.ts b/src/tab-bar-searchable/index.ts
index 0456d478..fa791fc7 100644
--- a/src/tab-bar-searchable/index.ts
+++ b/src/tab-bar-searchable/index.ts
@@ -1,5 +1,4 @@
import { createAnimation } from '@ionic/core';
-import { isNativeUIShell, registerNativeSearch, requestNativeSearch, suspendNativeUIShell } from '../native-integration';
import {
ANIMATION_DELAY_BASE,
ANIMATION_DURATION,
@@ -7,8 +6,11 @@ import {
getElementReferences,
getElementSizes,
throwErrorByFailedClickElement,
-} from './utils';
-import { SearchableEventCache, TabBarSearchableFunction, TabBarSearchableType } from './interfaces';
+ type SearchableEventCache,
+ type TabBarSearchableFunction,
+ TabBarSearchableType,
+} from '@rdlabo/ionic-theme-utils';
+import { isNativeUIShell, registerNativeSearch, requestNativeSearch, suspendNativeUIShell } from '../native-integration';
import {
createCloseButtonsAnimation,
createEffectAnimation,
@@ -24,7 +26,13 @@ import {
createReverseTabBarAnimation,
} from './animations/leave';
-export * from './interfaces';
+export {
+ TabBarSearchableType,
+ type TabBarSearchableFunction,
+ type SearchableEventCache,
+ type ElementSizes,
+ type ElementReferences,
+} from '@rdlabo/ionic-theme-utils';
/**
*
diff --git a/src/tab-bar-searchable/interfaces.ts b/src/tab-bar-searchable/interfaces.ts
deleted file mode 100644
index effaf012..00000000
--- a/src/tab-bar-searchable/interfaces.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-export enum TabBarSearchableType {
- Enter = 'enter',
- Leave = 'leave',
-}
-
-export type TabBarSearchableFunction = (event: Event, type: TabBarSearchableType) => Promise;
-
-export interface SearchableEventCache {
- elementSizes: ElementSizes;
- colorSelected: string;
-}
-
-// DOM要素とサイズ情報の型定義
-export interface ElementSizes {
- tabBar: { width: number; height: number };
- closeButton: { width: number; height: number };
- fabButton: { width: number; height: number };
- searchContainer: { width: number; height: number };
- selectedTabButtonIcon: { width: number; height: number; top: number; left: number };
-}
-
-export interface ElementReferences {
- searchContainer: HTMLElement;
- closeButtons: HTMLElement;
- selectedTabButton: HTMLElement;
- selectedTabButtonIcon: HTMLElement;
- closeButtonIcon: HTMLElement;
-}
diff --git a/src/tab-bar-searchable/utils.ts b/src/tab-bar-searchable/utils.ts
deleted file mode 100644
index e4f53631..00000000
--- a/src/tab-bar-searchable/utils.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { ElementReferences, ElementSizes } from './interfaces';
-
-export const ANIMATION_DURATION = 400;
-export const ANIMATION_DELAY_BASE = 140;
-export const ANIMATION_DELAY_CLOSE_BUTTONS = 240;
-export const ANIMATION_EASING = 'cubic-bezier(0, 1, 0.22, 1)';
-export const OPACITY_TRANSITION = 'opacity 140ms ease';
-
-export const throwErrorByFailedClickElement = (selector: string): Error => {
- return new Error('Expected click element to be inside `' + selector + '`');
-};
-
-export const throwErrorByFailedExistElement = (selector: string): Error => {
- return new Error('Expected element `' + selector + '` to exist');
-};
-
-export const getElement = (docs: HTMLElement, selector: string): HTMLElement => {
- const el = docs.querySelector(selector);
- if (!el) {
- throw throwErrorByFailedClickElement(selector);
- }
- return el;
-};
-
-export const getElementReferences = (ionTabBar: HTMLElement, ionFooter: HTMLElement): ElementReferences => {
- const searchContainer = getElement(ionFooter, 'ion-searchbar .searchbar-input-container');
- const closeButtons = getElement(ionFooter, 'ion-buttons[slot=start]');
- const selectedTabButton = ionTabBar.querySelector('ion-tab-button.tab-selected');
- const selectedTabButtonIcon = selectedTabButton?.querySelector('ion-icon');
- const closeButtonIcon = closeButtons.querySelector('ion-icon');
-
- if (!selectedTabButton) {
- throw throwErrorByFailedExistElement('ion-tab-button.tab-selected');
- }
-
- if (!selectedTabButtonIcon) {
- throw throwErrorByFailedExistElement('ion-tab-button.tab-selected ion-icon');
- }
-
- if (!closeButtonIcon) {
- throw throwErrorByFailedExistElement('ion-buttons[slot=start] ion-button ion-icon');
- }
-
- return {
- searchContainer,
- closeButtons,
- selectedTabButton,
- selectedTabButtonIcon,
- closeButtonIcon,
- };
-};
-
-/**
- * 各要素のサイズ情報を取得
- */
-export const getElementSizes = (ionTabBar: HTMLElement, ionFabButton: HTMLElement, references: ElementReferences): ElementSizes => {
- const tabBarRect = ionTabBar.getBoundingClientRect();
- const fabButtonRect = ionFabButton.getBoundingClientRect();
- const closeButtonRect = references.closeButtons.getBoundingClientRect();
- const searchContainerRect = references.searchContainer.getBoundingClientRect();
- const selectedTabButtonIconRect = references.selectedTabButtonIcon!.getBoundingClientRect();
-
- return {
- tabBar: { width: tabBarRect.width, height: tabBarRect.height },
- closeButton: { width: closeButtonRect.width, height: closeButtonRect.height },
- fabButton: { width: fabButtonRect.width, height: fabButtonRect.height },
- searchContainer: { width: searchContainerRect.width, height: searchContainerRect.height },
- selectedTabButtonIcon: {
- width: selectedTabButtonIconRect.width,
- height: selectedTabButtonIconRect.height,
- top: selectedTabButtonIconRect.top,
- left: selectedTabButtonIconRect.left,
- },
- };
-};
diff --git a/src/transition/ios.transition.ts b/src/transition/ios.transition.ts
index 6ccc450b..14bd7853 100644
--- a/src/transition/ios.transition.ts
+++ b/src/transition/ios.transition.ts
@@ -1,534 +1,14 @@
+import { createIosTransitionAnimation, shadow } from '@rdlabo/ionic-theme-utils';
import type { Animation } from '@ionic/core';
-import { createAnimation } from '@ionic/core';
import type { TransitionOptions } from './index';
import { getIonPageElement } from './index';
import { connectNativeUIShellTransition } from '../native-integration';
-const DURATION = 540;
+export { shadow };
-const getClonedElement = (tagName: string) => {
- return document.querySelector(`${tagName}.ion-cloned-element`);
-};
-
-export const shadow = (el: T): ShadowRoot | T => {
- return el.shadowRoot || el;
-};
-
-const animateFixedBackButton = (
- root: Animation,
- navEl: HTMLElement,
- page: HTMLElement,
- entering: boolean,
- interactive: boolean,
- otherPage?: HTMLElement,
-) => {
- const button = page.querySelector(
- ':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled) ion-back-button:not(.ios-theme-disabled, .ios26-disabled)',
- );
- if (!button || button.offsetWidth === 0) {
- return;
- }
-
- const otherButton = otherPage?.querySelector(
- ':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled) ion-back-button:not(.ios-theme-disabled, .ios26-disabled)',
- );
- const persistent = !!otherButton && otherButton.offsetWidth > 0;
- const buttons = [button, ...(persistent ? [otherButton!] : [])].map((element) => ({
- element,
- visibility: element.style.visibility,
- }));
- const rect = button.getBoundingClientRect();
- const width = button.offsetWidth;
- const height = button.offsetHeight;
- const clone = getClonedElement('ion-back-button');
- if (!clone) {
- return;
- }
- const cloneParent = clone.parentNode!;
- const cloneNextSibling = clone.nextSibling;
- const cloneStyle = clone.getAttribute('style');
- clone.icon = button.icon;
- clone.text = button.text;
- clone.mode = button.mode;
- clone.color = button.color;
- clone.disabled = button.disabled;
- const icon = shadow(clone).querySelector('ion-icon');
- const animation = createAnimation().addElement(clone);
- const fadeStart = !entering && interactive ? 0.8 : 0.4;
- const fadeEnd = entering ? 0.96 : interactive ? 1 : 0.95;
- if (persistent) {
- animation.fromTo('transform', 'scale(1)', 'scale(1)').fromTo('opacity', 1, 1);
- } else if (entering) {
- animation.keyframes([
- { offset: 0, transform: 'scale(1.2)', opacity: 0 },
- { offset: fadeStart, transform: 'scale(1.2)', opacity: 0 },
- { offset: 0.65, transform: 'scale(1.12)', opacity: 0.15 },
- { offset: 0.9, transform: 'scale(1.02)', opacity: 0.75 },
- { offset: fadeEnd, transform: 'scale(1)', opacity: 1 },
- { offset: 1, transform: 'scale(1)', opacity: 1 },
- ]);
- } else {
- animation.keyframes([
- { offset: 0, transform: 'scale(1)', opacity: 1 },
- { offset: fadeStart, transform: 'scale(1)', opacity: 1 },
- { offset: fadeEnd, transform: 'scale(1.2)', opacity: 0 },
- { offset: 1, transform: 'scale(1.2)', opacity: 0 },
- ]);
- }
- if (icon && !persistent) {
- const from = entering ? 'blur(4px)' : 'blur(0px)';
- const to = entering ? 'blur(0px)' : 'blur(4px)';
- animation.addAnimation(
- createAnimation()
- .addElement(icon)
- .keyframes([
- { offset: 0, filter: from },
- { offset: fadeStart, filter: from },
- { offset: fadeEnd, filter: to },
- { offset: 1, filter: to },
- ]),
- );
- }
- root.beforeAddWrite(() => {
- Object.assign(clone.style, {
- position: 'fixed',
- left: `${rect.left + (rect.width - width) / 2}px`,
- top: `${rect.top + (rect.height - height) / 2}px`,
- width: `${width}px`,
- height: `${height}px`,
- margin: '0',
- pointerEvents: 'none',
- visibility: 'visible',
- display: getComputedStyle(button).display,
- zIndex: '1000',
- });
- navEl.appendChild(clone);
- buttons.forEach(({ element }) => (element.style.visibility = 'hidden'));
+export const iosTransitionAnimation: (navEl: HTMLElement, opts: TransitionOptions) => Animation =
+ createIosTransitionAnimation({
+ offLeftPercent: 30,
+ getIonPageElement,
+ connectNativeUIShellTransition,
});
- root.afterAddWrite(() => {
- buttons.forEach(({ element, visibility }) => (element.style.visibility = visibility));
- cloneParent.insertBefore(clone, cloneNextSibling);
- if (cloneStyle === null) {
- clone.removeAttribute('style');
- } else {
- clone.setAttribute('style', cloneStyle);
- }
- });
- root.addAnimation(animation);
-};
-
-export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptions): Animation => {
- try {
- const EASING = 'cubic-bezier(0.32,0.72,0,1)';
- const OPACITY = 'opacity';
- const TRANSFORM = 'transform';
- const CENTER = '0%';
- const OFF_OPACITY = 0.8;
-
- const isRTL = navEl.ownerDocument.dir === 'rtl';
- const OFF_RIGHT = isRTL ? '-99.5%' : '99.5%';
- const OFF_LEFT = isRTL ? '33%' : '-33%';
-
- const enteringEl = opts.enteringEl;
- const leavingEl = opts.leavingEl;
-
- const backDirection = opts.direction === 'back';
- const contentEl = enteringEl.querySelector(':scope > ion-content');
- const headerEls = enteringEl.querySelectorAll(':scope > ion-header > *:not(ion-toolbar), :scope > ion-footer > *');
- const enteringToolBarEls = enteringEl.querySelectorAll(':scope > ion-header > ion-toolbar');
-
- const rootAnimation = createAnimation();
- const enteringContentAnimation = createAnimation();
-
- rootAnimation
- .addElement(enteringEl)
- .duration((opts.duration ?? 0) || DURATION)
- .easing(opts.easing || EASING)
- .fill('both')
- .beforeRemoveClass('ion-page-invisible');
-
- const topPage = backDirection ? leavingEl : enteringEl;
- const translucentTop = topPage?.querySelector(':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled)');
- if (topPage && translucentTop) {
- // UIKit dims the opaque page underneath, including its header, with 10% black.
- const shade = navEl.ownerDocument.createElement('div');
- shade.className = 'ios27-transition-shade';
- shade.setAttribute('aria-hidden', 'true');
- const edge = navEl.ownerDocument.createElement('div');
- edge.setAttribute('aria-hidden', 'true');
- const clipPath = topPage.style.clipPath;
- // UIKit's continuous page outline is smaller in tablet-sized panes.
- const [corner, control] = topPage.offsetWidth >= 768 ? [36, 13] : [78, 22];
- Object.assign(shade.style, {
- position: 'absolute',
- inset: '0',
- zIndex: topPage.style.zIndex,
- pointerEvents: 'none',
- background: 'rgba(0, 0, 0, 0.1)',
- });
- Object.assign(edge.style, {
- position: 'absolute',
- top: '0',
- bottom: '0',
- [isRTL ? 'right' : 'left']: '-100%',
- width: '100%',
- zIndex: topPage.style.zIndex,
- pointerEvents: 'none',
- background: `linear-gradient(to ${isRTL ? 'right' : 'left'}, rgba(0, 0, 0, 0.02), transparent 18px)`,
- });
- rootAnimation.beforeAddWrite(() => {
- topPage.before(shade, edge);
- topPage.style.clipPath = `inset(0 round ${corner * 0.82}px)`;
- topPage.style.clipPath = `shape(from 0px ${corner}px,
- curve to ${corner}px 0px with 0px ${control}px / ${control}px 0px, hline to calc(100% - ${corner}px),
- curve to 100% ${corner}px with calc(100% - ${control}px) 0px / 100% ${control}px, vline to calc(100% - ${corner}px),
- curve to calc(100% - ${corner}px) 100% with 100% calc(100% - ${control}px) / calc(100% - ${control}px) 100%, hline to ${corner}px,
- curve to 0px calc(100% - ${corner}px) with ${control}px 100% / 0px calc(100% - ${control}px), close)`;
- });
- rootAnimation.afterAddWrite(() => {
- shade.remove();
- edge.remove();
- topPage.style.clipPath = clipPath;
- });
- rootAnimation.addAnimation(
- createAnimation()
- .addElement(shade)
- .fromTo(OPACITY, backDirection ? 1 : 0, backDirection ? 0 : 1),
- );
- rootAnimation.addAnimation(
- createAnimation()
- .addElement(edge)
- .fromTo(
- TRANSFORM,
- `translateX(${backDirection ? CENTER : OFF_RIGHT})`,
- `translateX(${backDirection ? (isRTL ? '-100%' : '100%') : CENTER})`,
- ),
- );
- }
-
- // eslint-disable-next-line @typescript-eslint/prefer-optional-chain
- if (leavingEl && navEl !== null && navEl !== undefined) {
- const navDecorAnimation = createAnimation();
- navDecorAnimation.addElement(navEl);
- rootAnimation.addAnimation(navDecorAnimation);
- }
-
- if (enteringEl.querySelector(':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled)')) {
- enteringContentAnimation.addElement(enteringEl);
- } else if (!contentEl && enteringToolBarEls.length === 0 && headerEls.length === 0) {
- enteringContentAnimation.addElement(enteringEl.querySelector(':scope > .ion-page, :scope > ion-nav, :scope > ion-tabs')!); // REVIEW
- } else {
- enteringContentAnimation.addElement(contentEl!); // REVIEW
- enteringContentAnimation.addElement(headerEls);
- }
-
- rootAnimation.addAnimation(enteringContentAnimation);
-
- if (backDirection) {
- enteringContentAnimation
- .beforeClearStyles([OPACITY])
- .fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`)
- .fromTo(OPACITY, translucentTop ? 1 : OFF_OPACITY, 1);
- } else {
- // entering content, forward direction
- enteringContentAnimation.beforeClearStyles([OPACITY]).fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`);
- }
-
- if (contentEl && !enteringEl.querySelector(':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled)')) {
- const enteringTransitionEffectEl = shadow(contentEl).querySelector('.transition-effect');
- if (enteringTransitionEffectEl) {
- const enteringTransitionCoverEl = enteringTransitionEffectEl.querySelector('.transition-cover');
- const enteringTransitionShadowEl = enteringTransitionEffectEl.querySelector('.transition-shadow');
-
- const enteringTransitionEffect = createAnimation();
- const enteringTransitionCover = createAnimation();
- const enteringTransitionShadow = createAnimation();
-
- enteringTransitionEffect
- .addElement(enteringTransitionEffectEl)
- .beforeStyles({ opacity: '1', display: 'block' })
- .afterStyles({ opacity: '', display: '' });
-
- enteringTransitionCover
- .addElement(enteringTransitionCoverEl!) // REVIEW
- .beforeClearStyles([OPACITY])
- .fromTo(OPACITY, 0, 0.1);
-
- enteringTransitionShadow
- .addElement(enteringTransitionShadowEl!) // REVIEW
- .beforeClearStyles([OPACITY])
- .fromTo(OPACITY, 0.03, 0.7);
-
- enteringTransitionEffect.addAnimation([enteringTransitionCover, enteringTransitionShadow]);
- enteringContentAnimation.addAnimation([enteringTransitionEffect]);
- }
- }
-
- if (topPage) {
- animateFixedBackButton(
- rootAnimation,
- navEl,
- topPage,
- !backDirection,
- opts.progressCallback !== undefined,
- backDirection ? enteringEl : leavingEl,
- );
- }
-
- const enteringContentHasLargeTitle = enteringEl.querySelector('ion-header.header-collapse-condense');
-
- enteringToolBarEls.forEach((enteringToolBarEl) => {
- if (enteringToolBarEl.closest('ion-header')?.matches('.header-translucent:not(.ios-theme-disabled, .ios26-disabled)')) {
- return;
- }
- const enteringToolBar = createAnimation();
- enteringToolBar.addElement(enteringToolBarEl);
- rootAnimation.addAnimation(enteringToolBar);
-
- const enteringTitle = createAnimation();
- enteringTitle.addElement(enteringToolBarEl.querySelector('ion-title')!); // REVIEW
-
- const enteringToolBarButtons = createAnimation();
- const buttons = Array.from(enteringToolBarEl.querySelectorAll('ion-buttons,[menuToggle]'));
-
- const parentHeader = enteringToolBarEl.closest('ion-header');
- const inactiveHeader = parentHeader?.classList.contains('header-collapse-condense-inactive');
-
- let buttonsToAnimate;
- if (backDirection) {
- buttonsToAnimate = buttons.filter((button) => {
- const isCollapseButton = button.classList.contains('buttons-collapse');
- return (isCollapseButton && !inactiveHeader) || !isCollapseButton;
- });
- } else {
- buttonsToAnimate = buttons.filter((button) => !button.classList.contains('buttons-collapse'));
- }
-
- enteringToolBarButtons.addElement(buttonsToAnimate);
-
- const enteringToolBarItems = createAnimation();
- enteringToolBarItems.addElement(enteringToolBarEl.querySelectorAll(':scope > *:not(ion-title):not(ion-buttons):not([menuToggle])'));
-
- const enteringToolBarBg = createAnimation();
- enteringToolBarBg.addElement(shadow(enteringToolBarEl).querySelector('.toolbar-background')!); // REVIEW
-
- const enteringBackButton = createAnimation();
- const backButtonEl = enteringToolBarEl.querySelector('ion-back-button');
-
- if (backButtonEl) {
- enteringBackButton.addElement(backButtonEl);
- }
-
- enteringToolBar.addAnimation([enteringTitle, enteringToolBarButtons, enteringToolBarItems, enteringToolBarBg, enteringBackButton]);
- enteringToolBarButtons.fromTo(OPACITY, 0.01, 1);
- enteringToolBarItems.fromTo(OPACITY, 0.01, 1);
-
- if (backDirection) {
- if (!inactiveHeader) {
- enteringTitle.fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`).fromTo(OPACITY, 0.01, 1);
- }
-
- enteringToolBarItems.fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`);
-
- // back direction, entering page has a back button
- enteringBackButton.fromTo(OPACITY, 0.01, 1);
- } else {
- // entering toolbar, forward direction
- if (!enteringContentHasLargeTitle) {
- enteringTitle.fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`).fromTo(OPACITY, 0.01, 1);
- }
-
- enteringToolBarItems.fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`);
- enteringToolBarBg.beforeClearStyles([OPACITY, 'transform']);
-
- const translucentHeader = parentHeader?.translucent;
- if (!translucentHeader) {
- enteringToolBarBg.fromTo(OPACITY, 0.01, 'var(--opacity)');
- } else {
- enteringToolBarBg.fromTo('transform', isRTL ? 'translateX(-100%)' : 'translateX(100%)', 'translateX(0px)');
- }
-
- // forward direction, entering page has a back button
- enteringBackButton.fromTo(OPACITY, 0.01, 1);
-
- if (backButtonEl) {
- const enteringBackBtnText = createAnimation();
- enteringBackBtnText
- .addElement(shadow(backButtonEl).querySelector('.button-text')!) // REVIEW
- .fromTo(`transform`, isRTL ? 'translateX(-100px)' : 'translateX(100px)', 'translateX(0px)');
-
- enteringToolBar.addAnimation(enteringBackBtnText);
- }
- }
- });
-
- // setup leaving view
- if (leavingEl) {
- const leavingContent = createAnimation();
- const leavingContentEl = leavingEl.querySelector(':scope > ion-content');
- const leavingToolBarEls = leavingEl.querySelectorAll(':scope > ion-header > ion-toolbar');
- const leavingHeaderEls = leavingEl.querySelectorAll(':scope > ion-header > *:not(ion-toolbar), :scope > ion-footer > *');
-
- if (leavingEl.querySelector(':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled)')) {
- leavingContent.addElement(leavingEl);
- } else if (!leavingContentEl && leavingToolBarEls.length === 0 && leavingHeaderEls.length === 0) {
- leavingContent.addElement(leavingEl.querySelector(':scope > .ion-page, :scope > ion-nav, :scope > ion-tabs')!); // REVIEW
- } else {
- leavingContent.addElement(leavingContentEl!); // REVIEW
- leavingContent.addElement(leavingHeaderEls);
- }
-
- rootAnimation.addAnimation(leavingContent);
-
- if (backDirection) {
- // leaving content, back direction
- leavingContent
- .beforeClearStyles([OPACITY])
- .fromTo('transform', `translateX(${CENTER})`, isRTL ? 'translateX(-100%)' : 'translateX(100%)');
-
- const leavingPage = getIonPageElement(leavingEl) as HTMLElement;
- rootAnimation.afterAddWrite(() => {
- if (rootAnimation.getDirection() === 'normal') {
- leavingPage.style.setProperty('display', 'none');
- }
- });
- } else {
- // leaving content, forward direction
- leavingContent
- .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`)
- .fromTo(OPACITY, 1, translucentTop ? 1 : OFF_OPACITY);
- }
-
- if (
- leavingContentEl &&
- !leavingEl.querySelector(':scope > ion-header.header-translucent:not(.ios-theme-disabled, .ios26-disabled)')
- ) {
- const leavingTransitionEffectEl = shadow(leavingContentEl).querySelector('.transition-effect');
-
- if (leavingTransitionEffectEl) {
- const leavingTransitionCoverEl = leavingTransitionEffectEl.querySelector('.transition-cover');
- const leavingTransitionShadowEl = leavingTransitionEffectEl.querySelector('.transition-shadow');
-
- const leavingTransitionEffect = createAnimation();
- const leavingTransitionCover = createAnimation();
- const leavingTransitionShadow = createAnimation();
-
- leavingTransitionEffect
- .addElement(leavingTransitionEffectEl)
- .beforeStyles({ opacity: '1', display: 'block' })
- .afterStyles({ opacity: '', display: '' });
-
- leavingTransitionCover
- .addElement(leavingTransitionCoverEl!) // REVIEW
- .beforeClearStyles([OPACITY])
- .fromTo(OPACITY, 0.1, 0);
-
- leavingTransitionShadow
- .addElement(leavingTransitionShadowEl!) // REVIEW
- .beforeClearStyles([OPACITY])
- .fromTo(OPACITY, 0.7, 0.03);
-
- leavingTransitionEffect.addAnimation([leavingTransitionCover, leavingTransitionShadow]);
- leavingContent.addAnimation([leavingTransitionEffect]);
- }
- }
-
- leavingToolBarEls.forEach((leavingToolBarEl) => {
- if (leavingToolBarEl.closest('ion-header')?.matches('.header-translucent:not(.ios-theme-disabled, .ios26-disabled)')) {
- return;
- }
- const leavingToolBar = createAnimation();
- leavingToolBar.addElement(leavingToolBarEl);
-
- const leavingTitle = createAnimation();
- leavingTitle.addElement(leavingToolBarEl.querySelector('ion-title')!); // REVIEW
-
- const leavingToolBarButtons = createAnimation();
- const buttons = leavingToolBarEl.querySelectorAll('ion-buttons,[menuToggle]');
-
- const parentHeader = leavingToolBarEl.closest('ion-header');
- const inactiveHeader = parentHeader?.classList.contains('header-collapse-condense-inactive');
-
- const buttonsToAnimate = Array.from(buttons).filter((button) => {
- const isCollapseButton = button.classList.contains('buttons-collapse');
- return (isCollapseButton && !inactiveHeader) || !isCollapseButton;
- });
-
- leavingToolBarButtons.addElement(buttonsToAnimate);
-
- const leavingToolBarItems = createAnimation();
- const leavingToolBarItemEls = leavingToolBarEl.querySelectorAll(':scope > *:not(ion-title):not(ion-buttons):not([menuToggle])');
- if (leavingToolBarItemEls.length > 0) {
- leavingToolBarItems.addElement(leavingToolBarItemEls);
- }
-
- const leavingToolBarBg = createAnimation();
- leavingToolBarBg.addElement(shadow(leavingToolBarEl).querySelector('.toolbar-background')!); // REVIEW
-
- const leavingBackButton = createAnimation();
- const backButtonEl = leavingToolBarEl.querySelector('ion-back-button');
- if (backButtonEl) {
- leavingBackButton.addElement(backButtonEl);
- }
-
- leavingToolBar.addAnimation([leavingTitle, leavingToolBarButtons, leavingToolBarItems, leavingBackButton, leavingToolBarBg]);
- rootAnimation.addAnimation(leavingToolBar);
-
- // fade out leaving toolbar items
- leavingBackButton.fromTo(OPACITY, 0.99, 0);
-
- leavingToolBarButtons.fromTo(OPACITY, 0.99, 0);
- leavingToolBarItems.fromTo(OPACITY, 0.99, 0);
-
- if (backDirection) {
- if (!inactiveHeader) {
- // leaving toolbar, back direction
- leavingTitle
- .fromTo('transform', `translateX(${CENTER})`, isRTL ? 'translateX(-100%)' : 'translateX(100%)')
- .fromTo(OPACITY, 0.99, 0);
- }
-
- leavingToolBarItems.fromTo('transform', `translateX(${CENTER})`, isRTL ? 'translateX(-100%)' : 'translateX(100%)');
- leavingToolBarBg.beforeClearStyles([OPACITY, 'transform']);
- // leaving toolbar, back direction, and there's no entering toolbar
- // should just slide out, no fading out
- const translucentHeader = parentHeader?.translucent;
- if (!translucentHeader) {
- leavingToolBarBg.fromTo(OPACITY, 'var(--opacity)', 0);
- } else {
- leavingToolBarBg.fromTo('transform', 'translateX(0px)', isRTL ? 'translateX(-100%)' : 'translateX(100%)');
- }
-
- if (backButtonEl) {
- const leavingBackBtnText = createAnimation();
- leavingBackBtnText
- .addElement(shadow(backButtonEl).querySelector('.button-text')!) // REVIEW
- .fromTo('transform', `translateX(${CENTER})`, `translateX(${(isRTL ? -124 : 124) + 'px'})`);
- leavingToolBar.addAnimation(leavingBackBtnText);
- }
- } else {
- // leaving toolbar, forward direction
- if (!inactiveHeader) {
- leavingTitle
- .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`)
- .fromTo(OPACITY, 0.99, 0)
- .afterClearStyles([TRANSFORM, OPACITY]);
- }
-
- leavingToolBarItems
- .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`)
- .afterClearStyles([TRANSFORM, OPACITY]);
-
- leavingBackButton.afterClearStyles([OPACITY]);
- leavingTitle.afterClearStyles([OPACITY]);
- leavingToolBarButtons.afterClearStyles([OPACITY]);
- }
- });
- }
-
- connectNativeUIShellTransition(rootAnimation, enteringEl, leavingEl);
- return rootAnimation;
- } catch (err) {
- throw err;
- }
-};