diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index b1a494430d0..376404b42e7 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -71,7 +71,7 @@ export class Modal implements ComponentInterface, OverlayInterface { private gesture?: Gesture; private coreDelegate: FrameworkDelegate = CoreDelegate(); private sheetTransition?: Promise; - 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,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() { @@ -862,6 +955,8 @@ export class Modal implements ComponentInterface, OverlayInterface { } this.cleanupViewTransitionListener(); this.cleanupParentRemovalObserver(); + + this.cleanupChildRoutePassthrough(); } this.currentBreakpoint = undefined; this.animation = undefined; diff --git a/core/src/css/core.scss b/core/src/css/core.scss index cf7560bd348..db694fc6a07 100644 --- a/core/src/css/core.scss +++ b/core/src/css/core.scss @@ -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, diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 5b983158aa5..9ac85d1257a 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -38,6 +38,20 @@ let lastId = 0; export const activeAnimations = new WeakMap(); +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 = (tagName: string) => { return { create(options: Opts): Promise { @@ -539,11 +553,9 @@ export const present = async ( * 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(); @@ -680,12 +692,12 @@ export const dismiss = async ( * 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 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..d479599bccb --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/modal-child-route.spec.ts @@ -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'); + }); +}); 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/home-page/home-page.component.html b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html index 7ac9c619180..6dbad643eb2 100644 --- a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html +++ b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html @@ -100,6 +100,11 @@ Modal Dynamic Wrapper Test + + + Modal Child Route Test + + Programmatic Modal Test 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..6fa573fa197 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-child.component.ts @@ -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: ` + + + + + Modal in Child Route + + + + + + + + `, + standalone: true, + 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 new file mode 100644 index 00000000000..fdd5465ad11 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/modal-child-route/modal-child-route-parent.component.ts @@ -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: ` + + + Parent Page with Nested Route + + + +
+ - +

{{ count }}

+ + +
+ +
+ `, + standalone: true, + imports: [IonButton, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar], +}) +export class ModalChildRouteParentComponent { + count = 0; + + increment() { + this.count++; + } + + decrement() { + this.count--; + } +} diff --git a/packages/react/test/base/src/pages/overlay-components/ModalSheetChildRoute.tsx b/packages/react/test/base/src/pages/overlay-components/ModalSheetChildRoute.tsx new file mode 100644 index 00000000000..e686bbd00eb --- /dev/null +++ b/packages/react/test/base/src/pages/overlay-components/ModalSheetChildRoute.tsx @@ -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 ( + + + + Parent Page with Nested Route + + + +
+ setCount((c) => c - 1)}> + - + +

{count}

+ setCount((c) => c + 1)}> + + + +
+
+ + + +
+ ); +}; + +const ModalSheetChildRouteChild: React.FC = () => { + return ( + + + + + Modal in Child Route + + + + + + + + ); +}; + +export default ModalSheetChildRouteParent; diff --git a/packages/react/test/base/src/pages/overlay-components/OverlayComponents.tsx b/packages/react/test/base/src/pages/overlay-components/OverlayComponents.tsx index 19aebc9081c..75bf98bb734 100644 --- a/packages/react/test/base/src/pages/overlay-components/OverlayComponents.tsx +++ b/packages/react/test/base/src/pages/overlay-components/OverlayComponents.tsx @@ -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 = () => { + @@ -62,6 +64,10 @@ const OverlayHooks: React.FC = () => { Modal Teleport + + + Sheet Child + Picker 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'); diff --git a/packages/react/test/base/tests/e2e/specs/overlay-components/IonModalSheetChildRoute.cy.ts b/packages/react/test/base/tests/e2e/specs/overlay-components/IonModalSheetChildRoute.cy.ts new file mode 100644 index 00000000000..ff791f3aade --- /dev/null +++ b/packages/react/test/base/tests/e2e/specs/overlay-components/IonModalSheetChildRoute.cy.ts @@ -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'); + }); +});