diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml
index fe310a565a..f044cbd1db 100644
--- a/.github/workflows/conventional-commit.yml
+++ b/.github/workflows/conventional-commit.yml
@@ -9,24 +9,35 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Validate PR title
- uses: amannn/action-semantic-pull-request@v5
+ if: |
+ !contains(github.event.pull_request.title, 'release') &&
+ !contains(github.event.pull_request.title, 'chore')
+ uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
# Configure that a scope must always be provided.
requireScope: true
+ # Configure allowed commit types
+ types: |
+ feat
+ fix
+ docs
+ style
+ refactor
+ perf
+ test
+ build
+ ci
+ revert
+ release
+ chore
# Configure additional validation for the subject based on a regex.
# This example ensures the subject doesn't start with an uppercase character.
subjectPattern: ^(?![A-Z]).+$
- # If `subjectPattern` is configured, you can use this property to
- # override the default error message that is shown when the pattern
- # doesn't match. The variables `subject` and `title` can be used
+ # If `subjectPattern` is configured, you can use this property to
+ # override the default error message that is shown when the pattern
+ # doesn't match. The variables `subject` and `title` can be used
# within the message.
subjectPatternError: |
The subject "{subject}" found in the pull request title "{title}" didn't match the configured pattern. Please ensure that the subject doesn't start with an uppercase character.
- # If the PR contains one of these newline-delimited labels, the
- # validation is skipped. If you want to rerun the validation when
- # labels change, you might want to use the `labeled` and `unlabeled`
- # event triggers in your workflow.
- ignoreLabels: |
- release
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a19298210f..144a9ce9cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
+## [8.6.4](https://github.com/ionic-team/ionic-framework/compare/v8.6.3...v8.6.4) (2025-07-09)
+
+
+### Bug Fixes
+
+* **modal:** support iOS card view transitions for viewport changes ([#30520](https://github.com/ionic-team/ionic-framework/issues/30520)) ([0fd9e82](https://github.com/ionic-team/ionic-framework/commit/0fd9e824508333a53175d7da5f681fc3126a2394)), closes [#30296](https://github.com/ionic-team/ionic-framework/issues/30296)
+
+
+
+
+
## [8.6.3](https://github.com/ionic-team/ionic-framework/compare/v8.6.2...v8.6.3) (2025-07-02)
diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md
index bdb1d57042..3c8d4dca3f 100644
--- a/core/CHANGELOG.md
+++ b/core/CHANGELOG.md
@@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
+## [8.6.4](https://github.com/ionic-team/ionic-framework/compare/v8.6.3...v8.6.4) (2025-07-09)
+
+
+### Bug Fixes
+
+* **modal:** support iOS card view transitions for viewport changes ([#30520](https://github.com/ionic-team/ionic-framework/issues/30520)) ([0fd9e82](https://github.com/ionic-team/ionic-framework/commit/0fd9e824508333a53175d7da5f681fc3126a2394)), closes [#30296](https://github.com/ionic-team/ionic-framework/issues/30296)
+
+
+
+
+
## [8.6.3](https://github.com/ionic-team/ionic-framework/compare/v8.6.2...v8.6.3) (2025-07-02)
diff --git a/core/package-lock.json b/core/package-lock.json
index e1893b34a0..2af538f7f8 100644
--- a/core/package-lock.json
+++ b/core/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "@ionic/core",
- "version": "8.6.3",
+ "version": "8.6.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
diff --git a/core/package.json b/core/package.json
index 912f8add98..6e50a52641 100644
--- a/core/package.json
+++ b/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@ionic/core",
- "version": "8.6.3",
+ "version": "8.6.4",
"description": "Base components for Ionic",
"keywords": [
"ionic",
diff --git a/core/src/components/item-sliding/test/async/item-sliding.e2e.ts b/core/src/components/item-sliding/test/async/item-sliding.e2e.ts
index aa44b11292..ef3f5b8475 100644
--- a/core/src/components/item-sliding/test/async/item-sliding.e2e.ts
+++ b/core/src/components/item-sliding/test/async/item-sliding.e2e.ts
@@ -1,6 +1,9 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
+/**
+ * This behavior does not vary across modes/directions
+ */
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('item-sliding: async'), () => {
test.beforeEach(async ({ page }) => {
@@ -35,5 +38,85 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await expect(itemSlidingEl).toHaveClass(/item-sliding-active-slide/);
});
+
+ // NOTE: This test uses the CDN version of Ionic.
+ // If this test fails, it is likely due to a regression in the published package.
+ test('should not throw errors when adding multiple items with side="end" using the Ionic CDN', async ({
+ page,
+ }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/29499',
+ });
+
+ const errors: string[] = [];
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') {
+ errors.push(msg.text());
+ }
+ });
+ page.on('pageerror', (error) => {
+ errors.push(error.message);
+ });
+
+ // This issue only happens when using a CDN version of Ionic
+ // so we need to use the CDN by passing the `importIonicFromCDN` option
+ // to setContent.
+ await page.setContent(
+ `
+
+
+ Item Sliding
+
+ ADD ITEM
+
+
+
+
+
+
+
+
+ `,
+ { ...config, importIonicFromCDN: true }
+ );
+
+ // Click the button enough times to reproduce the issue
+ const addButton = page.locator('#addItem');
+ await addButton.click();
+ await addButton.click();
+ await addButton.click();
+
+ await page.waitForChanges();
+
+ // Check that the items have been added
+ const items = page.locator('ion-item-sliding');
+ expect(await items.count()).toBe(3);
+
+ // Check that no errors have been logged
+ expect(errors.length).toBe(0);
+ });
});
});
diff --git a/core/src/components/modal/animations/ios.enter.ts b/core/src/components/modal/animations/ios.enter.ts
index 34940062dd..c79e8752e2 100644
--- a/core/src/components/modal/animations/ios.enter.ts
+++ b/core/src/components/modal/animations/ios.enter.ts
@@ -48,7 +48,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
}
if (presentingEl) {
- const isMobile = window.innerWidth < 768;
+ const isPortrait = window.innerWidth < 768;
const hasCardModal =
presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined;
const presentingElRoot = getElementRoot(presentingEl);
@@ -61,7 +61,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
const bodyEl = document.body;
- if (isMobile) {
+ if (isPortrait) {
/**
* Fallback for browsers that does not support `max()` (ex: Firefox)
* No need to worry about statusbar padding since engines like Gecko
diff --git a/core/src/components/modal/animations/ios.leave.ts b/core/src/components/modal/animations/ios.leave.ts
index 914652878f..de543acaa5 100644
--- a/core/src/components/modal/animations/ios.leave.ts
+++ b/core/src/components/modal/animations/ios.leave.ts
@@ -35,7 +35,7 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
.addAnimation(wrapperAnimation);
if (presentingEl) {
- const isMobile = window.innerWidth < 768;
+ const isPortrait = window.innerWidth < 768;
const hasCardModal =
presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined;
const presentingElRoot = getElementRoot(presentingEl);
@@ -61,7 +61,7 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
const bodyEl = document.body;
- if (isMobile) {
+ if (isPortrait) {
const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
const modalTransform = hasCardModal ? '-10px' : transformOffset;
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
diff --git a/core/src/components/modal/animations/ios.transition.ts b/core/src/components/modal/animations/ios.transition.ts
new file mode 100644
index 0000000000..6ce2cd75e1
--- /dev/null
+++ b/core/src/components/modal/animations/ios.transition.ts
@@ -0,0 +1,198 @@
+import { createAnimation } from '@utils/animation/animation';
+import { getElementRoot } from '@utils/helpers';
+
+import type { Animation } from '../../../interface';
+import { SwipeToCloseDefaults } from '../gestures/swipe-to-close';
+import type { ModalAnimationOptions } from '../modal-interface';
+
+/**
+ * Transition animation from portrait view to landscape view
+ * This handles the case where a card modal is open in portrait view
+ * and the user switches to landscape view
+ */
+export const portraitToLandscapeTransition = (
+ baseEl: HTMLElement,
+ opts: ModalAnimationOptions,
+ duration = 300
+): Animation => {
+ const { presentingEl } = opts;
+
+ if (!presentingEl) {
+ // No transition needed for non-card modals
+ return createAnimation('portrait-to-landscape-transition');
+ }
+
+ const presentingElIsCardModal =
+ presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined;
+ const presentingElRoot = getElementRoot(presentingEl);
+ const bodyEl = document.body;
+
+ const baseAnimation = createAnimation('portrait-to-landscape-transition')
+ .addElement(baseEl)
+ .easing('cubic-bezier(0.32,0.72,0,1)')
+ .duration(duration);
+
+ const presentingAnimation = createAnimation().beforeStyles({
+ transform: 'translateY(0)',
+ 'transform-origin': 'top center',
+ overflow: 'hidden',
+ });
+
+ if (!presentingElIsCardModal) {
+ // The presenting element is not a card modal, so we do not
+ // need to care about layering and modal-specific styles.
+ const root = getElementRoot(baseEl);
+ const wrapperAnimation = createAnimation()
+ .addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!)
+ .fromTo('opacity', '1', '1'); // Keep wrapper visible in landscape
+
+ const backdropAnimation = createAnimation()
+ .addElement(root.querySelector('ion-backdrop')!)
+ .fromTo('opacity', 'var(--backdrop-opacity)', 'var(--backdrop-opacity)'); // Keep backdrop visible
+
+ // Animate presentingEl from portrait state back to normal
+ const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
+ const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
+ const fromTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`;
+
+ presentingAnimation
+ .addElement(presentingEl)
+ .afterStyles({
+ transform: 'translateY(0px) scale(1)',
+ 'border-radius': '0px',
+ })
+ .beforeAddWrite(() => bodyEl.style.setProperty('background-color', ''))
+ .fromTo('transform', fromTransform, 'translateY(0px) scale(1)')
+ .fromTo('filter', 'contrast(0.85)', 'contrast(1)')
+ .fromTo('border-radius', '10px 10px 0 0', '0px');
+
+ baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]);
+ } else {
+ // The presenting element is a card modal, so we do
+ // need to care about layering and modal-specific styles.
+ const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
+ const fromTransform = `translateY(-10px) scale(${toPresentingScale})`;
+ const toTransform = `translateY(-10px) scale(${toPresentingScale})`;
+
+ presentingAnimation
+ .addElement(presentingElRoot.querySelector('.modal-wrapper')!)
+ .afterStyles({
+ transform: toTransform,
+ })
+ .fromTo('transform', fromTransform, toTransform)
+ .fromTo('filter', 'contrast(0.85)', 'contrast(0.85)'); // Keep same contrast for card
+
+ const shadowAnimation = createAnimation()
+ .addElement(presentingElRoot.querySelector('.modal-shadow')!)
+ .afterStyles({
+ transform: toTransform,
+ })
+ .fromTo('opacity', '0', '0') // Shadow stays hidden in landscape for card modals
+ .fromTo('transform', fromTransform, toTransform);
+
+ baseAnimation.addAnimation([presentingAnimation, shadowAnimation]);
+ }
+
+ return baseAnimation;
+};
+
+/**
+ * Transition animation from landscape view to portrait view
+ * This handles the case where a card modal is open in landscape view
+ * and the user switches to portrait view
+ */
+export const landscapeToPortraitTransition = (
+ baseEl: HTMLElement,
+ opts: ModalAnimationOptions,
+ duration = 300
+): Animation => {
+ const { presentingEl } = opts;
+
+ if (!presentingEl) {
+ // No transition needed for non-card modals
+ return createAnimation('landscape-to-portrait-transition');
+ }
+
+ const presentingElIsCardModal =
+ presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined;
+ const presentingElRoot = getElementRoot(presentingEl);
+ const bodyEl = document.body;
+
+ const baseAnimation = createAnimation('landscape-to-portrait-transition')
+ .addElement(baseEl)
+ .easing('cubic-bezier(0.32,0.72,0,1)')
+ .duration(duration);
+
+ const presentingAnimation = createAnimation().beforeStyles({
+ transform: 'translateY(0)',
+ 'transform-origin': 'top center',
+ overflow: 'hidden',
+ });
+
+ if (!presentingElIsCardModal) {
+ // The presenting element is not a card modal, so we do not
+ // need to care about layering and modal-specific styles.
+ const root = getElementRoot(baseEl);
+ const wrapperAnimation = createAnimation()
+ .addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!)
+ .fromTo('opacity', '1', '1'); // Keep wrapper visible
+
+ const backdropAnimation = createAnimation()
+ .addElement(root.querySelector('ion-backdrop')!)
+ .fromTo('opacity', 'var(--backdrop-opacity)', 'var(--backdrop-opacity)'); // Keep backdrop visible
+
+ // Animate presentingEl from normal state to portrait state
+ const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
+ const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
+ const toTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`;
+
+ presentingAnimation
+ .addElement(presentingEl)
+ .beforeStyles({
+ transform: 'translateY(0px) scale(1)',
+ 'transform-origin': 'top center',
+ overflow: 'hidden',
+ })
+ .afterStyles({
+ transform: toTransform,
+ 'border-radius': '10px 10px 0 0',
+ filter: 'contrast(0.85)',
+ overflow: 'hidden',
+ 'transform-origin': 'top center',
+ })
+ .beforeAddWrite(() => bodyEl.style.setProperty('background-color', 'black'))
+ .keyframes([
+ { offset: 0, transform: 'translateY(0px) scale(1)', filter: 'contrast(1)', borderRadius: '0px' },
+ { offset: 0.2, transform: 'translateY(0px) scale(1)', filter: 'contrast(1)', borderRadius: '10px 10px 0 0' },
+ { offset: 1, transform: toTransform, filter: 'contrast(0.85)', borderRadius: '10px 10px 0 0' },
+ ]);
+
+ baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]);
+ } else {
+ // The presenting element is also a card modal, so we need
+ // to handle layering and modal-specific styles.
+ const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
+ const fromTransform = `translateY(-10px) scale(${toPresentingScale})`;
+ const toTransform = `translateY(-10px) scale(${toPresentingScale})`;
+
+ presentingAnimation
+ .addElement(presentingElRoot.querySelector('.modal-wrapper')!)
+ .afterStyles({
+ transform: toTransform,
+ })
+ .fromTo('transform', fromTransform, toTransform)
+ .fromTo('filter', 'contrast(0.85)', 'contrast(0.85)'); // Keep same contrast for card
+
+ const shadowAnimation = createAnimation()
+ .addElement(presentingElRoot.querySelector('.modal-shadow')!)
+ .afterStyles({
+ transform: toTransform,
+ })
+ .fromTo('opacity', '0', '0') // Shadow stays hidden
+ .fromTo('transform', fromTransform, toTransform);
+
+ baseAnimation.addAnimation([presentingAnimation, shadowAnimation]);
+ }
+
+ return baseAnimation;
+};
diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx
index 909b8fb630..46d176d101 100644
--- a/core/src/components/modal/modal.tsx
+++ b/core/src/components/modal/modal.tsx
@@ -1,8 +1,8 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
-import { Component, Element, Event, Host, Method, Prop, State, Watch, h, writeTask } from '@stencil/core';
+import { Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h, writeTask } from '@stencil/core';
import { findIonContent, printIonContentErrorMsg } from '@utils/content';
import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework-delegate';
-import { raf, inheritAttributes, hasLazyBuild } from '@utils/helpers';
+import { raf, inheritAttributes, hasLazyBuild, getElementRoot } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { createLockController } from '@utils/lock-controller';
import { printIonWarning } from '@utils/logging';
@@ -37,11 +37,12 @@ import type { OverlayEventDetail } from '../../utils/overlays-interface';
import { iosEnterAnimation } from './animations/ios.enter';
import { iosLeaveAnimation } from './animations/ios.leave';
+import { portraitToLandscapeTransition, landscapeToPortraitTransition } from './animations/ios.transition';
import { mdEnterAnimation } from './animations/md.enter';
import { mdLeaveAnimation } from './animations/md.leave';
import type { MoveSheetToBreakpointOptions } from './gestures/sheet';
import { createSheetGesture } from './gestures/sheet';
-import { createSwipeToCloseGesture } from './gestures/swipe-to-close';
+import { createSwipeToCloseGesture, SwipeToCloseDefaults } from './gestures/swipe-to-close';
import type { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from './modal-interface';
import { setCardStatusBarDark, setCardStatusBarDefault } from './utils';
@@ -92,6 +93,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
// Whether or not modal is being dismissed via gesture
private gestureAnimationDismissing = false;
+ // View transition properties for handling portrait/landscape switches
+ private currentViewIsPortrait?: boolean;
+ private viewTransitionAnimation?: Animation;
+ private resizeTimeout?: any;
+
lastFocus?: HTMLElement;
animation?: Animation;
@@ -263,6 +269,19 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
}
+ @Listen('resize', { target: 'window' })
+ onWindowResize() {
+ // Only handle resize for iOS card modals when no custom animations are provided
+ if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
+ return;
+ }
+
+ clearTimeout(this.resizeTimeout);
+ this.resizeTimeout = setTimeout(() => {
+ this.handleViewTransition();
+ }, 50); // Debounce to avoid excessive calls during active resizing
+ }
+
/**
* If `true`, the component passed into `ion-modal` will
* automatically be mounted when the modal is created. The
@@ -389,6 +408,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
disconnectedCallback() {
this.triggerController.removeClickListener();
+ this.cleanupViewTransitionListener();
}
componentWillLoad() {
@@ -631,6 +651,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.initSwipeToClose();
}
+ // Initialize view transition listener for iOS card modals
+ this.initViewTransitionListener();
+
unlock();
}
@@ -830,6 +853,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
if (this.gesture) {
this.gesture.destroy();
}
+ this.cleanupViewTransitionListener();
}
this.currentBreakpoint = undefined;
this.animation = undefined;
@@ -993,6 +1017,134 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
};
+ private initViewTransitionListener() {
+ // Only enable for iOS card modals when no custom animations are provided
+ if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
+ return;
+ }
+
+ // Set initial view state
+ this.currentViewIsPortrait = window.innerWidth < 768;
+ }
+
+ private handleViewTransition() {
+ const isPortrait = window.innerWidth < 768;
+
+ // Only transition if view state actually changed
+ if (this.currentViewIsPortrait === isPortrait) {
+ return;
+ }
+
+ // Cancel any ongoing transition animation
+ if (this.viewTransitionAnimation) {
+ this.viewTransitionAnimation.destroy();
+ this.viewTransitionAnimation = undefined;
+ }
+
+ const { presentingElement } = this;
+ if (!presentingElement) {
+ return;
+ }
+
+ // Create transition animation
+ let transitionAnimation: Animation;
+ if (this.currentViewIsPortrait && !isPortrait) {
+ // Portrait to landscape transition
+ transitionAnimation = portraitToLandscapeTransition(this.el, {
+ presentingEl: presentingElement,
+ currentBreakpoint: this.currentBreakpoint,
+ backdropBreakpoint: this.backdropBreakpoint,
+ expandToScroll: this.expandToScroll,
+ });
+ } else {
+ // Landscape to portrait transition
+ transitionAnimation = landscapeToPortraitTransition(this.el, {
+ presentingEl: presentingElement,
+ currentBreakpoint: this.currentBreakpoint,
+ backdropBreakpoint: this.backdropBreakpoint,
+ expandToScroll: this.expandToScroll,
+ });
+ }
+
+ // Update state and play animation
+ this.currentViewIsPortrait = isPortrait;
+ this.viewTransitionAnimation = transitionAnimation;
+
+ transitionAnimation.play().then(() => {
+ this.viewTransitionAnimation = undefined;
+
+ // After orientation transition, recreate the swipe-to-close gesture
+ // with updated animation that reflects the new presenting element state
+ this.reinitSwipeToClose();
+ });
+ }
+
+ private cleanupViewTransitionListener() {
+ // Clear any pending resize timeout
+ if (this.resizeTimeout) {
+ clearTimeout(this.resizeTimeout);
+ this.resizeTimeout = undefined;
+ }
+
+ if (this.viewTransitionAnimation) {
+ this.viewTransitionAnimation.destroy();
+ this.viewTransitionAnimation = undefined;
+ }
+ }
+
+ private reinitSwipeToClose() {
+ // Only reinitialize if we have a presenting element and are on iOS
+ if (getIonMode(this) !== 'ios' || !this.presentingElement) {
+ return;
+ }
+
+ // Clean up existing gesture and animation
+ if (this.gesture) {
+ this.gesture.destroy();
+ this.gesture = undefined;
+ }
+
+ if (this.animation) {
+ // Properly end the progress-based animation at initial state before destroying
+ // to avoid leaving modal in intermediate swipe position
+ this.animation.progressEnd(0, 0, 0);
+ this.animation.destroy();
+ this.animation = undefined;
+ }
+
+ // Force the modal back to the correct position or it could end up
+ // in a weird state after destroying the animation
+ raf(() => {
+ this.ensureCorrectModalPosition();
+ this.initSwipeToClose();
+ });
+ }
+
+ private ensureCorrectModalPosition() {
+ const { el, presentingElement } = this;
+ const root = getElementRoot(el);
+
+ const wrapperEl = root.querySelector('.modal-wrapper') as HTMLElement | null;
+ if (wrapperEl) {
+ wrapperEl.style.transform = 'translateY(0vh)';
+ wrapperEl.style.opacity = '1';
+ }
+
+ if (presentingElement) {
+ const isPortrait = window.innerWidth < 768;
+
+ if (isPortrait) {
+ const transformOffset = !CSS.supports('width', 'max(0px, 1px)')
+ ? '30px'
+ : 'max(30px, var(--ion-safe-area-top))';
+ const scale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
+ presentingElement.style.transform = `translateY(${transformOffset}) scale(${scale})`;
+ } else {
+ presentingElement.style.transform = 'translateY(0px) scale(1)';
+ }
+ }
+ }
+
render() {
const {
handle,
diff --git a/core/src/utils/test/playwright/page/utils/set-content.ts b/core/src/utils/test/playwright/page/utils/set-content.ts
index b6417d5f3b..1231340f59 100644
--- a/core/src/utils/test/playwright/page/utils/set-content.ts
+++ b/core/src/utils/test/playwright/page/utils/set-content.ts
@@ -1,5 +1,5 @@
import type { Page, TestInfo } from '@playwright/test';
-import type { E2EPageOptions, Mode, Direction, Theme, Palette } from '@utils/test/playwright';
+import type { Direction, E2EPageOptions, Mode, Palette, Theme } from '@utils/test/playwright';
/**
* Overwrites the default Playwright page.setContent method.
@@ -36,6 +36,30 @@ export const setContent = async (page: Page, html: string, testInfo: TestInfo, o
const baseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL;
+ // The Ionic bundle is included locally by default unless the test
+ // config passes in the importIonicFromCDN option. This is useful
+ // when testing with the CDN version of Ionic.
+ let ionicCSSImports = theme === 'ionic' ? `
+
+ ` : `
+
+ `;
+ let ionicJSImports = `
+
+ `;
+
+ if (options?.importIonicFromCDN) {
+ ionicCSSImports = theme === 'ionic' ? `
+
+ ` : `
+
+ `;
+ ionicJSImports = `
+
+
+ `;
+ }
+
const output = `
@@ -43,15 +67,11 @@ export const setContent = async (page: Page, html: string, testInfo: TestInfo, o
Ionic Playwright Test
- ${
- theme === 'ionic'
- ? ``
- : ``
- }
+ ${ionicCSSImports}
${palette !== 'light' ? `` : ''}
-
+ ${ionicJSImports}