Skip to content

Commit b9e3cf0

Browse files
authored
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](#30563), which added root-locking behavior that didn't account for modals that allow background interaction. A partial fix in [#30689](#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 ```
1 parent 99dcf38 commit b9e3cf0

File tree

12 files changed

+365
-12
lines changed

12 files changed

+365
-12
lines changed

core/src/components/modal/modal.tsx

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
7171
private gesture?: Gesture;
7272
private coreDelegate: FrameworkDelegate = CoreDelegate();
7373
private sheetTransition?: Promise<any>;
74-
private isSheetModal = false;
74+
@State() private isSheetModal = false;
7575
private currentBreakpoint?: number;
7676
private wrapperEl?: HTMLElement;
7777
private backdropEl?: HTMLIonBackdropElement;
@@ -100,6 +100,8 @@ 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+
// Cached ion-page ancestor for child route passthrough
104+
private cachedPageParent?: HTMLElement | null;
103105

104106
lastFocus?: HTMLElement;
105107
animation?: Animation;
@@ -644,7 +646,14 @@ export class Modal implements ComponentInterface, OverlayInterface {
644646
window.addEventListener(KEYBOARD_DID_OPEN, this.keyboardOpenCallback);
645647
}
646648

647-
if (this.isSheetModal) {
649+
/**
650+
* Recalculate isSheetModal 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) {
648657
this.initSheetGesture();
649658
} else if (hasCardModal) {
650659
this.initSwipeToClose();
@@ -753,6 +762,91 @@ export class Modal implements ComponentInterface, OverlayInterface {
753762
this.moveSheetToBreakpoint = moveSheetToBreakpoint;
754763

755764
this.gesture.enable(true);
765+
766+
/**
767+
* When backdrop interaction is allowed, nested router outlets from child routes
768+
* may block pointer events to parent content. Apply passthrough styles only when
769+
* the modal was the sole content of a child route page.
770+
* See https://github.com/ionic-team/ionic-framework/issues/30700
771+
*/
772+
const backdropNotBlocking = this.showBackdrop === false || this.focusTrap === false || backdropBreakpoint > 0;
773+
if (backdropNotBlocking) {
774+
this.setupChildRoutePassthrough();
775+
}
776+
}
777+
778+
/**
779+
* For sheet modals that allow background interaction, sets up pointer-events
780+
* passthrough on child route page wrappers and nested router outlets.
781+
*/
782+
private setupChildRoutePassthrough() {
783+
// Cache the page parent for cleanup
784+
this.cachedPageParent = this.getOriginalPageParent();
785+
const pageParent = this.cachedPageParent;
786+
787+
// Skip ion-app (controller modals) and pages with visible sibling content next to the modal
788+
if (!pageParent || pageParent.tagName === 'ION-APP') {
789+
return;
790+
}
791+
792+
const hasVisibleContent = Array.from(pageParent.children).some(
793+
(child) =>
794+
child !== this.el &&
795+
!(child instanceof HTMLElement && window.getComputedStyle(child).display === 'none') &&
796+
child.tagName !== 'TEMPLATE' &&
797+
child.tagName !== 'SLOT' &&
798+
!(child.nodeType === Node.TEXT_NODE && !child.textContent?.trim())
799+
);
800+
801+
if (hasVisibleContent) {
802+
return;
803+
}
804+
805+
// Child route case: page only contained the modal
806+
pageParent.classList.add('ion-page-overlay-passthrough');
807+
808+
// Also make nested router outlets passthrough
809+
const routerOutlet = pageParent.parentElement;
810+
if (routerOutlet?.tagName === 'ION-ROUTER-OUTLET' && routerOutlet.parentElement?.tagName !== 'ION-APP') {
811+
routerOutlet.style.setProperty('pointer-events', 'none');
812+
routerOutlet.setAttribute('data-overlay-passthrough', 'true');
813+
}
814+
}
815+
816+
/**
817+
* Finds the ion-page ancestor of the modal's original parent location.
818+
*/
819+
private getOriginalPageParent(): HTMLElement | null {
820+
if (!this.cachedOriginalParent) {
821+
return null;
822+
}
823+
824+
let pageParent: HTMLElement | null = this.cachedOriginalParent;
825+
while (pageParent && !pageParent.classList.contains('ion-page')) {
826+
pageParent = pageParent.parentElement;
827+
}
828+
return pageParent;
829+
}
830+
831+
/**
832+
* Removes passthrough styles added by setupChildRoutePassthrough.
833+
*/
834+
private cleanupChildRoutePassthrough() {
835+
const pageParent = this.cachedPageParent;
836+
if (!pageParent) {
837+
return;
838+
}
839+
840+
pageParent.classList.remove('ion-page-overlay-passthrough');
841+
842+
const routerOutlet = pageParent.parentElement;
843+
if (routerOutlet?.hasAttribute('data-overlay-passthrough')) {
844+
routerOutlet.style.removeProperty('pointer-events');
845+
routerOutlet.removeAttribute('data-overlay-passthrough');
846+
}
847+
848+
// Clear the cached reference
849+
this.cachedPageParent = undefined;
756850
}
757851

758852
private sheetOnDismiss() {
@@ -862,6 +956,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
862956
}
863957
this.cleanupViewTransitionListener();
864958
this.cleanupParentRemovalObserver();
959+
960+
this.cleanupChildRoutePassthrough();
865961
}
866962
this.currentBreakpoint = undefined;
867963
this.animation = undefined;

core/src/css/core.scss

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,15 @@ html.ios ion-modal.modal-card .ion-page {
181181
z-index: $z-index-page-container;
182182
}
183183

184+
/**
185+
* Allows pointer events to pass through child route page wrappers
186+
* when they only contain a sheet modal that permits background interaction.
187+
* https://github.com/ionic-team/ionic-framework/issues/30700
188+
*/
189+
.ion-page.ion-page-overlay-passthrough {
190+
pointer-events: none;
191+
}
192+
184193
/**
185194
* When making custom dialogs, using
186195
* ion-content is not required. As a result,

core/src/utils/overlays.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ let lastId = 0;
3838

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

41+
type OverlayWithFocusTrapProps = HTMLIonOverlayElement & {
42+
focusTrap?: boolean;
43+
showBackdrop?: boolean;
44+
backdropBreakpoint?: number;
45+
};
46+
47+
/**
48+
* Determines if the overlay's backdrop is always blocking (no background interaction).
49+
* Returns false if showBackdrop=false or backdropBreakpoint > 0.
50+
*/
51+
const isBackdropAlwaysBlocking = (el: OverlayWithFocusTrapProps): boolean => {
52+
return el.showBackdrop !== false && !((el.backdropBreakpoint ?? 0) > 0);
53+
};
54+
4155
const createController = <Opts extends object, HTMLElm>(tagName: string) => {
4256
return {
4357
create(options: Opts): Promise<HTMLElm> {
@@ -539,11 +553,9 @@ export const present = async <OverlayPresentOptions>(
539553
* view container subtree, skip adding aria-hidden/inert there
540554
* to avoid disabling the overlay.
541555
*/
542-
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
556+
const overlayEl = overlay.el as OverlayWithFocusTrapProps;
543557
const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false;
544-
// Only lock out root content when backdrop is active. Developers relying on showBackdrop=false
545-
// expect background interaction to remain enabled.
546-
const shouldLockRoot = shouldTrapFocus && overlayEl.showBackdrop !== false;
558+
const shouldLockRoot = shouldTrapFocus && isBackdropAlwaysBlocking(overlayEl);
547559

548560
overlay.presented = true;
549561
overlay.willPresent.emit();
@@ -680,12 +692,12 @@ export const dismiss = async <OverlayDismissOptions>(
680692
* is dismissed.
681693
*/
682694
const overlaysLockingRoot = presentedOverlays.filter((o) => {
683-
const el = o as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
684-
return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && el.showBackdrop !== false;
695+
const el = o as OverlayWithFocusTrapProps;
696+
return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && isBackdropAlwaysBlocking(el);
685697
});
686-
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
698+
const overlayEl = overlay.el as OverlayWithFocusTrapProps;
687699
const locksRoot =
688-
overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && overlayEl.showBackdrop !== false;
700+
overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && isBackdropAlwaysBlocking(overlayEl);
689701

690702
/**
691703
* If this is the last visible overlay that is trapping focus
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
/**
4+
* Tests for sheet modals in child routes with showBackdrop=false.
5+
* Parent has buttons + nested outlet; child route contains only the modal.
6+
* See https://github.com/ionic-team/ionic-framework/issues/30700
7+
*/
8+
test.describe('Modals: Inline Sheet in Child Route (standalone)', () => {
9+
test.beforeEach(async ({ page }) => {
10+
await page.goto('/standalone/modal-child-route/child');
11+
});
12+
13+
test('should render parent content and child modal', async ({ page }) => {
14+
await expect(page.locator('#increment-btn')).toBeVisible();
15+
await expect(page.locator('#decrement-btn')).toBeVisible();
16+
await expect(page.locator('#background-action-count')).toHaveText('0');
17+
await expect(page.locator('ion-modal.show-modal')).toBeVisible();
18+
await expect(page.locator('#modal-content-loaded')).toBeVisible();
19+
});
20+
21+
test('should allow interacting with parent content while modal is open in child route', async ({ page }) => {
22+
await expect(page.locator('ion-modal.show-modal')).toBeVisible();
23+
24+
await page.locator('#increment-btn').click();
25+
await expect(page.locator('#background-action-count')).toHaveText('1');
26+
});
27+
28+
test('should allow multiple interactions with parent content while modal is open', async ({ page }) => {
29+
await expect(page.locator('ion-modal.show-modal')).toBeVisible();
30+
31+
await page.locator('#increment-btn').click();
32+
await page.locator('#increment-btn').click();
33+
await expect(page.locator('#background-action-count')).toHaveText('2');
34+
35+
await page.locator('#decrement-btn').click();
36+
await expect(page.locator('#background-action-count')).toHaveText('1');
37+
});
38+
});

packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ export const routes: Routes = [
1313
{ path: 'modal', loadComponent: () => import('../modal/modal.component').then(c => c.ModalComponent) },
1414
{ path: 'modal-sheet-inline', loadComponent: () => import('../modal-sheet-inline/modal-sheet-inline.component').then(c => c.ModalSheetInlineComponent) },
1515
{ path: 'modal-dynamic-wrapper', loadComponent: () => import('../modal-dynamic-wrapper/modal-dynamic-wrapper.component').then(c => c.ModalDynamicWrapperComponent) },
16+
{ path: 'modal-child-route', redirectTo: '/standalone/modal-child-route/child', pathMatch: 'full' },
17+
{
18+
path: 'modal-child-route',
19+
loadComponent: () => import('../modal-child-route/modal-child-route-parent.component').then(c => c.ModalChildRouteParentComponent),
20+
children: [
21+
{ path: 'child', loadComponent: () => import('../modal-child-route/modal-child-route-child.component').then(c => c.ModalChildRouteChildComponent) },
22+
]
23+
},
1624
{ path: 'programmatic-modal', loadComponent: () => import('../programmatic-modal/programmatic-modal.component').then(c => c.ProgrammaticModalComponent) },
1725
{ path: 'router-outlet', loadComponent: () => import('../router-outlet/router-outlet.component').then(c => c.RouterOutletComponent) },
1826
{ path: 'back-button', loadComponent: () => import('../back-button/back-button.component').then(c => c.BackButtonComponent) },

packages/angular/test/base/src/app/standalone/home-page/home-page.component.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@
100100
Modal Dynamic Wrapper Test
101101
</ion-label>
102102
</ion-item>
103+
<ion-item routerLink="/standalone/modal-child-route">
104+
<ion-label>
105+
Modal Child Route Test
106+
</ion-label>
107+
</ion-item>
103108
<ion-item routerLink="/standalone/programmatic-modal">
104109
<ion-label>
105110
Programmatic Modal Test
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { CommonModule } from '@angular/common';
2+
import { Component } from '@angular/core';
3+
import { IonContent, IonHeader, IonModal, IonTitle, IonToolbar } from '@ionic/angular/standalone';
4+
5+
/**
6+
* Child route component containing only the sheet modal with showBackdrop=false.
7+
* Verifies issue https://github.com/ionic-team/ionic-framework/issues/30700
8+
*/
9+
@Component({
10+
selector: 'app-modal-child-route-child',
11+
template: `
12+
<ion-modal
13+
[isOpen]="true"
14+
[breakpoints]="[0.2, 0.5, 0.7]"
15+
[initialBreakpoint]="0.5"
16+
[showBackdrop]="false"
17+
>
18+
<ng-template>
19+
<ion-header>
20+
<ion-toolbar>
21+
<ion-title>Modal in Child Route</ion-title>
22+
</ion-toolbar>
23+
</ion-header>
24+
<ion-content class="ion-padding">
25+
<p id="modal-content-loaded">Modal content loaded in child route</p>
26+
</ion-content>
27+
</ng-template>
28+
</ion-modal>
29+
`,
30+
standalone: true,
31+
imports: [CommonModule, IonContent, IonHeader, IonModal, IonTitle, IonToolbar],
32+
})
33+
export class ModalChildRouteChildComponent {}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Component } from '@angular/core';
2+
import { IonButton, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar } from '@ionic/angular/standalone';
3+
4+
/**
5+
* Parent with interactive buttons and nested outlet for child route modal.
6+
* See https://github.com/ionic-team/ionic-framework/issues/30700
7+
*/
8+
@Component({
9+
selector: 'app-modal-child-route-parent',
10+
template: `
11+
<ion-header>
12+
<ion-toolbar>
13+
<ion-title>Parent Page with Nested Route</ion-title>
14+
</ion-toolbar>
15+
</ion-header>
16+
<ion-content class="ion-padding">
17+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
18+
<ion-button id="decrement-btn" (click)="decrement()">-</ion-button>
19+
<p id="background-action-count">{{ count }}</p>
20+
<ion-button id="increment-btn" (click)="increment()">+</ion-button>
21+
</div>
22+
<ion-router-outlet></ion-router-outlet>
23+
</ion-content>
24+
`,
25+
standalone: true,
26+
imports: [IonButton, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar],
27+
})
28+
export class ModalChildRouteParentComponent {
29+
count = 0;
30+
31+
increment() {
32+
this.count++;
33+
}
34+
35+
decrement() {
36+
this.count--;
37+
}
38+
}

0 commit comments

Comments
 (0)