@@ -100,8 +100,6 @@ export class Modal implements ComponentInterface, OverlayInterface {
100100 private parentRemovalObserver ?: MutationObserver ;
101101 // Cached original parent from before modal is moved to body during presentation
102102 private cachedOriginalParent ?: HTMLElement ;
103- // Elements that had pointer-events disabled for background interaction
104- private pointerEventsDisabledElements : HTMLElement [ ] = [ ] ;
105103
106104 lastFocus ?: HTMLElement ;
107105 animation ?: Animation ;
@@ -646,14 +644,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
646644 window . addEventListener ( KEYBOARD_DID_OPEN , this . keyboardOpenCallback ) ;
647645 }
648646
649- /**
650- * Recalculate isSheetModal here because framework bindings (e.g., Angular)
651- * may not have been applied when componentWillLoad ran.
652- */
653- const isSheetModal = this . breakpoints !== undefined && this . initialBreakpoint !== undefined ;
654- this . isSheetModal = isSheetModal ;
655-
656- if ( isSheetModal ) {
647+ if ( this . isSheetModal ) {
657648 this . initSheetGesture ( ) ;
658649 } else if ( hasCardModal ) {
659650 this . initSwipeToClose ( ) ;
@@ -764,56 +755,82 @@ export class Modal implements ComponentInterface, OverlayInterface {
764755 this . gesture . enable ( true ) ;
765756
766757 /**
767- * When the backdrop doesn't block pointer events (showBackdrop=false,
768- * focusTrap=false, or backdropBreakpoint > 0), the modal's original parent
769- * may block pointer events after the modal is moved to ion-app. This only
770- * applies when the modal is in a child route (detected by the modal being
771- * inside a route wrapper like ion-page). Disable pointer-events on the child
772- * route's wrapper elements up to (and including) the first ion-router-outlet.
773- * We stop there because parent elements may contain sibling content that
774- * should remain interactive.
758+ * When backdrop interaction is allowed, nested router outlets from child routes
759+ * may block pointer events to parent content. Apply passthrough styles only when
760+ * the modal was the sole content of a child route page.
775761 * See https://github.com/ionic-team/ionic-framework/issues/30700
776762 */
777- const backdropNotBlocking = this . showBackdrop === false || this . focusTrap === false || this . backdropBreakpoint > 0 ;
778- if ( backdropNotBlocking && this . cachedOriginalParent ) {
779- // Find the first meaningful parent (skip template and other non-semantic wrappers).
780- // In Ionic React, modals are wrapped in a <template> element.
781- let semanticParent : HTMLElement | null = this . cachedOriginalParent ;
782- while ( semanticParent && ( semanticParent . tagName === 'TEMPLATE' || semanticParent . tagName === 'SLOT' ) ) {
783- semanticParent = semanticParent . parentElement ;
784- }
763+ const backdropNotBlocking = this . showBackdrop === false || this . focusTrap === false || backdropBreakpoint > 0 ;
764+ if ( backdropNotBlocking ) {
765+ this . setupChildRoutePassthrough ( ) ;
766+ }
767+ }
785768
786- // Check if the modal is inside a route wrapper (ion-page or div.ion-page)
787- // If the modal is inside ion-content or other content containers, this fix doesn't apply
788- const parentIsRouteWrapper =
789- semanticParent && ( semanticParent . tagName === 'ION-PAGE' || semanticParent . classList . contains ( 'ion-page' ) ) ;
790-
791- if ( parentIsRouteWrapper && semanticParent ) {
792- this . pointerEventsDisabledElements = [ ] ;
793- let current : HTMLElement | null = semanticParent ;
794-
795- while ( current && current . tagName !== 'ION-APP' ) {
796- const tagName = current . tagName ;
797- // Check for ion-page tag or elements with ion-page class
798- // (React renders IonPage as div.ion-page, not ion-page tag)
799- const isIonPage = tagName === 'ION-PAGE' || current . classList . contains ( 'ion-page' ) ;
800- const isRouterOutlet = tagName === 'ION-ROUTER-OUTLET' ;
801- const isNav = tagName === 'ION-NAV' ;
802-
803- if ( isIonPage || isRouterOutlet || isNav ) {
804- current . style . setProperty ( 'pointer-events' , 'none' ) ;
805- this . pointerEventsDisabledElements . push ( current ) ;
806- }
769+ /**
770+ * For sheet modals that allow background interaction, sets up pointer-events
771+ * passthrough on child route page wrappers and nested router outlets.
772+ */
773+ private setupChildRoutePassthrough ( ) {
774+ const pageParent = this . getOriginalPageParent ( ) ;
807775
808- // Stop after processing the first ion-router-outlet - parent elements
809- // may contain sibling content (like buttons) that should remain interactive
810- if ( isRouterOutlet ) {
811- break ;
812- }
776+ // Skip ion-app (controller modals) and pages with other content (inline modals)
777+ if ( ! pageParent || pageParent . tagName === 'ION-APP' ) {
778+ return ;
779+ }
813780
814- current = current . parentElement ;
815- }
816- }
781+ const hasVisibleContent = Array . from ( pageParent . children ) . some ( ( child ) => {
782+ if ( child === this . el ) return false ;
783+ if ( child instanceof HTMLElement && window . getComputedStyle ( child ) . display === 'none' ) return false ;
784+ if ( child . tagName === 'TEMPLATE' || child . tagName === 'SLOT' ) return false ;
785+ if ( child . nodeType === Node . TEXT_NODE && ! child . textContent ?. trim ( ) ) return false ;
786+ return true ;
787+ } ) ;
788+
789+ if ( hasVisibleContent ) {
790+ return ;
791+ }
792+
793+ // Child route case: page only contained the modal
794+ pageParent . classList . add ( 'ion-page-overlay-passthrough' ) ;
795+
796+ // Also make nested router outlets passthrough
797+ const routerOutlet = pageParent . parentElement ;
798+ if ( routerOutlet ?. tagName === 'ION-ROUTER-OUTLET' && routerOutlet . parentElement ?. tagName !== 'ION-APP' ) {
799+ routerOutlet . style . setProperty ( 'pointer-events' , 'none' ) ;
800+ routerOutlet . setAttribute ( 'data-overlay-passthrough' , 'true' ) ;
801+ }
802+ }
803+
804+ /**
805+ * Finds the ion-page ancestor of the modal's original parent location.
806+ */
807+ private getOriginalPageParent ( ) : HTMLElement | null {
808+ if ( ! this . cachedOriginalParent ) {
809+ return null ;
810+ }
811+
812+ let pageParent : HTMLElement | null = this . cachedOriginalParent ;
813+ while ( pageParent && ! pageParent . classList . contains ( 'ion-page' ) ) {
814+ pageParent = pageParent . parentElement ;
815+ }
816+ return pageParent ;
817+ }
818+
819+ /**
820+ * Removes passthrough styles added by setupChildRoutePassthrough.
821+ */
822+ private cleanupChildRoutePassthrough ( ) {
823+ const pageParent = this . getOriginalPageParent ( ) ;
824+ if ( ! pageParent ) {
825+ return ;
826+ }
827+
828+ pageParent . classList . remove ( 'ion-page-overlay-passthrough' ) ;
829+
830+ const routerOutlet = pageParent . parentElement ;
831+ if ( routerOutlet ?. hasAttribute ( 'data-overlay-passthrough' ) ) {
832+ routerOutlet . style . removeProperty ( 'pointer-events' ) ;
833+ routerOutlet . removeAttribute ( 'data-overlay-passthrough' ) ;
817834 }
818835 }
819836
@@ -925,13 +942,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
925942 this . cleanupViewTransitionListener ( ) ;
926943 this . cleanupParentRemovalObserver ( ) ;
927944
928- /**
929- * Clean up pointer-events changes made in initSheetGesture.
930- */
931- for ( const element of this . pointerEventsDisabledElements ) {
932- element . style . removeProperty ( 'pointer-events' ) ;
933- }
934- this . pointerEventsDisabledElements = [ ] ;
945+ this . cleanupChildRoutePassthrough ( ) ;
935946 }
936947 this . currentBreakpoint = undefined ;
937948 this . animation = undefined ;
0 commit comments