fix(modal): allow interaction with parent content through sheet modals in child routes (#30839)

Issue number: resolves #30700

---------

## What is the current behavior?

When a sheet modal with showBackdrop=false is rendered in a child route
(nested ion-router-outlet), the parent content becomes non-interactive.
Clicks on buttons or other interactive elements in the parent component
are blocked, even though showBackdrop=false should allow background
interaction.

Two separate issues contributed to this bug:
1. **Root locking with `backdropBreakpoint`**: The `shouldLockRoot`
logic in `overlays.ts` didn't account for `backdropBreakpoint`. Modals
with `backdropBreakpoint > 0` were still locking the root with
`aria-hidden`, even though developers expect background interaction when
the modal is below the backdrop breakpoint.
2. **Child route wrapper blocking**: When a modal is in a child route,
the child route's page wrapper (`ion-page`) and its parent
`ion-router-outlet` remain in the DOM with `position: absolute` covering
the viewport. Even after the modal is moved to `ion-app` and has
`pointer-events: none`, these wrapper elements block clicks to the
parent page's content.

This issue stems from
[#30563](https://github.com/ionic-team/ionic-framework/pull/30563),
which added root-locking behavior that didn't account for modals that
allow background interaction. A partial fix in
[#30689](https://github.com/ionic-team/ionic-framework/pull/30689)
partially addressed `showBackdrop=false` and `focusTrap=false`, but
missed `backdropBreakpoint`.

## What is the new behavior?

Sheet modals with showBackdrop=false or focusTrap=false now correctly
allow interaction with parent content when the modal is in a child
route.
Improvements:
- Recalculates isSheetModal in present() to handle Angular binding
timing
- Sets pointer-events: none on the modal element and its original parent
elements when background interaction should be allowed
- Cleans up pointer-events on dismiss
- Adds regression tests

## Does this introduce a breaking change?

- [ ] Yes
- [X] No


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Dev build:
```
8.7.12-dev.11765060985.14ad27fb
```
This commit is contained in:
Shane
2025-12-10 13:08:48 -08:00
committed by GitHub
parent 99dcf3810a
commit b9e3cf0f5a
12 changed files with 365 additions and 12 deletions

View File

@@ -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;
@@ -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;
@@ -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();
@@ -753,6 +762,91 @@ 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 visible sibling content next to the modal
if (!pageParent || pageParent.tagName === 'ION-APP') {
return;
}
const hasVisibleContent = Array.from(pageParent.children).some(
(child) =>
child !== this.el &&
!(child instanceof HTMLElement && window.getComputedStyle(child).display === 'none') &&
child.tagName !== 'TEMPLATE' &&
child.tagName !== 'SLOT' &&
!(child.nodeType === Node.TEXT_NODE && !child.textContent?.trim())
);
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() {
@@ -862,6 +956,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
this.cleanupViewTransitionListener();
this.cleanupParentRemovalObserver();
this.cleanupChildRoutePassthrough();
}
this.currentBreakpoint = undefined;
this.animation = undefined;

View File

@@ -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,

View File

@@ -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 blocking (no background interaction).
* Returns false if showBackdrop=false or backdropBreakpoint > 0.
*/
const isBackdropAlwaysBlocking = (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> {
@@ -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 && isBackdropAlwaysBlocking(overlayEl);
overlay.presented = true;
overlay.willPresent.emit();
@@ -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 && isBackdropAlwaysBlocking(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 && isBackdropAlwaysBlocking(overlayEl);
/**
* If this is the last visible overlay that is trapping focus

View File

@@ -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');
});
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');
});
});

View File

@@ -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) },

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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--;
}
}

View File

@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import {
IonButton,
IonContent,
IonHeader,
IonModal,
IonPage,
IonRouterOutlet,
IonTitle,
IonToolbar,
} from '@ionic/react';
import { Route } from 'react-router';
/**
* Parent component with counter buttons and nested router outlet.
* This reproduces the issue from https://github.com/ionic-team/ionic-framework/issues/30700
* where sheet modals in child routes with showBackdrop=false block interaction with parent content.
*/
const ModalSheetChildRouteParent: React.FC = () => {
const [count, setCount] = useState(0);
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>Parent Page with Nested Route</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<IonButton id="decrement-btn" onClick={() => setCount((c) => c - 1)}>
-
</IonButton>
<p id="background-action-count">{count}</p>
<IonButton id="increment-btn" onClick={() => setCount((c) => c + 1)}>
+
</IonButton>
</div>
</IonContent>
<IonRouterOutlet>
<Route path="/overlay-components/modal-sheet-child-route/child" component={ModalSheetChildRouteChild} />
</IonRouterOutlet>
</IonPage>
);
};
const ModalSheetChildRouteChild: React.FC = () => {
return (
<IonPage>
<IonModal
isOpen={true}
breakpoints={[0.2, 0.5, 0.7]}
initialBreakpoint={0.5}
showBackdrop={false}
>
<IonHeader>
<IonToolbar>
<IonTitle>Modal in Child Route</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<p id="modal-content-loaded">Modal content loaded in child route</p>
</IonContent>
</IonModal>
</IonPage>
);
};
export default ModalSheetChildRouteParent;

View File

@@ -15,6 +15,7 @@ import AlertComponent from './AlertComponent';
import LoadingComponent from './LoadingComponent';
import ModalComponent from './ModalComponent';
import ModalFocusTrap from './ModalFocusTrap';
import ModalSheetChildRoute from './ModalSheetChildRoute';
import ModalTeleport from './ModalTeleport';
import PickerComponent from './PickerComponent';
import PopoverComponent from './PopoverComponent';
@@ -32,6 +33,7 @@ const OverlayHooks: React.FC<OverlayHooksProps> = () => {
<Route path="/overlay-components/loading" component={LoadingComponent} />
<Route path="/overlay-components/modal-basic" component={ModalComponent} />
<Route path="/overlay-components/modal-focus-trap" component={ModalFocusTrap} />
<Route path="/overlay-components/modal-sheet-child-route" component={ModalSheetChildRoute} />
<Route path="/overlay-components/modal-teleport" component={ModalTeleport} />
<Route path="/overlay-components/picker" component={PickerComponent} />
<Route path="/overlay-components/popover" component={PopoverComponent} />
@@ -62,6 +64,10 @@ const OverlayHooks: React.FC<OverlayHooksProps> = () => {
<IonIcon icon={star} />
<IonLabel>Modal Teleport</IonLabel>
</IonTabButton>
<IonTabButton tab="modalSheetChildRoute" href="/overlay-components/modal-sheet-child-route/child">
<IonIcon icon={star} />
<IonLabel>Sheet Child</IonLabel>
</IonTabButton>
<IonTabButton tab="picker" href="/overlay-components/picker">
<IonIcon icon={logoIonic} />
<IonLabel>Picker</IonLabel>

View File

@@ -5,7 +5,9 @@ describe('IonModal: focusTrap regression', () => {
it('should allow interacting with background when focusTrap=false', () => {
cy.get('#open-non-trapped-modal').click();
cy.get('ion-modal').should('be.visible');
// Use 'exist' instead of 'be.visible' because the modal has pointer-events: none
// to allow background interaction, which Cypress interprets as "covered"
cy.get('ion-modal.show-modal').should('exist');
cy.get('#background-action').click();
cy.get('#background-action-count').should('have.text', '1');
@@ -13,7 +15,7 @@ describe('IonModal: focusTrap regression', () => {
it('should prevent interacting with background when focusTrap=true', () => {
cy.get('#open-trapped-modal').click();
cy.get('ion-modal').should('be.visible');
cy.get('ion-modal.show-modal').should('be.visible');
// Ensure backdrop is active and capturing pointer events
cy.get('ion-backdrop').should('exist');

View File

@@ -0,0 +1,37 @@
/**
* Tests for sheet modals in child routes with showBackdrop=false.
* See https://github.com/ionic-team/ionic-framework/issues/30700
*/
describe('IonModal: Sheet in Child Route with Nested Routing', () => {
beforeEach(() => {
cy.visit('/overlay-components/modal-sheet-child-route/child');
});
it('should render parent content and child modal', () => {
cy.get('#increment-btn').should('exist');
cy.get('#decrement-btn').should('exist');
cy.get('#background-action-count').should('have.text', '0');
cy.get('ion-modal.show-modal').should('exist');
cy.get('#modal-content-loaded').should('exist');
});
it('should allow interacting with parent content while modal is open in child route', () => {
// Wait for modal to be presented
cy.get('ion-modal.show-modal').should('exist');
// Click the increment button in the parent content
cy.get('#increment-btn').click();
cy.get('#background-action-count').should('have.text', '1');
});
it('should allow multiple interactions with parent content while modal is open', () => {
cy.get('ion-modal.show-modal').should('exist');
cy.get('#increment-btn').click();
cy.get('#increment-btn').click();
cy.get('#background-action-count').should('have.text', '2');
cy.get('#decrement-btn').click();
cy.get('#background-action-count').should('have.text', '1');
});
});