Skip to content
Open
99 changes: 97 additions & 2 deletions core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
private gesture?: Gesture;
private coreDelegate: FrameworkDelegate = CoreDelegate();
private sheetTransition?: Promise<any>;
private isSheetModal = false;
@State() private isSheetModal = false;
private currentBreakpoint?: number;
private wrapperEl?: HTMLElement;
private backdropEl?: HTMLIonBackdropElement;
Expand Down Expand Up @@ -100,6 +100,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
private parentRemovalObserver?: MutationObserver;
// Cached original parent from before modal is moved to body during presentation
private cachedOriginalParent?: HTMLElement;
// Cached ion-page ancestor for child route passthrough
private cachedPageParent?: HTMLElement | null;

lastFocus?: HTMLElement;
animation?: Animation;
Expand Down Expand Up @@ -644,7 +646,14 @@ export class Modal implements ComponentInterface, OverlayInterface {
window.addEventListener(KEYBOARD_DID_OPEN, this.keyboardOpenCallback);
}

if (this.isSheetModal) {
/**
* Recalculate isSheetModal because framework bindings (e.g., Angular)
* may not have been applied when componentWillLoad ran.
*/
const isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined;
this.isSheetModal = isSheetModal;

if (isSheetModal) {
this.initSheetGesture();
} else if (hasCardModal) {
this.initSwipeToClose();
Expand Down Expand Up @@ -753,6 +762,90 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.moveSheetToBreakpoint = moveSheetToBreakpoint;

this.gesture.enable(true);

/**
* When backdrop interaction is allowed, nested router outlets from child routes
* may block pointer events to parent content. Apply passthrough styles only when
* the modal was the sole content of a child route page.
* See https://github.com/ionic-team/ionic-framework/issues/30700
*/
const backdropNotBlocking = this.showBackdrop === false || this.focusTrap === false || backdropBreakpoint > 0;
if (backdropNotBlocking) {
this.setupChildRoutePassthrough();
}
}

/**
* For sheet modals that allow background interaction, sets up pointer-events
* passthrough on child route page wrappers and nested router outlets.
*/
private setupChildRoutePassthrough() {
// Cache the page parent for cleanup
this.cachedPageParent = this.getOriginalPageParent();
const pageParent = this.cachedPageParent;

// Skip ion-app (controller modals) and pages with other content (inline modals)
if (!pageParent || pageParent.tagName === 'ION-APP') {
return;
}

const hasVisibleContent = Array.from(pageParent.children).some((child) => {
if (child === this.el) return false;
if (child instanceof HTMLElement && window.getComputedStyle(child).display === 'none') return false;
if (child.tagName === 'TEMPLATE' || child.tagName === 'SLOT') return false;
if (child.nodeType === Node.TEXT_NODE && !child.textContent?.trim()) return false;
return true;
});

if (hasVisibleContent) {
return;
}

// Child route case: page only contained the modal
pageParent.classList.add('ion-page-overlay-passthrough');

// Also make nested router outlets passthrough
const routerOutlet = pageParent.parentElement;
if (routerOutlet?.tagName === 'ION-ROUTER-OUTLET' && routerOutlet.parentElement?.tagName !== 'ION-APP') {
routerOutlet.style.setProperty('pointer-events', 'none');
routerOutlet.setAttribute('data-overlay-passthrough', 'true');
}
}

/**
* Finds the ion-page ancestor of the modal's original parent location.
*/
private getOriginalPageParent(): HTMLElement | null {
if (!this.cachedOriginalParent) {
return null;
}

let pageParent: HTMLElement | null = this.cachedOriginalParent;
while (pageParent && !pageParent.classList.contains('ion-page')) {
pageParent = pageParent.parentElement;
}
return pageParent;
}

/**
* Removes passthrough styles added by setupChildRoutePassthrough.
*/
private cleanupChildRoutePassthrough() {
const pageParent = this.cachedPageParent;
if (!pageParent) {
return;
}

pageParent.classList.remove('ion-page-overlay-passthrough');

const routerOutlet = pageParent.parentElement;
if (routerOutlet?.hasAttribute('data-overlay-passthrough')) {
routerOutlet.style.removeProperty('pointer-events');
routerOutlet.removeAttribute('data-overlay-passthrough');
}

// Clear the cached reference
this.cachedPageParent = undefined;
}

private sheetOnDismiss() {
Expand Down Expand Up @@ -862,6 +955,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
this.cleanupViewTransitionListener();
this.cleanupParentRemovalObserver();

this.cleanupChildRoutePassthrough();
}
this.currentBreakpoint = undefined;
this.animation = undefined;
Expand Down
9 changes: 9 additions & 0 deletions core/src/css/core.scss
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,15 @@ html.ios ion-modal.modal-card .ion-page {
z-index: $z-index-page-container;
}

/**
* Allows pointer events to pass through child route page wrappers
* when they only contain a sheet modal that permits background interaction.
* https://github.com/ionic-team/ionic-framework/issues/30700
*/
.ion-page.ion-page-overlay-passthrough {
pointer-events: none;
}

/**
* When making custom dialogs, using
* ion-content is not required. As a result,
Expand Down
28 changes: 20 additions & 8 deletions core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ let lastId = 0;

export const activeAnimations = new WeakMap<OverlayInterface, Animation[]>();

type OverlayWithFocusTrapProps = HTMLIonOverlayElement & {
focusTrap?: boolean;
showBackdrop?: boolean;
backdropBreakpoint?: number;
};

/**
* Determines if the overlay's backdrop is always active (no background interaction).
* Returns false if showBackdrop=false or backdropBreakpoint > 0.
*/
const isBackdropAlwaysActive = (el: OverlayWithFocusTrapProps): boolean => {
return el.showBackdrop !== false && !((el.backdropBreakpoint ?? 0) > 0);
};

const createController = <Opts extends object, HTMLElm>(tagName: string) => {
return {
create(options: Opts): Promise<HTMLElm> {
Expand Down Expand Up @@ -539,11 +553,9 @@ export const present = async <OverlayPresentOptions>(
* view container subtree, skip adding aria-hidden/inert there
* to avoid disabling the overlay.
*/
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
const overlayEl = overlay.el as OverlayWithFocusTrapProps;
const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false;
// Only lock out root content when backdrop is active. Developers relying on showBackdrop=false
// expect background interaction to remain enabled.
const shouldLockRoot = shouldTrapFocus && overlayEl.showBackdrop !== false;
const shouldLockRoot = shouldTrapFocus && isBackdropAlwaysActive(overlayEl);

overlay.presented = true;
overlay.willPresent.emit();
Expand Down Expand Up @@ -680,12 +692,12 @@ export const dismiss = async <OverlayDismissOptions>(
* is dismissed.
*/
const overlaysLockingRoot = presentedOverlays.filter((o) => {
const el = o as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && el.showBackdrop !== false;
const el = o as OverlayWithFocusTrapProps;
return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && isBackdropAlwaysActive(el);
});
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
const overlayEl = overlay.el as OverlayWithFocusTrapProps;
const locksRoot =
overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && overlayEl.showBackdrop !== false;
overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && isBackdropAlwaysActive(overlayEl);

/**
* If this is the last visible overlay that is trapping focus
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test';

/**
* Tests for sheet modals in child routes with showBackdrop=false.
* Parent has buttons + nested outlet; child route contains only the modal.
* See https://github.com/ionic-team/ionic-framework/issues/30700
*/
test.describe('Modals: Inline Sheet in Child Route (standalone)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/standalone/modal-child-route/child');
});

test('should render parent content and child modal', async ({ page }) => {
await expect(page.locator('#increment-btn')).toBeVisible();
await expect(page.locator('#decrement-btn')).toBeVisible();
await expect(page.locator('#background-action-count')).toHaveText('0');
await expect(page.locator('ion-modal.show-modal')).toBeVisible();
await expect(page.locator('#modal-content-loaded')).toBeVisible();
});

test('should allow interacting with parent content while modal is open in child route', async ({ page }) => {
await expect(page.locator('ion-modal.show-modal')).toBeVisible();

await page.locator('#increment-btn').click();
await expect(page.locator('#background-action-count')).toHaveText('1');
});
Comment on lines +21 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was testing this scenario manually and I'm not able to interact with the background. This is happening on Firefox, Chrome, and Safari.

Screen.Recording.2025-12-08.at.11.49.19.AM.mov

Copy link
Member Author

@ShaneK ShaneK Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works great for me in my testing, and worked for kumibrr here, so I'm unsure of what's going on 🤔

Maybe pull and try with my latest changes? That's real weird though!

Screenshot.2025-12-08.at.15.10.34.mp4

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still had issues with the new code but I've approved it since the issue seems to be only on my end.


test('should allow multiple interactions with parent content while modal is open', async ({ page }) => {
await expect(page.locator('ion-modal.show-modal')).toBeVisible();

await page.locator('#increment-btn').click();
await page.locator('#increment-btn').click();
await expect(page.locator('#background-action-count')).toHaveText('2');

await page.locator('#decrement-btn').click();
await expect(page.locator('#background-action-count')).toHaveText('1');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export const routes: Routes = [
{ path: 'modal', loadComponent: () => import('../modal/modal.component').then(c => c.ModalComponent) },
{ path: 'modal-sheet-inline', loadComponent: () => import('../modal-sheet-inline/modal-sheet-inline.component').then(c => c.ModalSheetInlineComponent) },
{ path: 'modal-dynamic-wrapper', loadComponent: () => import('../modal-dynamic-wrapper/modal-dynamic-wrapper.component').then(c => c.ModalDynamicWrapperComponent) },
{ path: 'modal-child-route', redirectTo: '/standalone/modal-child-route/child', pathMatch: 'full' },
{
path: 'modal-child-route',
loadComponent: () => import('../modal-child-route/modal-child-route-parent.component').then(c => c.ModalChildRouteParentComponent),
children: [
{ path: 'child', loadComponent: () => import('../modal-child-route/modal-child-route-child.component').then(c => c.ModalChildRouteChildComponent) },
]
},
{ path: 'programmatic-modal', loadComponent: () => import('../programmatic-modal/programmatic-modal.component').then(c => c.ProgrammaticModalComponent) },
{ path: 'router-outlet', loadComponent: () => import('../router-outlet/router-outlet.component').then(c => c.RouterOutletComponent) },
{ path: 'back-button', loadComponent: () => import('../back-button/back-button.component').then(c => c.BackButtonComponent) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@
Modal Dynamic Wrapper Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/modal-child-route">
<ion-label>
Modal Child Route Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/programmatic-modal">
<ion-label>
Programmatic Modal Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { IonContent, IonHeader, IonModal, IonTitle, IonToolbar } from '@ionic/angular/standalone';

/**
* Child route component containing only the sheet modal with showBackdrop=false.
* Verifies issue https://github.com/ionic-team/ionic-framework/issues/30700
*/
@Component({
selector: 'app-modal-child-route-child',
template: `
<ion-modal
[isOpen]="true"
[breakpoints]="[0.2, 0.5, 0.7]"
[initialBreakpoint]="0.5"
[showBackdrop]="false"
>
<ng-template>
<ion-header>
<ion-toolbar>
<ion-title>Modal in Child Route</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p id="modal-content-loaded">Modal content loaded in child route</p>
</ion-content>
</ng-template>
</ion-modal>
`,
standalone: true,
imports: [CommonModule, IonContent, IonHeader, IonModal, IonTitle, IonToolbar],
})
export class ModalChildRouteChildComponent {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Component } from '@angular/core';
import { IonButton, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar } from '@ionic/angular/standalone';

/**
* Parent with interactive buttons and nested outlet for child route modal.
* See https://github.com/ionic-team/ionic-framework/issues/30700
*/
@Component({
selector: 'app-modal-child-route-parent',
template: `
<ion-header>
<ion-toolbar>
<ion-title>Parent Page with Nested Route</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<ion-button id="decrement-btn" (click)="decrement()">-</ion-button>
<p id="background-action-count">{{ count }}</p>
<ion-button id="increment-btn" (click)="increment()">+</ion-button>
</div>
<ion-router-outlet></ion-router-outlet>
</ion-content>
`,
standalone: true,
imports: [IonButton, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar],
})
export class ModalChildRouteParentComponent {
count = 0;

increment() {
this.count++;
}

decrement() {
this.count--;
}
}
Loading
Loading