From 3738016006c33f958452c01bb4f88be921dc7cf3 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 30 Jun 2025 08:38:50 -0700 Subject: [PATCH 01/11] fix(modal): support ios card view transitions for viewport changes --- .../modal/animations/ios.transition.ts | 161 ++++++++++++++++++ core/src/components/modal/modal.tsx | 92 ++++++++++ 2 files changed, 253 insertions(+) create mode 100644 core/src/components/modal/animations/ios.transition.ts 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 00000000000..711b704c0d7 --- /dev/null +++ b/core/src/components/modal/animations/ios.transition.ts @@ -0,0 +1,161 @@ +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 mobile view to portrait view + * This handles the case where a card modal is open in mobile view + * and the user switches to portrait view + */ +export const mobileToPortraitTransition = ( + baseEl: HTMLElement, + opts: ModalAnimationOptions, + duration = 300 +): Animation => { + const { presentingEl } = opts; + + if (!presentingEl) { + // No transition needed for non-card modals + return createAnimation('mobile-to-portrait-transition'); + } + + const hasCardModal = + presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined; + const presentingElRoot = getElementRoot(presentingEl); + const bodyEl = document.body; + + const baseAnimation = createAnimation('mobile-to-portrait-transition') + .addElement(baseEl) + .easing('cubic-bezier(0.32,0.72,0,1)') + .duration(duration); + + const presentingAnimation = createAnimation(); + + if (!hasCardModal) { + // Non-card modal: transition from mobile state to portrait state + // Mobile: presentingEl has transform and body has black background + // Portrait: no transform, no body background, modal wrapper opacity changes + + const root = getElementRoot(baseEl); + const wrapperAnimation = createAnimation() + .addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!) + .fromTo('opacity', '1', '1'); // Keep wrapper visible in portrait + + const backdropAnimation = createAnimation() + .addElement(root.querySelector('ion-backdrop')!) + .fromTo('opacity', 'var(--backdrop-opacity)', 'var(--backdrop-opacity)'); // Keep backdrop visible + + // Animate presentingEl from mobile 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) + .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 { + // Card modal: transition from mobile card state to portrait card state + const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; + const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))'; + const fromTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`; + const toTransform = `translateY(-10px) scale(${toPresentingScale})`; + + presentingAnimation + .addElement(presentingElRoot.querySelector('.modal-wrapper')!) + .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')!) + .fromTo('opacity', '0', '0') // Shadow stays hidden in portrait for card modals + .fromTo('transform', fromTransform, toTransform); + + baseAnimation.addAnimation([presentingAnimation, shadowAnimation]); + } + + return baseAnimation; +}; + +/** + * Transition animation from portrait view to mobile view + * This handles the case where a card modal is open in portrait view + * and the user switches to mobile view + */ +export const portraitToMobileTransition = ( + baseEl: HTMLElement, + opts: ModalAnimationOptions, + duration = 300 +): Animation => { + const { presentingEl } = opts; + + if (!presentingEl) { + // No transition needed for non-card modals + return createAnimation('portrait-to-mobile-transition'); + } + + const hasCardModal = + presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined; + const presentingElRoot = getElementRoot(presentingEl); + const bodyEl = document.body; + + const baseAnimation = createAnimation('portrait-to-mobile-transition') + .addElement(baseEl) + .easing('cubic-bezier(0.32,0.72,0,1)') + .duration(duration); + + const presentingAnimation = createAnimation(); + + if (!hasCardModal) { + // Non-card modal: transition from portrait state to mobile state + 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 mobile 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) + .beforeAddWrite(() => bodyEl.style.setProperty('background-color', 'black')) + .fromTo('transform', 'translateY(0px) scale(1)', toTransform) + .fromTo('filter', 'contrast(1)', 'contrast(0.85)') + .fromTo('border-radius', '0px', '10px 10px 0 0'); + + baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]); + } else { + // Card modal: transition from portrait card state to mobile card state + const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; + const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))'; + const fromTransform = `translateY(-10px) scale(${toPresentingScale})`; + const toTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`; + + presentingAnimation + .addElement(presentingElRoot.querySelector('.modal-wrapper')!) + .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')!) + .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 eb56d50e73f..226869dd36b 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -37,6 +37,7 @@ import type { OverlayEventDetail } from '../../utils/overlays-interface'; import { iosEnterAnimation } from './animations/ios.enter'; import { iosLeaveAnimation } from './animations/ios.leave'; +import { mobileToPortraitTransition, portraitToMobileTransition } from './animations/ios.transition'; import { mdEnterAnimation } from './animations/md.enter'; import { mdLeaveAnimation } from './animations/md.leave'; import type { MoveSheetToBreakpointOptions } from './gestures/sheet'; @@ -89,6 +90,11 @@ export class Modal implements ComponentInterface, OverlayInterface { // Whether or not modal is being dismissed via gesture private gestureAnimationDismissing = false; + // View transition properties for handling mobile/portrait switches + private resizeListener?: () => void; + private currentViewIsMobile?: boolean; + private viewTransitionAnimation?: Animation; + lastFocus?: HTMLElement; animation?: Animation; @@ -377,6 +383,7 @@ export class Modal implements ComponentInterface, OverlayInterface { disconnectedCallback() { this.triggerController.removeClickListener(); + this.cleanupViewTransitionListener(); } componentWillLoad() { @@ -618,6 +625,9 @@ export class Modal implements ComponentInterface, OverlayInterface { this.initSwipeToClose(); } + // Initialize view transition listener for iOS card modals + this.initViewTransitionListener(); + unlock(); } @@ -815,6 +825,7 @@ export class Modal implements ComponentInterface, OverlayInterface { if (this.gesture) { this.gesture.destroy(); } + this.cleanupViewTransitionListener(); } this.currentBreakpoint = undefined; this.animation = undefined; @@ -950,6 +961,87 @@ 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.currentViewIsMobile = window.innerWidth < 768; + + // Create debounced resize handler + let resizeTimeout: any; + this.resizeListener = () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + this.handleViewTransition(); + }, 100); // Debounce for 100ms to avoid excessive calls + }; + + window.addEventListener('resize', this.resizeListener); + } + + private handleViewTransition() { + const isMobile = window.innerWidth < 768; + + // Only transition if view state actually changed + if (this.currentViewIsMobile === isMobile) { + 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.currentViewIsMobile && !isMobile) { + // Mobile to portrait transition + transitionAnimation = mobileToPortraitTransition(this.el, { + presentingEl: presentingElement, + currentBreakpoint: this.currentBreakpoint, + backdropBreakpoint: this.backdropBreakpoint, + expandToScroll: this.expandToScroll, + }); + } else { + // Portrait to mobile transition + transitionAnimation = portraitToMobileTransition(this.el, { + presentingEl: presentingElement, + currentBreakpoint: this.currentBreakpoint, + backdropBreakpoint: this.backdropBreakpoint, + expandToScroll: this.expandToScroll, + }); + } + + // Update state and play animation + this.currentViewIsMobile = isMobile; + this.viewTransitionAnimation = transitionAnimation; + + transitionAnimation.play().then(() => { + this.viewTransitionAnimation = undefined; + }); + } + + private cleanupViewTransitionListener() { + if (this.resizeListener) { + window.removeEventListener('resize', this.resizeListener); + this.resizeListener = undefined; + } + + if (this.viewTransitionAnimation) { + this.viewTransitionAnimation.destroy(); + this.viewTransitionAnimation = undefined; + } + } + render() { const { handle, From 112929c3a7c2f143f436112c95d4cab2de97dbd4 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 30 Jun 2025 10:41:42 -0700 Subject: [PATCH 02/11] fix(modal): clarity around mobile/portrait terminology --- .../components/modal/animations/ios.enter.ts | 4 +- .../components/modal/animations/ios.leave.ts | 4 +- .../modal/animations/ios.transition.ts | 44 +++++++++---------- core/src/components/modal/modal.tsx | 24 +++++----- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/core/src/components/modal/animations/ios.enter.ts b/core/src/components/modal/animations/ios.enter.ts index 34940062dd2..c79e8752e22 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 914652878fa..de543acaa54 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 index 711b704c0d7..31f88c071df 100644 --- a/core/src/components/modal/animations/ios.transition.ts +++ b/core/src/components/modal/animations/ios.transition.ts @@ -6,11 +6,11 @@ import { SwipeToCloseDefaults } from '../gestures/swipe-to-close'; import type { ModalAnimationOptions } from '../modal-interface'; /** - * Transition animation from mobile view to portrait view - * This handles the case where a card modal is open in mobile view - * and the user switches to portrait view + * 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 mobileToPortraitTransition = ( +export const portraitToLandscapeTransition = ( baseEl: HTMLElement, opts: ModalAnimationOptions, duration = 300 @@ -19,7 +19,7 @@ export const mobileToPortraitTransition = ( if (!presentingEl) { // No transition needed for non-card modals - return createAnimation('mobile-to-portrait-transition'); + return createAnimation('portrait-to-landscape-transition'); } const hasCardModal = @@ -27,7 +27,7 @@ export const mobileToPortraitTransition = ( const presentingElRoot = getElementRoot(presentingEl); const bodyEl = document.body; - const baseAnimation = createAnimation('mobile-to-portrait-transition') + const baseAnimation = createAnimation('portrait-to-landscape-transition') .addElement(baseEl) .easing('cubic-bezier(0.32,0.72,0,1)') .duration(duration); @@ -35,20 +35,20 @@ export const mobileToPortraitTransition = ( const presentingAnimation = createAnimation(); if (!hasCardModal) { - // Non-card modal: transition from mobile state to portrait state - // Mobile: presentingEl has transform and body has black background - // Portrait: no transform, no body background, modal wrapper opacity changes + // Non-card modal: transition from portrait state to landscape state + // Portrait: presentingEl has transform and body has black background + // Landscape: no transform, no body background, modal wrapper opacity changes const root = getElementRoot(baseEl); const wrapperAnimation = createAnimation() .addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!) - .fromTo('opacity', '1', '1'); // Keep wrapper visible in portrait + .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 mobile state back to normal + // 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})`; @@ -62,7 +62,7 @@ export const mobileToPortraitTransition = ( baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]); } else { - // Card modal: transition from mobile card state to portrait card state + // Card modal: transition from portrait card state to landscape card state const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))'; const fromTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`; @@ -75,7 +75,7 @@ export const mobileToPortraitTransition = ( const shadowAnimation = createAnimation() .addElement(presentingElRoot.querySelector('.modal-shadow')!) - .fromTo('opacity', '0', '0') // Shadow stays hidden in portrait for card modals + .fromTo('opacity', '0', '0') // Shadow stays hidden in landscape for card modals .fromTo('transform', fromTransform, toTransform); baseAnimation.addAnimation([presentingAnimation, shadowAnimation]); @@ -85,11 +85,11 @@ export const mobileToPortraitTransition = ( }; /** - * Transition animation from portrait view to mobile view - * This handles the case where a card modal is open in portrait view - * and the user switches to mobile view + * 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 portraitToMobileTransition = ( +export const landscapeToPortraitTransition = ( baseEl: HTMLElement, opts: ModalAnimationOptions, duration = 300 @@ -98,7 +98,7 @@ export const portraitToMobileTransition = ( if (!presentingEl) { // No transition needed for non-card modals - return createAnimation('portrait-to-mobile-transition'); + return createAnimation('landscape-to-portrait-transition'); } const hasCardModal = @@ -106,7 +106,7 @@ export const portraitToMobileTransition = ( const presentingElRoot = getElementRoot(presentingEl); const bodyEl = document.body; - const baseAnimation = createAnimation('portrait-to-mobile-transition') + const baseAnimation = createAnimation('landscape-to-portrait-transition') .addElement(baseEl) .easing('cubic-bezier(0.32,0.72,0,1)') .duration(duration); @@ -114,7 +114,7 @@ export const portraitToMobileTransition = ( const presentingAnimation = createAnimation(); if (!hasCardModal) { - // Non-card modal: transition from portrait state to mobile state + // Non-card modal: transition from landscape state to portrait state const root = getElementRoot(baseEl); const wrapperAnimation = createAnimation() .addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!) @@ -124,7 +124,7 @@ export const portraitToMobileTransition = ( .addElement(root.querySelector('ion-backdrop')!) .fromTo('opacity', 'var(--backdrop-opacity)', 'var(--backdrop-opacity)'); // Keep backdrop visible - // Animate presentingEl from normal state to mobile state + // 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})`; @@ -138,7 +138,7 @@ export const portraitToMobileTransition = ( baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]); } else { - // Card modal: transition from portrait card state to mobile card state + // Card modal: transition from landscape card state to portrait card state const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))'; const fromTransform = `translateY(-10px) scale(${toPresentingScale})`; diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 4272b9d7de4..5b17a4d2df4 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -37,7 +37,7 @@ import type { OverlayEventDetail } from '../../utils/overlays-interface'; import { iosEnterAnimation } from './animations/ios.enter'; import { iosLeaveAnimation } from './animations/ios.leave'; -import { mobileToPortraitTransition, portraitToMobileTransition } from './animations/ios.transition'; +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'; @@ -91,9 +91,9 @@ export class Modal implements ComponentInterface, OverlayInterface { // Whether or not modal is being dismissed via gesture private gestureAnimationDismissing = false; - // View transition properties for handling mobile/portrait switches + // View transition properties for handling portrait/landscape switches private resizeListener?: () => void; - private currentViewIsMobile?: boolean; + private currentViewIsPortrait?: boolean; private viewTransitionAnimation?: Animation; lastFocus?: HTMLElement; @@ -981,7 +981,7 @@ export class Modal implements ComponentInterface, OverlayInterface { } // Set initial view state - this.currentViewIsMobile = window.innerWidth < 768; + this.currentViewIsPortrait = window.innerWidth < 768; // Create debounced resize handler let resizeTimeout: any; @@ -996,10 +996,10 @@ export class Modal implements ComponentInterface, OverlayInterface { } private handleViewTransition() { - const isMobile = window.innerWidth < 768; + const isPortrait = window.innerWidth < 768; // Only transition if view state actually changed - if (this.currentViewIsMobile === isMobile) { + if (this.currentViewIsPortrait === isPortrait) { return; } @@ -1016,17 +1016,17 @@ export class Modal implements ComponentInterface, OverlayInterface { // Create transition animation let transitionAnimation: Animation; - if (this.currentViewIsMobile && !isMobile) { - // Mobile to portrait transition - transitionAnimation = mobileToPortraitTransition(this.el, { + if (this.currentViewIsPortrait && !isPortrait) { + // Portrait to landscape transition + transitionAnimation = portraitToLandscapeTransition(this.el, { presentingEl: presentingElement, currentBreakpoint: this.currentBreakpoint, backdropBreakpoint: this.backdropBreakpoint, expandToScroll: this.expandToScroll, }); } else { - // Portrait to mobile transition - transitionAnimation = portraitToMobileTransition(this.el, { + // Landscape to portrait transition + transitionAnimation = landscapeToPortraitTransition(this.el, { presentingEl: presentingElement, currentBreakpoint: this.currentBreakpoint, backdropBreakpoint: this.backdropBreakpoint, @@ -1035,7 +1035,7 @@ export class Modal implements ComponentInterface, OverlayInterface { } // Update state and play animation - this.currentViewIsMobile = isMobile; + this.currentViewIsPortrait = isPortrait; this.viewTransitionAnimation = transitionAnimation; transitionAnimation.play().then(() => { From da06a671af379940fc5ad243d6783b267fc2e602 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 30 Jun 2025 13:21:20 -0700 Subject: [PATCH 03/11] fix(modal): properly hadnling presenting element when starting in portrait and going to landscape --- .../modal/animations/ios.transition.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/core/src/components/modal/animations/ios.transition.ts b/core/src/components/modal/animations/ios.transition.ts index 31f88c071df..0ca8c48bcdf 100644 --- a/core/src/components/modal/animations/ios.transition.ts +++ b/core/src/components/modal/animations/ios.transition.ts @@ -32,7 +32,11 @@ export const portraitToLandscapeTransition = ( .easing('cubic-bezier(0.32,0.72,0,1)') .duration(duration); - const presentingAnimation = createAnimation(); + const presentingAnimation = createAnimation().beforeStyles({ + transform: 'translateY(0)', + 'transform-origin': 'top center', + overflow: 'hidden', + }); if (!hasCardModal) { // Non-card modal: transition from portrait state to landscape state @@ -55,6 +59,10 @@ export const portraitToLandscapeTransition = ( 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)') @@ -111,7 +119,11 @@ export const landscapeToPortraitTransition = ( .easing('cubic-bezier(0.32,0.72,0,1)') .duration(duration); - const presentingAnimation = createAnimation(); + const presentingAnimation = createAnimation().beforeStyles({ + transform: 'translateY(0)', + 'transform-origin': 'top center', + overflow: 'hidden', + }); if (!hasCardModal) { // Non-card modal: transition from landscape state to portrait state @@ -131,6 +143,10 @@ export const landscapeToPortraitTransition = ( presentingAnimation .addElement(presentingEl) + .afterStyles({ + transform: toTransform, + 'border-radius': '10px 10px 0 0', + }) .beforeAddWrite(() => bodyEl.style.setProperty('background-color', 'black')) .fromTo('transform', 'translateY(0px) scale(1)', toTransform) .fromTo('filter', 'contrast(1)', 'contrast(0.85)') From 67f1d0575ba769cdd181d3b5e00f8db053090c06 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 1 Jul 2025 06:19:22 -0700 Subject: [PATCH 04/11] fix(modal): fixing dismiss animation in portrait view so it restores even if a card modal was started in a landscape view --- core/src/components/modal/animations/ios.transition.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/src/components/modal/animations/ios.transition.ts b/core/src/components/modal/animations/ios.transition.ts index 0ca8c48bcdf..9d68b072848 100644 --- a/core/src/components/modal/animations/ios.transition.ts +++ b/core/src/components/modal/animations/ios.transition.ts @@ -146,6 +146,9 @@ export const landscapeToPortraitTransition = ( .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')) .fromTo('transform', 'translateY(0px) scale(1)', toTransform) From 2cc4a5c332885f7ebdb8158feccbabf182fb235e Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 1 Jul 2025 07:06:03 -0700 Subject: [PATCH 05/11] fix(modal): trigger view transition manually on cleanup to ensure we're in the right state --- core/src/components/modal/modal.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 5b17a4d2df4..7adea5f54f4 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -1044,6 +1044,7 @@ export class Modal implements ComponentInterface, OverlayInterface { } private cleanupViewTransitionListener() { + const hasCardView = !!this.resizeListener; if (this.resizeListener) { window.removeEventListener('resize', this.resizeListener); this.resizeListener = undefined; @@ -1053,6 +1054,15 @@ export class Modal implements ComponentInterface, OverlayInterface { this.viewTransitionAnimation.destroy(); this.viewTransitionAnimation = undefined; } + + if (hasCardView) { + // If we had a card view, let's trigger the view transition + // one last time to make sure we're in the right state. + // This will prevent tricky things like resizing the modal causing + // it to dismiss programatically too quickly and preventing the view transition + // from being applied. + this.handleViewTransition(); + } } render() { From 159ec7d26bb9f0c86108775415cadcea97332578 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 2 Jul 2025 06:21:59 -0700 Subject: [PATCH 06/11] fix(modal): resetting dismiss gesture when transitioning sizes --- core/src/components/modal/modal.tsx | 46 +++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 7adea5f54f4..79a453fbfe9 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -95,6 +95,7 @@ export class Modal implements ComponentInterface, OverlayInterface { private resizeListener?: () => void; private currentViewIsPortrait?: boolean; private viewTransitionAnimation?: Animation; + private resizeTimeout?: any; lastFocus?: HTMLElement; animation?: Animation; @@ -984,12 +985,12 @@ export class Modal implements ComponentInterface, OverlayInterface { this.currentViewIsPortrait = window.innerWidth < 768; // Create debounced resize handler - let resizeTimeout: any; this.resizeListener = () => { - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(() => { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = setTimeout(() => { + console.log('View transition triggered by resize'); this.handleViewTransition(); - }, 100); // Debounce for 100ms to avoid excessive calls + }, 50); // Debounce to avoid excessive calls during active resizing }; window.addEventListener('resize', this.resizeListener); @@ -1040,29 +1041,50 @@ export class Modal implements ComponentInterface, OverlayInterface { 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() { - const hasCardView = !!this.resizeListener; if (this.resizeListener) { window.removeEventListener('resize', this.resizeListener); this.resizeListener = undefined; } + // 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; + } - if (hasCardView) { - // If we had a card view, let's trigger the view transition - // one last time to make sure we're in the right state. - // This will prevent tricky things like resizing the modal causing - // it to dismiss programatically too quickly and preventing the view transition - // from being applied. - this.handleViewTransition(); + // Clean up existing gesture and animation + if (this.gesture) { + this.gesture.destroy(); + this.gesture = undefined; } + + if (this.animation) { + this.animation.destroy(); + this.animation = undefined; + } + + // Reinitialize the swipe-to-close gesture with current state + this.initSwipeToClose(); } render() { From a5501fb993681b447d9639cae3f764926a9f98a2 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 2 Jul 2025 09:35:05 -0700 Subject: [PATCH 07/11] fix(modal): cleaning up transition for cards when moving from landscape to portrait --- .../modal/animations/ios.transition.ts | 54 ++++++++++++------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/core/src/components/modal/animations/ios.transition.ts b/core/src/components/modal/animations/ios.transition.ts index 9d68b072848..6ce2cd75e16 100644 --- a/core/src/components/modal/animations/ios.transition.ts +++ b/core/src/components/modal/animations/ios.transition.ts @@ -22,7 +22,7 @@ export const portraitToLandscapeTransition = ( return createAnimation('portrait-to-landscape-transition'); } - const hasCardModal = + const presentingElIsCardModal = presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined; const presentingElRoot = getElementRoot(presentingEl); const bodyEl = document.body; @@ -38,11 +38,9 @@ export const portraitToLandscapeTransition = ( overflow: 'hidden', }); - if (!hasCardModal) { - // Non-card modal: transition from portrait state to landscape state - // Portrait: presentingEl has transform and body has black background - // Landscape: no transform, no body background, modal wrapper opacity changes - + 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')!) @@ -70,19 +68,25 @@ export const portraitToLandscapeTransition = ( baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]); } else { - // Card modal: transition from portrait card state to landscape card state + // 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 transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))'; - const fromTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`; + 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); @@ -109,7 +113,7 @@ export const landscapeToPortraitTransition = ( return createAnimation('landscape-to-portrait-transition'); } - const hasCardModal = + const presentingElIsCardModal = presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined; const presentingElRoot = getElementRoot(presentingEl); const bodyEl = document.body; @@ -125,8 +129,9 @@ export const landscapeToPortraitTransition = ( overflow: 'hidden', }); - if (!hasCardModal) { - // Non-card modal: transition from landscape state to portrait state + 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')!) @@ -143,6 +148,11 @@ export const landscapeToPortraitTransition = ( presentingAnimation .addElement(presentingEl) + .beforeStyles({ + transform: 'translateY(0px) scale(1)', + 'transform-origin': 'top center', + overflow: 'hidden', + }) .afterStyles({ transform: toTransform, 'border-radius': '10px 10px 0 0', @@ -151,25 +161,33 @@ export const landscapeToPortraitTransition = ( 'transform-origin': 'top center', }) .beforeAddWrite(() => bodyEl.style.setProperty('background-color', 'black')) - .fromTo('transform', 'translateY(0px) scale(1)', toTransform) - .fromTo('filter', 'contrast(1)', 'contrast(0.85)') - .fromTo('border-radius', '0px', '10px 10px 0 0'); + .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 { - // Card modal: transition from landscape card state to portrait card state + // 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 transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))'; const fromTransform = `translateY(-10px) scale(${toPresentingScale})`; - const toTransform = `translateY(${transformOffset}) 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); From 8acfb31f4273a4456cbcf5e8c38080ac1b7cb1b8 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 8 Jul 2025 11:11:40 -0700 Subject: [PATCH 08/11] fix(modal): fixing card modal positioning when resetting animation after view change --- core/src/components/modal/modal.tsx | 37 ++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 79a453fbfe9..0d948722831 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -2,7 +2,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, 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'; @@ -1079,12 +1079,43 @@ export class Modal implements ComponentInterface, OverlayInterface { } 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; } - // Reinitialize the swipe-to-close gesture with current state - this.initSwipeToClose(); + // Minimal fix: timing + essential positioning + raf(() => { + this.ensureCorrectModalPosition(); + this.initSwipeToClose(); + }); + } + + private ensureCorrectModalPosition() { + const { el, presentingElement } = this; + const root = getElementRoot(el); + + // Minimal fix: wrapper element and presenting element positioning only + 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 = 0.915; // SwipeToCloseDefaults.MIN_PRESENTING_SCALE + presentingElement.style.transform = `translateY(${transformOffset}) scale(${scale})`; + } else { + presentingElement.style.transform = 'translateY(0px) scale(1)'; + } + } } render() { From 51fb5593b0c63a252f6d628c9835186769f03756 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 9 Jul 2025 09:32:04 -0700 Subject: [PATCH 09/11] fix(lint): running lint fix --- core/src/components/modal/modal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 0d948722831..ed2ba015775 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -988,7 +988,6 @@ export class Modal implements ComponentInterface, OverlayInterface { this.resizeListener = () => { clearTimeout(this.resizeTimeout); this.resizeTimeout = setTimeout(() => { - console.log('View transition triggered by resize'); this.handleViewTransition(); }, 50); // Debounce to avoid excessive calls during active resizing }; @@ -1109,7 +1108,9 @@ export class Modal implements ComponentInterface, OverlayInterface { const isPortrait = window.innerWidth < 768; if (isPortrait) { - const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))'; + const transformOffset = !CSS.supports('width', 'max(0px, 1px)') + ? '30px' + : 'max(30px, var(--ion-safe-area-top))'; const scale = 0.915; // SwipeToCloseDefaults.MIN_PRESENTING_SCALE presentingElement.style.transform = `translateY(${transformOffset}) scale(${scale})`; } else { From d3b968b4439d0cf75a2d435391b6491730986f80 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 9 Jul 2025 10:54:46 -0700 Subject: [PATCH 10/11] chore(modal): comments --- core/src/components/modal/modal.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index ed2ba015775..9a1cf44f889 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -42,7 +42,7 @@ 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'; @@ -1085,7 +1085,8 @@ export class Modal implements ComponentInterface, OverlayInterface { this.animation = undefined; } - // Minimal fix: timing + essential positioning + // 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(); @@ -1096,9 +1097,7 @@ export class Modal implements ComponentInterface, OverlayInterface { const { el, presentingElement } = this; const root = getElementRoot(el); - // Minimal fix: wrapper element and presenting element positioning only const wrapperEl = root.querySelector('.modal-wrapper') as HTMLElement | null; - if (wrapperEl) { wrapperEl.style.transform = 'translateY(0vh)'; wrapperEl.style.opacity = '1'; @@ -1111,7 +1110,7 @@ export class Modal implements ComponentInterface, OverlayInterface { const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))'; - const scale = 0.915; // SwipeToCloseDefaults.MIN_PRESENTING_SCALE + const scale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; presentingElement.style.transform = `translateY(${transformOffset}) scale(${scale})`; } else { presentingElement.style.transform = 'translateY(0px) scale(1)'; From 278c2d7968323772e47abcddb9ad636536ccf5d2 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 9 Jul 2025 11:26:55 -0700 Subject: [PATCH 11/11] refactor(modal): moving to @Listen instead of a manual resize listener --- core/src/components/modal/modal.tsx | 31 +++++++++++++---------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 9a1cf44f889..8f528e658e5 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -1,5 +1,5 @@ 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, getElementRoot } from '@utils/helpers'; @@ -92,7 +92,6 @@ export class Modal implements ComponentInterface, OverlayInterface { private gestureAnimationDismissing = false; // View transition properties for handling portrait/landscape switches - private resizeListener?: () => void; private currentViewIsPortrait?: boolean; private viewTransitionAnimation?: Animation; private resizeTimeout?: any; @@ -268,6 +267,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 @@ -983,16 +995,6 @@ export class Modal implements ComponentInterface, OverlayInterface { // Set initial view state this.currentViewIsPortrait = window.innerWidth < 768; - - // Create debounced resize handler - this.resizeListener = () => { - clearTimeout(this.resizeTimeout); - this.resizeTimeout = setTimeout(() => { - this.handleViewTransition(); - }, 50); // Debounce to avoid excessive calls during active resizing - }; - - window.addEventListener('resize', this.resizeListener); } private handleViewTransition() { @@ -1048,11 +1050,6 @@ export class Modal implements ComponentInterface, OverlayInterface { } private cleanupViewTransitionListener() { - if (this.resizeListener) { - window.removeEventListener('resize', this.resizeListener); - this.resizeListener = undefined; - } - // Clear any pending resize timeout if (this.resizeTimeout) { clearTimeout(this.resizeTimeout);