Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
100 changes: 98 additions & 2 deletions core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down 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;
// Cached ion-page ancestor for child route passthrough
private cachedPageParent?: HTMLElement | null;

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 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,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() {
Expand Down Expand Up @@ -862,6 +956,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
this.cleanupViewTransitionListener();
this.cleanupParentRemovalObserver();

this.cleanupChildRoutePassthrough();
}
this.currentBreakpoint = undefined;
this.animation = undefined;
Expand Down
9 changes: 9 additions & 0 deletions core/src/css/core.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 20 additions & 8 deletions core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
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
Expand Up @@ -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
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--;
}
}
Loading
Loading