From ea8a22d2b0878fed2dfc3becc9295bc4c3aeb744 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 4 Dec 2025 17:41:31 -0800 Subject: [PATCH 1/7] chore(angular): reproducing issue where modals with showBackdrop=false aren't allowing clicks through the background --- .../src/standalone/modal-child-route.spec.ts | 64 +++++++++++++++++++ .../standalone/app-standalone/app.routes.ts | 8 +++ .../modal-child-route-child.component.ts | 49 ++++++++++++++ .../modal-child-route-parent.component.ts | 54 ++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 packages/angular/test/base/e2e/src/standalone/modal-child-route.spec.ts create mode 100644 packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-child.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-parent.component.ts diff --git a/packages/angular/test/base/e2e/src/standalone/modal-child-route.spec.ts b/packages/angular/test/base/e2e/src/standalone/modal-child-route.spec.ts new file mode 100644 index 00000000000..2a41872eb9d --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/modal-child-route.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test'; + +/** + * Tests for INLINE sheet modals in child routes with showBackdrop=false. + * + * Related issue: https://github.com/ionic-team/ionic-framework/issues/30700 + * + * This test mimics the EXACT structure from the issue reproduction: + * - PARENT component has interactive content (buttons) AND a nested ion-router-outlet + * - CHILD route (rendered in that nested outlet) contains ONLY the modal + * - The modal has showBackdrop=false + * + * The bug: when the modal opens in the child route, the buttons in the PARENT + * become non-interactive even though showBackdrop=false should allow interaction. + * + * DOM structure: + * - ion-app > ion-router-outlet (root) > PARENT (buttons + nested outlet) > ion-router-outlet > CHILD (modal only) + */ +test.describe('Modals: Inline Sheet in Child Route (standalone)', () => { + test.beforeEach(async ({ page }) => { + // Navigate to the child route - this will: + // 1. Render the parent with buttons + // 2. Render the child in the nested outlet, which immediately opens the modal + await page.goto('/standalone/modal-child-route/child'); + }); + + test('should render parent content and child modal', async ({ page }) => { + // Verify the PARENT content is visible (buttons are in parent, not child) + await expect(page.locator('#increment-btn')).toBeVisible(); + await expect(page.locator('#decrement-btn')).toBeVisible(); + await expect(page.locator('#background-action-count')).toHaveText('0'); + + // Verify the modal from CHILD route is visible (opens immediately with isOpen=true) + 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 (showBackdrop: false) is open in CHILD route', async ({ page }) => { + // Modal should already be open (isOpen=true in child component) + await expect(page.locator('ion-modal.show-modal')).toBeVisible(); + await expect(page.locator('#modal-content-loaded')).toBeVisible(); + + // Click the increment button in the PARENT - this should work with showBackdrop=false + // This is the KEY test - it FAILS due to issue #30700 + await page.locator('#increment-btn').click(); + + // Verify the click was registered + await expect(page.locator('#background-action-count')).toHaveText('1'); + }); + + test('should allow multiple interactions with PARENT content while modal is open', async ({ page }) => { + // Modal should already be open + await expect(page.locator('ion-modal.show-modal')).toBeVisible(); + + // Click increment multiple times + await page.locator('#increment-btn').click(); + await page.locator('#increment-btn').click(); + await expect(page.locator('#background-action-count')).toHaveText('2'); + + // Click decrement + await page.locator('#decrement-btn').click(); + await expect(page.locator('#background-action-count')).toHaveText('1'); + }); +}); diff --git a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts index 007743f905f..667ef672e8b 100644 --- a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts +++ b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts @@ -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) }, diff --git a/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-child.component.ts b/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-child.component.ts new file mode 100644 index 00000000000..25d82955df1 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-child.component.ts @@ -0,0 +1,49 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { IonButton, IonContent, IonHeader, IonModal, IonTitle, IonToolbar } from '@ionic/angular/standalone'; + +/** + * Child component that ONLY contains the modal. + * + * This mimics the EXACT structure from issue #30700 reproduction where: + * - The PARENT page has the interactive content (buttons) + * - The CHILD route (this component) contains ONLY the modal + * + * The structure is: + * - ion-app > ion-router-outlet (root) > PARENT (has buttons + nested outlet) > ion-router-outlet (nested) > THIS COMPONENT (has modal) + * + * The bug is: when this modal opens, the buttons in the PARENT become non-interactive + * even though showBackdrop=false should allow background interaction. + * + * Related issue: https://github.com/ionic-team/ionic-framework/issues/30700 + */ +@Component({ + selector: 'app-modal-child-route-child', + template: ` + + + + + + + + + Modal in Child Route + + + + +

The +/- buttons in the parent should be clickable!

+
+
+
+ `, + standalone: true, + imports: [CommonModule, IonButton, IonContent, IonHeader, IonModal, IonTitle, IonToolbar], +}) +export class ModalChildRouteChildComponent {} diff --git a/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-parent.component.ts b/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-parent.component.ts new file mode 100644 index 00000000000..74f9e116cef --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-parent.component.ts @@ -0,0 +1,54 @@ +import { Component } from '@angular/core'; +import { IonButton, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar } from '@ionic/angular/standalone'; + +/** + * Parent component that contains: + * 1. Interactive content (buttons) that should remain clickable + * 2. A nested ion-router-outlet where the child route with the modal will render + * + * This mimics the EXACT structure from issue #30700 reproduction: + * - Parent page has buttons (+/-) + * - Parent page has a nested IonRouterOutlet + * - Child route (rendered in that outlet) contains ONLY the modal + * + * The bug is: when the modal opens in the child route, the buttons in THIS + * parent component become non-interactive even with showBackdrop=false. + * + * Related issue: https://github.com/ionic-team/ionic-framework/issues/30700 + */ +@Component({ + selector: 'app-modal-child-route-parent', + template: ` + + + Parent Page with Nested Route + + + + +
+ - +

{{ count }}

+ + +
+ +

The modal will be rendered from a child route below:

+ + + +
+ `, + standalone: true, + imports: [IonButton, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar], +}) +export class ModalChildRouteParentComponent { + count = 0; + + increment() { + this.count++; + } + + decrement() { + this.count--; + } +} From cc6727a116ac96f091b47ce07f16671f47698927 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 4 Dec 2025 18:43:49 -0800 Subject: [PATCH 2/7] fix(modal): allow interaction with parent content when sheet modal has showBackdrop=false in child routes --- core/src/components/modal/modal.scss | 14 ++++++++ core/src/components/modal/modal.tsx | 35 +++++++++++++++++- .../src/standalone/modal-child-route.spec.ts | 36 +++---------------- .../modal-child-route-child.component.ts | 24 +++---------- .../modal-child-route-parent.component.ts | 22 ++---------- .../IonModalFocusTrap.cy.ts | 6 ++-- 6 files changed, 64 insertions(+), 73 deletions(-) diff --git a/core/src/components/modal/modal.scss b/core/src/components/modal/modal.scss index 7c5ec7916fe..74ff7ebc56c 100644 --- a/core/src/components/modal/modal.scss +++ b/core/src/components/modal/modal.scss @@ -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; } diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index b1a494430d0..12eb598e2ce 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -644,7 +644,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(); @@ -753,6 +760,21 @@ export class Modal implements ComponentInterface, OverlayInterface { this.moveSheetToBreakpoint = moveSheetToBreakpoint; this.gesture.enable(true); + + /** + * When showBackdrop or focusTrap is false, the modal's original parent may + * block pointer events after the modal is moved to ion-app. Disable + * pointer-events on the parent elements to allow background interaction. + * See https://github.com/ionic-team/ionic-framework/issues/30700 + */ + if ((this.showBackdrop === false || this.focusTrap === false) && this.cachedOriginalParent) { + this.cachedOriginalParent.style.setProperty('pointer-events', 'none'); + + const immediateParent = this.cachedOriginalParent.parentElement; + if (immediateParent?.tagName === 'ION-ROUTER-OUTLET') { + immediateParent.style.setProperty('pointer-events', 'none'); + } + } } private sheetOnDismiss() { @@ -862,6 +884,17 @@ export class Modal implements ComponentInterface, OverlayInterface { } this.cleanupViewTransitionListener(); this.cleanupParentRemovalObserver(); + + /** + * Clean up pointer-events changes made in initSheetGesture. + */ + if (this.cachedOriginalParent) { + this.cachedOriginalParent.style.removeProperty('pointer-events'); + const immediateParent = this.cachedOriginalParent.parentElement; + if (immediateParent?.tagName === 'ION-ROUTER-OUTLET') { + immediateParent.style.removeProperty('pointer-events'); + } + } } this.currentBreakpoint = undefined; this.animation = undefined; diff --git a/packages/angular/test/base/e2e/src/standalone/modal-child-route.spec.ts b/packages/angular/test/base/e2e/src/standalone/modal-child-route.spec.ts index 2a41872eb9d..d479599bccb 100644 --- a/packages/angular/test/base/e2e/src/standalone/modal-child-route.spec.ts +++ b/packages/angular/test/base/e2e/src/standalone/modal-child-route.spec.ts @@ -1,63 +1,37 @@ import { expect, test } from '@playwright/test'; /** - * Tests for INLINE sheet modals in child routes with showBackdrop=false. - * - * Related issue: https://github.com/ionic-team/ionic-framework/issues/30700 - * - * This test mimics the EXACT structure from the issue reproduction: - * - PARENT component has interactive content (buttons) AND a nested ion-router-outlet - * - CHILD route (rendered in that nested outlet) contains ONLY the modal - * - The modal has showBackdrop=false - * - * The bug: when the modal opens in the child route, the buttons in the PARENT - * become non-interactive even though showBackdrop=false should allow interaction. - * - * DOM structure: - * - ion-app > ion-router-outlet (root) > PARENT (buttons + nested outlet) > ion-router-outlet > CHILD (modal only) + * 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 }) => { - // Navigate to the child route - this will: - // 1. Render the parent with buttons - // 2. Render the child in the nested outlet, which immediately opens the modal await page.goto('/standalone/modal-child-route/child'); }); test('should render parent content and child modal', async ({ page }) => { - // Verify the PARENT content is visible (buttons are in parent, not child) await expect(page.locator('#increment-btn')).toBeVisible(); await expect(page.locator('#decrement-btn')).toBeVisible(); await expect(page.locator('#background-action-count')).toHaveText('0'); - - // Verify the modal from CHILD route is visible (opens immediately with isOpen=true) 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 (showBackdrop: false) is open in CHILD route', async ({ page }) => { - // Modal should already be open (isOpen=true in child component) + 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 expect(page.locator('#modal-content-loaded')).toBeVisible(); - // Click the increment button in the PARENT - this should work with showBackdrop=false - // This is the KEY test - it FAILS due to issue #30700 await page.locator('#increment-btn').click(); - - // Verify the click was registered await expect(page.locator('#background-action-count')).toHaveText('1'); }); - test('should allow multiple interactions with PARENT content while modal is open', async ({ page }) => { - // Modal should already be open + test('should allow multiple interactions with parent content while modal is open', async ({ page }) => { await expect(page.locator('ion-modal.show-modal')).toBeVisible(); - // Click increment multiple times await page.locator('#increment-btn').click(); await page.locator('#increment-btn').click(); await expect(page.locator('#background-action-count')).toHaveText('2'); - // Click decrement await page.locator('#decrement-btn').click(); await expect(page.locator('#background-action-count')).toHaveText('1'); }); diff --git a/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-child.component.ts b/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-child.component.ts index 25d82955df1..6fa573fa197 100644 --- a/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-child.component.ts +++ b/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-child.component.ts @@ -1,29 +1,14 @@ import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; -import { IonButton, IonContent, IonHeader, IonModal, IonTitle, IonToolbar } from '@ionic/angular/standalone'; +import { IonContent, IonHeader, IonModal, IonTitle, IonToolbar } from '@ionic/angular/standalone'; /** - * Child component that ONLY contains the modal. - * - * This mimics the EXACT structure from issue #30700 reproduction where: - * - The PARENT page has the interactive content (buttons) - * - The CHILD route (this component) contains ONLY the modal - * - * The structure is: - * - ion-app > ion-router-outlet (root) > PARENT (has buttons + nested outlet) > ion-router-outlet (nested) > THIS COMPONENT (has modal) - * - * The bug is: when this modal opens, the buttons in the PARENT become non-interactive - * even though showBackdrop=false should allow background interaction. - * - * Related issue: https://github.com/ionic-team/ionic-framework/issues/30700 + * 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: ` - - - - -

The +/- buttons in the parent should be clickable!

`, standalone: true, - imports: [CommonModule, IonButton, IonContent, IonHeader, IonModal, IonTitle, IonToolbar], + imports: [CommonModule, IonContent, IonHeader, IonModal, IonTitle, IonToolbar], }) export class ModalChildRouteChildComponent {} diff --git a/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-parent.component.ts b/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-parent.component.ts index 74f9e116cef..fdd5465ad11 100644 --- a/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-parent.component.ts +++ b/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-parent.component.ts @@ -2,19 +2,8 @@ import { Component } from '@angular/core'; import { IonButton, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar } from '@ionic/angular/standalone'; /** - * Parent component that contains: - * 1. Interactive content (buttons) that should remain clickable - * 2. A nested ion-router-outlet where the child route with the modal will render - * - * This mimics the EXACT structure from issue #30700 reproduction: - * - Parent page has buttons (+/-) - * - Parent page has a nested IonRouterOutlet - * - Child route (rendered in that outlet) contains ONLY the modal - * - * The bug is: when the modal opens in the child route, the buttons in THIS - * parent component become non-interactive even with showBackdrop=false. - * - * Related issue: https://github.com/ionic-team/ionic-framework/issues/30700 + * 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', @@ -25,17 +14,12 @@ import { IonButton, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar -
-

{{ count }}

+
- -

The modal will be rendered from a child route below:

- - - +
`, standalone: true, diff --git a/packages/react/test/base/tests/e2e/specs/overlay-components/IonModalFocusTrap.cy.ts b/packages/react/test/base/tests/e2e/specs/overlay-components/IonModalFocusTrap.cy.ts index 395c642dcc4..78ca7a581e3 100644 --- a/packages/react/test/base/tests/e2e/specs/overlay-components/IonModalFocusTrap.cy.ts +++ b/packages/react/test/base/tests/e2e/specs/overlay-components/IonModalFocusTrap.cy.ts @@ -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'); From ef60cf1bcd6aedfcc85157cc291fb44663ebf922 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Fri, 5 Dec 2025 09:06:52 -0800 Subject: [PATCH 3/7] fix(modal): handle React template wrapper in child route pointer-events fix --- core/src/components/modal/modal.tsx | 59 ++++++++++++---- .../ModalSheetChildRoute.tsx | 69 +++++++++++++++++++ .../overlay-components/OverlayComponents.tsx | 2 + .../IonModalSheetChildRoute.cy.ts | 37 ++++++++++ 4 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 packages/react/test/base/src/pages/overlay-components/ModalSheetChildRoute.tsx create mode 100644 packages/react/test/base/tests/e2e/specs/overlay-components/IonModalSheetChildRoute.cy.ts diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 12eb598e2ce..c089fd3a617 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -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; @@ -763,16 +765,52 @@ export class Modal implements ComponentInterface, OverlayInterface { /** * When showBackdrop or focusTrap is false, the modal's original parent may - * block pointer events after the modal is moved to ion-app. Disable - * pointer-events on the parent elements to allow background interaction. + * 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 */ if ((this.showBackdrop === false || this.focusTrap === false) && this.cachedOriginalParent) { - this.cachedOriginalParent.style.setProperty('pointer-events', 'none'); + // Find the first meaningful parent (skip template and other non-semantic wrappers). + // In Ionic React, modals are wrapped in a