Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions core/src/components/modal/modal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ ion-backdrop {
pointer-events: auto;
}

/**
* When focus trap is disabled on a visible modal, allow clicks to pass through
* to content behind it. The .modal-wrapper retains pointer-events: auto so
* modal content remains interactive. We only apply this when show-modal is
* present to avoid affecting hidden modals that haven't presented.
*/
:host(.ion-disable-focus-trap.show-modal) {
pointer-events: none;
}

:host(.ion-disable-focus-trap.show-modal) ion-backdrop {
pointer-events: none;
}

:host(.overlay-hidden) {
display: none;
}
Expand Down
72 changes: 71 additions & 1 deletion core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
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;
// Elements that had pointer-events disabled for background interaction
private pointerEventsDisabledElements: HTMLElement[] = [];

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 here 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,59 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.moveSheetToBreakpoint = moveSheetToBreakpoint;

this.gesture.enable(true);

/**
* When the backdrop doesn't block pointer events (showBackdrop=false,
* focusTrap=false, or backdropBreakpoint > 0), the modal's original parent
* may block pointer events after the modal is moved to ion-app. This only
* applies when the modal is in a child route (detected by the modal being
* inside a route wrapper like ion-page). Disable pointer-events on the child
* route's wrapper elements up to (and including) the first ion-router-outlet.
* We stop there because parent elements may contain sibling content that
* should remain interactive.
* See https://github.com/ionic-team/ionic-framework/issues/30700
*/
const backdropNotBlocking = this.showBackdrop === false || this.focusTrap === false || this.backdropBreakpoint > 0;
if (backdropNotBlocking && this.cachedOriginalParent) {
// Find the first meaningful parent (skip template and other non-semantic wrappers).
// In Ionic React, modals are wrapped in a <template> element.
let semanticParent: HTMLElement | null = this.cachedOriginalParent;
while (semanticParent && (semanticParent.tagName === 'TEMPLATE' || semanticParent.tagName === 'SLOT')) {
semanticParent = semanticParent.parentElement;
}

// Check if the modal is inside a route wrapper (ion-page or div.ion-page)
// If the modal is inside ion-content or other content containers, this fix doesn't apply
const parentIsRouteWrapper =
semanticParent && (semanticParent.tagName === 'ION-PAGE' || semanticParent.classList.contains('ion-page'));

if (parentIsRouteWrapper && semanticParent) {
this.pointerEventsDisabledElements = [];
let current: HTMLElement | null = semanticParent;

while (current && current.tagName !== 'ION-APP') {
const tagName = current.tagName;
// Check for ion-page tag or elements with ion-page class
// (React renders IonPage as div.ion-page, not ion-page tag)
const isIonPage = tagName === 'ION-PAGE' || current.classList.contains('ion-page');
const isRouterOutlet = tagName === 'ION-ROUTER-OUTLET';
const isNav = tagName === 'ION-NAV';

if (isIonPage || isRouterOutlet || isNav) {
current.style.setProperty('pointer-events', 'none');
this.pointerEventsDisabledElements.push(current);
}

// Stop after processing the first ion-router-outlet - parent elements
// may contain sibling content (like buttons) that should remain interactive
if (isRouterOutlet) {
break;
}

current = current.parentElement;
}
}
}
}

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

/**
* Clean up pointer-events changes made in initSheetGesture.
*/
for (const element of this.pointerEventsDisabledElements) {
element.style.removeProperty('pointer-events');
}
this.pointerEventsDisabledElements = [];
}
this.currentBreakpoint = undefined;
this.animation = undefined;
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');
});

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
@@ -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--;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ 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');
});

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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading