fix(modal): allow sheet modals to skip focus trap (#30689)

Issue number: resolves #30684

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->
Recently, we [fixed some issues with aria-hidden in
modals](https://github.com/ionic-team/ionic-framework/pull/30563),
unfortunately at this time we neglected modals that opt out of focus
trapping. As a result, a lot of modals that disable focus trapping still
have it happening and it doesn't get cleaned up properly on dismiss.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->
We're now properly checking for and skipping focus traps on modals that
do not want them.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

I created regression tests for Angular in this to prevent this from
happening again. I initially tried to do this with core, but the issue
doesn't seem to reproduce with core.

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Current dev build:
```
 8.7.5-dev.11758652700.103435a3
```
This commit is contained in:
Shane
2025-09-24 12:29:58 -07:00
committed by GitHub
parent 5a06503d4a
commit a40d957ad9
35 changed files with 1094 additions and 25 deletions

View File

@ -95,6 +95,12 @@ export const createSheetGesture = (
const contentAnimation = animation.childAnimations.find((ani) => ani.id === 'contentAnimation');
const enableBackdrop = () => {
// Respect explicit opt-out of focus trapping/backdrop interactions
// If focusTrap is false or showBackdrop is false, do not enable the backdrop or re-enable focus trap
const el = baseEl as HTMLIonModalElement & { focusTrap?: boolean; showBackdrop?: boolean };
if (el.focusTrap === false || el.showBackdrop === false) {
return;
}
baseEl.style.setProperty('pointer-events', 'auto');
backdropEl.style.setProperty('pointer-events', 'auto');
@ -235,7 +241,10 @@ export const createSheetGesture = (
* ion-backdrop and .modal-wrapper always have pointer-events: auto
* applied, so the modal content can still be interacted with.
*/
const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint;
const shouldEnableBackdrop =
currentBreakpoint > backdropBreakpoint &&
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false &&
(baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false;
if (shouldEnableBackdrop) {
enableBackdrop();
} else {
@ -582,7 +591,10 @@ export const createSheetGesture = (
* Backdrop should become enabled
* after the backdropBreakpoint value
*/
const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint;
const shouldEnableBackdrop =
currentBreakpoint > backdropBreakpoint &&
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false &&
(baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false;
if (shouldEnableBackdrop) {
enableBackdrop();
} else {

View File

@ -494,10 +494,8 @@ export const setRootAriaHidden = (hidden = false) => {
if (hidden) {
viewContainer.setAttribute('aria-hidden', 'true');
viewContainer.setAttribute('inert', '');
} else {
viewContainer.removeAttribute('aria-hidden');
viewContainer.removeAttribute('inert');
}
};
@ -529,15 +527,37 @@ export const present = async <OverlayPresentOptions>(
* focus traps.
*
* All other overlays should have focus traps to prevent
* the keyboard focus from leaving the overlay.
* the keyboard focus from leaving the overlay unless
* developers explicitly opt out (for example, sheet
* modals that should permit background interaction).
*
* Note: Some apps move inline overlays to a specific container
* during the willPresent lifecycle (e.g., React portals via
* onWillPresent). Defer applying aria-hidden/inert to the app
* root until after willPresent so we can detect where the
* overlay is finally inserted. If the overlay is inside the
* view container subtree, skip adding aria-hidden/inert there
* to avoid disabling the overlay.
*/
if (overlay.el.tagName !== 'ION-TOAST') {
setRootAriaHidden(true);
document.body.classList.add(BACKDROP_NO_SCROLL);
}
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
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;
overlay.presented = true;
overlay.willPresent.emit();
if (shouldLockRoot) {
const root = getAppRoot(document);
const viewContainer = root.querySelector('ion-router-outlet, #ion-view-container-root');
const overlayInsideViewContainer = viewContainer ? viewContainer.contains(overlayEl) : false;
if (!overlayInsideViewContainer) {
setRootAriaHidden(true);
}
document.body.classList.add(BACKDROP_NO_SCROLL);
}
overlay.willPresentShorthand?.emit();
const mode = getIonMode(overlay);
@ -653,22 +673,28 @@ export const dismiss = async <OverlayDismissOptions>(
* For accessibility, toasts lack focus traps and don't receive
* `aria-hidden` on the root element when presented.
*
* All other overlays use focus traps to keep keyboard focus
* within the overlay, setting `aria-hidden` on the root element
* to enhance accessibility.
*
* Therefore, we must remove `aria-hidden` from the root element
* when the last non-toast overlay is dismissed.
* Overlays that opt into focus trapping set `aria-hidden`
* on the root element to keep keyboard focus and pointer
* events inside the overlay. We must remove `aria-hidden`
* from the root element when the last focus-trapping overlay
* is dismissed.
*/
const overlaysNotToast = presentedOverlays.filter((o) => o.tagName !== 'ION-TOAST');
const lastOverlayNotToast = overlaysNotToast.length === 1 && overlaysNotToast[0].id === overlay.el.id;
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 overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
const locksRoot =
overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && overlayEl.showBackdrop !== false;
/**
* If this is the last visible overlay that is not a toast
* If this is the last visible overlay that is trapping focus
* then we want to re-add the root to the accessibility tree.
*/
if (lastOverlayNotToast) {
const lastOverlayTrappingFocus =
locksRoot && overlaysLockingRoot.length === 1 && overlaysLockingRoot[0].id === overlayEl.id;
if (lastOverlayTrappingFocus) {
setRootAriaHidden(false);
document.body.classList.remove(BACKDROP_NO_SCROLL);
}

View File

@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test';
test.describe('Modals: Dynamic Wrapper', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/lazy/modal-dynamic-wrapper');
});
test('should render dynamic component inside modal', async ({ page }) => {
await page.locator('#open-dynamic-modal').click();
await expect(page.locator('ion-modal')).toBeVisible();
await expect(page.locator('#dynamic-component-loaded')).toBeVisible();
});
test('should allow interacting with background content while sheet is open', async ({ page }) => {
await page.locator('#open-dynamic-modal').click();
await expect(page.locator('ion-modal')).toBeVisible();
await page.locator('#background-action').click();
await expect(page.locator('#background-action-count')).toHaveText('1');
});
test('should prevent interacting with background content when focus is trapped', async ({ page }) => {
await page.locator('#open-focused-modal').click();
await expect(page.locator('ion-modal')).toBeVisible();
// Attempt to click the background button via coordinates; click should be intercepted by backdrop
const box = await page.locator('#background-action').boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
await expect(page.locator('#background-action-count')).toHaveText('0');
});
});

View File

@ -0,0 +1,34 @@
import { expect, test } from '@playwright/test';
test.describe('Modals: Inline Sheet', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/lazy/modal-sheet-inline');
});
test('should open inline sheet modal', async ({ page }) => {
await page.locator('#present-inline-sheet-modal').click();
await expect(page.locator('ion-modal')).toBeVisible();
await expect(page.locator('#current-breakpoint')).toHaveText('0.2');
await expect(page.locator('ion-modal ion-item')).toHaveCount(4);
});
test('should expand to 0.75 breakpoint when searchbar is clicked', async ({ page }) => {
await page.locator('#present-inline-sheet-modal').click();
await expect(page.locator('#current-breakpoint')).toHaveText('0.2');
await page.locator('ion-modal ion-searchbar').click();
await expect(page.locator('#current-breakpoint')).toHaveText('0.75');
});
test('should allow interacting with background content while sheet is open', async ({ page }) => {
await page.locator('#present-inline-sheet-modal').click();
await expect(page.locator('ion-modal')).toBeVisible();
await page.locator('#background-action').click();
await expect(page.locator('#background-action-count')).toHaveText('1');
});
});

View File

@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test';
test.describe('Modals: Dynamic Wrapper (standalone)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/standalone/modal-dynamic-wrapper');
});
test('should render dynamic component inside modal', async ({ page }) => {
await page.locator('#open-dynamic-modal').click();
await expect(page.locator('ion-modal')).toBeVisible();
await expect(page.locator('#dynamic-component-loaded')).toBeVisible();
});
test('should allow interacting with background content while sheet is open', async ({ page }) => {
await page.locator('#open-dynamic-modal').click();
await expect(page.locator('ion-modal')).toBeVisible();
await page.locator('#background-action').click();
await expect(page.locator('#background-action-count')).toHaveText('1');
});
test('should prevent interacting with background content when focus is trapped', async ({ page }) => {
await page.locator('#open-focused-modal').click();
await expect(page.locator('ion-modal')).toBeVisible();
// Attempt to click the background button via coordinates; click should be intercepted by backdrop
const box = await page.locator('#background-action').boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
}
await expect(page.locator('#background-action-count')).toHaveText('0');
});
});

View File

@ -0,0 +1,34 @@
import { expect, test } from '@playwright/test';
test.describe('Modals: Inline Sheet (standalone)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/standalone/modal-sheet-inline');
});
test('should open inline sheet modal', async ({ page }) => {
await page.locator('#present-inline-sheet-modal').click();
await expect(page.locator('ion-modal')).toBeVisible();
await expect(page.locator('#current-breakpoint')).toHaveText('0.2');
await expect(page.locator('ion-modal ion-item')).toHaveCount(4);
});
test('should expand to 0.75 breakpoint when searchbar is clicked', async ({ page }) => {
await page.locator('#present-inline-sheet-modal').click();
await expect(page.locator('#current-breakpoint')).toHaveText('0.2');
await page.locator('ion-modal ion-searchbar').click();
await expect(page.locator('#current-breakpoint')).toHaveText('0.75');
});
test('should allow interacting with background content while sheet is open', async ({ page }) => {
await page.locator('#present-inline-sheet-modal').click();
await expect(page.locator('ion-modal')).toBeVisible();
await page.locator('#background-action').click();
await expect(page.locator('#background-action-count')).toHaveText('1');
});
});

View File

@ -37,6 +37,8 @@ export const routes: Routes = [
{ path: 'template-form', component: TemplateFormComponent },
{ path: 'modals', component: ModalComponent },
{ path: 'modal-inline', loadChildren: () => import('../modal-inline').then(m => m.ModalInlineModule) },
{ path: 'modal-sheet-inline', loadChildren: () => import('../modal-sheet-inline').then(m => m.ModalSheetInlineModule) },
{ path: 'modal-dynamic-wrapper', loadChildren: () => import('../modal-dynamic-wrapper').then(m => m.ModalDynamicWrapperModule) },
{ path: 'view-child', component: ViewChildComponent },
{ path: 'keep-contents-mounted', loadChildren: () => import('../keep-contents-mounted').then(m => m.OverlayAutoMountModule) },
{ path: 'overlays-inline', loadChildren: () => import('../overlays-inline').then(m => m.OverlaysInlineModule) },
@ -90,4 +92,3 @@ export const routes: Routes = [
]
},
];

View File

@ -35,6 +35,16 @@
Modals Test
</ion-label>
</ion-item>
<ion-item routerLink="/lazy/modal-sheet-inline">
<ion-label>
Modal Sheet Inline Test
</ion-label>
</ion-item>
<ion-item routerLink="/lazy/modal-dynamic-wrapper">
<ion-label>
Modal Dynamic Wrapper Test
</ion-label>
</ion-item>
<ion-item routerLink="/lazy/router-link">
<ion-label>
Router link Test

View File

@ -0,0 +1,26 @@
import { Component, ComponentRef, Input, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
@Component({
selector: 'app-dynamic-component-wrapper',
template: `
<ion-content>
<ng-container #container></ng-container>
</ion-content>
`,
standalone: false
})
export class DynamicComponentWrapperComponent implements OnInit, OnDestroy {
@Input() componentRef?: ComponentRef<unknown>;
@ViewChild('container', { read: ViewContainerRef, static: true }) container!: ViewContainerRef;
ngOnInit(): void {
if (this.componentRef) {
this.container.insert(this.componentRef.hostView);
}
}
ngOnDestroy(): void {
this.componentRef?.destroy();
}
}

View File

@ -0,0 +1,20 @@
import { Component, EventEmitter, Output } from "@angular/core";
@Component({
selector: 'app-dynamic-modal-content',
template: `
<ion-header>
<ion-toolbar>
<ion-title>Dynamic Sheet Content</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p id="dynamic-component-loaded">Dynamic component rendered inside wrapper.</p>
<ion-button id="dismiss-dynamic-modal" (click)="dismiss.emit()">Close</ion-button>
</ion-content>
`,
standalone: false
})
export class DynamicModalContentComponent {
@Output() dismiss = new EventEmitter<void>();
}

View File

@ -0,0 +1,2 @@
export * from './modal-dynamic-wrapper.component';
export * from './modal-dynamic-wrapper.module';

View File

@ -0,0 +1,16 @@
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { ModalDynamicWrapperComponent } from ".";
@NgModule({
imports: [
RouterModule.forChild([
{
path: '',
component: ModalDynamicWrapperComponent
}
])
],
exports: [RouterModule]
})
export class ModalDynamicWrapperRoutingModule { }

View File

@ -0,0 +1,8 @@
<ion-button id="open-dynamic-modal" (click)="openModal()">Open Dynamic Sheet Modal</ion-button>
<ion-button id="open-focused-modal" color="primary" (click)="openFocusedModal()">Open Focus-Trapped Sheet Modal</ion-button>
<ion-button id="background-action" (click)="onBackgroundActionClick()">Background Action</ion-button>
<p>
Background action count: <span id="background-action-count">{{ backgroundActionCount }}</span>
</p>
<ng-template #modalHost></ng-template>

View File

@ -0,0 +1,104 @@
import { Component, ComponentRef, OnDestroy, ViewChild, ViewContainerRef } from "@angular/core";
import { ModalController } from "@ionic/angular";
import { DynamicComponentWrapperComponent } from "./dynamic-component-wrapper.component";
import { DynamicModalContentComponent } from "./dynamic-modal-content.component";
@Component({
selector: 'app-modal-dynamic-wrapper',
templateUrl: './modal-dynamic-wrapper.component.html',
standalone: false
})
export class ModalDynamicWrapperComponent implements OnDestroy {
@ViewChild('modalHost', { read: ViewContainerRef, static: true }) modalHost!: ViewContainerRef;
backgroundActionCount = 0;
private currentModal?: HTMLIonModalElement;
private currentComponentRef?: ComponentRef<DynamicModalContentComponent>;
constructor(private modalCtrl: ModalController) {}
async openModal() {
await this.closeModal();
const componentRef = this.modalHost.createComponent(DynamicModalContentComponent);
this.modalHost.detach();
componentRef.instance.dismiss.subscribe(() => this.closeModal());
this.currentComponentRef = componentRef;
const modal = await this.modalCtrl.create({
component: DynamicComponentWrapperComponent,
componentProps: {
componentRef
},
breakpoints: [0, 0.2, 0.75, 1],
initialBreakpoint: 0.2,
backdropDismiss: false,
focusTrap: false,
handleBehavior: 'cycle'
});
this.currentModal = modal;
modal.onWillDismiss().then(() => this.destroyComponent());
await modal.present();
}
async openFocusedModal() {
await this.closeModal();
const componentRef = this.modalHost.createComponent(DynamicModalContentComponent);
this.modalHost.detach();
componentRef.instance.dismiss.subscribe(() => this.closeModal());
this.currentComponentRef = componentRef;
const modal = await this.modalCtrl.create({
component: DynamicComponentWrapperComponent,
componentProps: {
componentRef,
},
// Choose a higher initial breakpoint to ensure backdrop is active immediately
breakpoints: [0, 0.25, 0.5, 0.75, 1],
initialBreakpoint: 0.5,
// Keep backdrop active but do not dismiss on tap to avoid interfering with assertions
backdropDismiss: false,
// Explicitly enable focus trapping to block background interaction
focusTrap: true,
handleBehavior: 'cycle',
});
this.currentModal = modal;
modal.onWillDismiss().then(() => this.destroyComponent());
await modal.present();
}
async closeModal() {
if (this.currentModal) {
await this.currentModal.dismiss();
this.currentModal = undefined;
}
this.destroyComponent();
}
private destroyComponent() {
if (this.currentComponentRef) {
this.currentComponentRef.destroy();
this.currentComponentRef = undefined;
}
}
onBackgroundActionClick() {
this.backgroundActionCount++;
}
ngOnDestroy(): void {
this.destroyComponent();
}
}

View File

@ -0,0 +1,14 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { IonicModule } from "@ionic/angular";
import { DynamicComponentWrapperComponent } from "./dynamic-component-wrapper.component";
import { DynamicModalContentComponent } from "./dynamic-modal-content.component";
import { ModalDynamicWrapperRoutingModule } from "./modal-dynamic-wrapper-routing.module";
import { ModalDynamicWrapperComponent } from "./modal-dynamic-wrapper.component";
@NgModule({
imports: [CommonModule, IonicModule, ModalDynamicWrapperRoutingModule],
declarations: [ModalDynamicWrapperComponent, DynamicComponentWrapperComponent, DynamicModalContentComponent],
exports: [ModalDynamicWrapperComponent]
})
export class ModalDynamicWrapperModule { }

View File

@ -0,0 +1,2 @@
export * from './modal-sheet-inline.component';
export * from './modal-sheet-inline.module';

View File

@ -0,0 +1,16 @@
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { ModalSheetInlineComponent } from ".";
@NgModule({
imports: [
RouterModule.forChild([
{
path: '',
component: ModalSheetInlineComponent
}
])
],
exports: [RouterModule]
})
export class ModalSheetInlineRoutingModule { }

View File

@ -0,0 +1,46 @@
<ion-button id="present-inline-sheet-modal" (click)="presentInlineSheetModal()">
Present Inline Sheet Modal
</ion-button>
<p>
Current breakpoint: <span id="current-breakpoint">{{ currentBreakpoint }}</span>
</p>
<ion-button id="background-action" (click)="onBackgroundActionClick()">
Background Action
</ion-button>
<p>
Background action count: <span id="background-action-count">{{ backgroundActionCount }}</span>
</p>
<ion-modal
#inlineSheetModal
mode="md"
[isOpen]="isSheetOpen"
[initialBreakpoint]="0.2"
[breakpoints]="breakpoints"
[backdropDismiss]="false"
[backdropBreakpoint]="0.5"
[focusTrap]="false"
handleBehavior="cycle"
(ionBreakpointDidChange)="onSheetBreakpointDidChange($event)"
(didDismiss)="onSheetDidDismiss()"
>
<ng-template>
<ion-content>
<ion-searchbar placeholder="Search" (click)="expandInlineSheet()"></ion-searchbar>
<ion-list>
<ion-item *ngFor="let contact of contacts">
<ion-avatar slot="start">
<ion-img [src]="contact.avatar"></ion-img>
</ion-avatar>
<ion-label>
<h2>{{ contact.name }}</h2>
<p>{{ contact.title }}</p>
</ion-label>
</ion-item>
</ion-list>
</ion-content>
</ng-template>
</ion-modal>

View File

@ -0,0 +1,79 @@
import { Component, ViewChild } from "@angular/core";
import { IonModal } from "@ionic/angular";
interface Contact {
name: string;
title: string;
avatar: string;
}
@Component({
selector: 'app-modal-sheet-inline',
templateUrl: './modal-sheet-inline.component.html',
standalone: false
})
export class ModalSheetInlineComponent {
@ViewChild('inlineSheetModal', { read: IonModal }) inlineSheetModal?: IonModal;
readonly breakpoints: number[] = [0, 0.2, 0.75, 1];
readonly contacts: Contact[] = [
{
name: 'Connor Smith',
title: 'Sales Rep',
avatar: 'https://i.pravatar.cc/300?u=b'
},
{
name: 'Daniel Smith',
title: 'Product Designer',
avatar: 'https://i.pravatar.cc/300?u=a'
},
{
name: 'Greg Smith',
title: 'Director of Operations',
avatar: 'https://i.pravatar.cc/300?u=d'
},
{
name: 'Zoey Smith',
title: 'CEO',
avatar: 'https://i.pravatar.cc/300?u=e'
}
];
isSheetOpen = false;
currentBreakpoint = 'closed';
backgroundActionCount = 0;
presentInlineSheetModal() {
this.isSheetOpen = true;
this.currentBreakpoint = '0.2';
}
async expandInlineSheet() {
const modal = this.inlineSheetModal;
if (!modal) {
return;
}
await modal.setCurrentBreakpoint(0.75);
this.currentBreakpoint = '0.75';
}
onSheetDidDismiss() {
this.isSheetOpen = false;
this.currentBreakpoint = 'closed';
}
onSheetBreakpointDidChange(event: CustomEvent<{ breakpoint: number }>) {
this.currentBreakpoint = event.detail.breakpoint.toString();
}
onBackgroundActionClick() {
this.backgroundActionCount++;
}
}

View File

@ -0,0 +1,12 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { IonicModule } from "@ionic/angular";
import { ModalSheetInlineRoutingModule } from "./modal-sheet-inline-routing.module";
import { ModalSheetInlineComponent } from "./modal-sheet-inline.component";
@NgModule({
imports: [CommonModule, IonicModule, ModalSheetInlineRoutingModule],
declarations: [ModalSheetInlineComponent],
exports: [ModalSheetInlineComponent]
})
export class ModalSheetInlineModule { }

View File

@ -11,6 +11,8 @@ export const routes: Routes = [
{ path: 'action-sheet-controller', loadComponent: () => import('../action-sheet-controller/action-sheet-controller.component').then(c => c.ActionSheetControllerComponent) },
{ path: 'popover', loadComponent: () => import('../popover/popover.component').then(c => c.PopoverComponent) },
{ 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: '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) },

View File

@ -90,6 +90,16 @@
Modal Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/modal-sheet-inline">
<ion-label>
Modal Sheet Inline Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/modal-dynamic-wrapper">
<ion-label>
Modal Dynamic Wrapper Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/programmatic-modal">
<ion-label>
Programmatic Modal Test

View File

@ -0,0 +1,27 @@
import { Component, ComponentRef, Input, OnDestroy, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
import { IonContent } from '@ionic/angular/standalone';
@Component({
selector: 'app-dynamic-component-wrapper',
template: `
<ion-content>
<ng-container #container></ng-container>
</ion-content>
`,
standalone: true,
imports: [IonContent],
})
export class DynamicComponentWrapperComponent implements OnInit, OnDestroy {
@Input() componentRef?: ComponentRef<unknown>;
@ViewChild('container', { read: ViewContainerRef, static: true }) container!: ViewContainerRef;
ngOnInit(): void {
if (this.componentRef) {
this.container.insert(this.componentRef.hostView);
}
}
ngOnDestroy(): void {
this.componentRef?.destroy();
}
}

View File

@ -0,0 +1,28 @@
import { Component, EventEmitter, Output } from '@angular/core';
import {
IonButton,
IonContent,
IonHeader,
IonTitle,
IonToolbar,
} from '@ionic/angular/standalone';
@Component({
selector: 'app-dynamic-modal-content',
template: `
<ion-header>
<ion-toolbar>
<ion-title>Dynamic Sheet Content</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p id="dynamic-component-loaded">Dynamic component rendered inside wrapper.</p>
<ion-button id="dismiss-dynamic-modal" (click)="dismiss.emit()">Close</ion-button>
</ion-content>
`,
standalone: true,
imports: [IonButton, IonContent, IonHeader, IonTitle, IonToolbar],
})
export class DynamicModalContentComponent {
@Output() dismiss = new EventEmitter<void>();
}

View File

@ -0,0 +1,8 @@
<ion-button id="open-dynamic-modal" (click)="openModal()">Open Dynamic Sheet Modal</ion-button>
<ion-button id="open-focused-modal" color="primary" (click)="openFocusedModal()">Open Focus-Trapped Sheet Modal</ion-button>
<ion-button id="background-action" (click)="onBackgroundActionClick()">Background Action</ion-button>
<p>
Background action count: <span id="background-action-count">{{ backgroundActionCount }}</span>
</p>
<ng-template #modalHost></ng-template>

View File

@ -0,0 +1,103 @@
import { CommonModule } from '@angular/common';
import { Component, ComponentRef, OnDestroy, ViewChild, ViewContainerRef } from '@angular/core';
import { IonButton, ModalController } from '@ionic/angular/standalone';
import { DynamicComponentWrapperComponent } from './dynamic-component-wrapper.component';
import { DynamicModalContentComponent } from './dynamic-modal-content.component';
@Component({
selector: 'app-modal-dynamic-wrapper',
templateUrl: './modal-dynamic-wrapper.component.html',
standalone: true,
imports: [CommonModule, IonButton],
})
export class ModalDynamicWrapperComponent implements OnDestroy {
@ViewChild('modalHost', { read: ViewContainerRef, static: true }) modalHost!: ViewContainerRef;
backgroundActionCount = 0;
private currentModal?: HTMLIonModalElement;
private currentComponentRef?: ComponentRef<DynamicModalContentComponent>;
constructor(private modalCtrl: ModalController) {}
async openModal() {
await this.closeModal();
const componentRef = this.modalHost.createComponent(DynamicModalContentComponent);
this.modalHost.detach();
componentRef.instance.dismiss.subscribe(() => this.closeModal());
this.currentComponentRef = componentRef;
const modal = await this.modalCtrl.create({
component: DynamicComponentWrapperComponent,
componentProps: {
componentRef,
},
breakpoints: [0, 0.2, 0.75, 1],
initialBreakpoint: 0.2,
backdropDismiss: false,
focusTrap: false,
handleBehavior: 'cycle',
});
this.currentModal = modal;
modal.onWillDismiss().then(() => this.destroyComponent());
await modal.present();
}
async openFocusedModal() {
await this.closeModal();
const componentRef = this.modalHost.createComponent(DynamicModalContentComponent);
this.modalHost.detach();
componentRef.instance.dismiss.subscribe(() => this.closeModal());
this.currentComponentRef = componentRef;
const modal = await this.modalCtrl.create({
component: DynamicComponentWrapperComponent,
componentProps: {
componentRef,
},
breakpoints: [0, 0.25, 0.5, 0.75, 1],
initialBreakpoint: 0.5,
backdropDismiss: false,
focusTrap: true,
handleBehavior: 'cycle',
});
this.currentModal = modal;
modal.onWillDismiss().then(() => this.destroyComponent());
await modal.present();
}
async closeModal() {
if (this.currentModal) {
await this.currentModal.dismiss();
this.currentModal = undefined;
}
this.destroyComponent();
}
private destroyComponent() {
if (this.currentComponentRef) {
this.currentComponentRef.destroy();
this.currentComponentRef = undefined;
}
}
onBackgroundActionClick() {
this.backgroundActionCount++;
}
ngOnDestroy(): void {
this.destroyComponent();
}
}

View File

@ -0,0 +1,46 @@
<ion-button id="present-inline-sheet-modal" (click)="presentInlineSheetModal()">
Present Inline Sheet Modal
</ion-button>
<p>
Current breakpoint: <span id="current-breakpoint">{{ currentBreakpoint }}</span>
</p>
<ion-button id="background-action" (click)="onBackgroundActionClick()">
Background Action
</ion-button>
<p>
Background action count: <span id="background-action-count">{{ backgroundActionCount }}</span>
</p>
<ion-modal
#inlineSheetModal
mode="md"
[isOpen]="isSheetOpen"
[initialBreakpoint]="0.2"
[breakpoints]="breakpoints"
[backdropDismiss]="false"
[backdropBreakpoint]="0.5"
[focusTrap]="false"
handleBehavior="cycle"
(ionBreakpointDidChange)="onSheetBreakpointDidChange($event)"
(didDismiss)="onSheetDidDismiss()"
>
<ng-template>
<ion-content>
<ion-searchbar placeholder="Search" (click)="expandInlineSheet()"></ion-searchbar>
<ion-list>
<ion-item *ngFor="let contact of contacts">
<ion-avatar slot="start">
<ion-img [src]="contact.avatar"></ion-img>
</ion-avatar>
<ion-label>
<h2>{{ contact.name }}</h2>
<p>{{ contact.title }}</p>
</ion-label>
</ion-item>
</ion-list>
</ion-content>
</ng-template>
</ion-modal>

View File

@ -0,0 +1,100 @@
import { CommonModule } from '@angular/common';
import { Component, ViewChild } from '@angular/core';
import {
IonAvatar,
IonButton,
IonContent,
IonImg,
IonItem,
IonLabel,
IonList,
IonModal,
IonSearchbar,
} from '@ionic/angular/standalone';
interface Contact {
name: string;
title: string;
avatar: string;
}
@Component({
selector: 'app-modal-sheet-inline',
templateUrl: './modal-sheet-inline.component.html',
standalone: true,
imports: [
CommonModule,
IonAvatar,
IonButton,
IonContent,
IonImg,
IonItem,
IonLabel,
IonList,
IonModal,
IonSearchbar,
],
})
export class ModalSheetInlineComponent {
@ViewChild('inlineSheetModal', { read: IonModal }) inlineSheetModal?: IonModal;
readonly breakpoints: number[] = [0, 0.2, 0.75, 1];
readonly contacts: Contact[] = [
{
name: 'Connor Smith',
title: 'Sales Rep',
avatar: 'https://i.pravatar.cc/300?u=b',
},
{
name: 'Daniel Smith',
title: 'Product Designer',
avatar: 'https://i.pravatar.cc/300?u=a',
},
{
name: 'Greg Smith',
title: 'Director of Operations',
avatar: 'https://i.pravatar.cc/300?u=d',
},
{
name: 'Zoey Smith',
title: 'CEO',
avatar: 'https://i.pravatar.cc/300?u=e',
},
];
isSheetOpen = false;
currentBreakpoint = 'closed';
backgroundActionCount = 0;
presentInlineSheetModal() {
this.isSheetOpen = true;
this.currentBreakpoint = '0.2';
}
async expandInlineSheet() {
const modal = this.inlineSheetModal;
if (!modal) {
return;
}
await modal.setCurrentBreakpoint(0.75);
this.currentBreakpoint = '0.75';
}
onSheetDidDismiss() {
this.isSheetOpen = false;
this.currentBreakpoint = 'closed';
}
onSheetBreakpointDidChange(event: CustomEvent<{ breakpoint: number }>) {
this.currentBreakpoint = event.detail.breakpoint.toString();
}
onBackgroundActionClick() {
this.backgroundActionCount++;
}
}

View File

@ -0,0 +1,61 @@
import React, { useState } from 'react';
import { IonButton, IonContent, IonModal, IonPage } from '@ionic/react';
const ModalFocusTrap: React.FC = () => {
const [showNonTrapped, setShowNonTrapped] = useState(false);
const [showTrapped, setShowTrapped] = useState(false);
const [count, setCount] = useState(0);
return (
<IonPage>
<IonContent className="ion-padding">
<IonButton id="open-non-trapped-modal" onClick={() => setShowNonTrapped(true)}>
Open Non-Trapped Sheet Modal
</IonButton>
<IonButton id="open-trapped-modal" color="primary" onClick={() => setShowTrapped(true)}>
Open Focus-Trapped Sheet Modal
</IonButton>
<IonButton id="background-action" onClick={() => setCount((c) => c + 1)}>
Background Action
</IonButton>
<div>
Background action count: <span id="background-action-count">{count}</span>
</div>
<IonModal
isOpen={showNonTrapped}
onDidDismiss={() => setShowNonTrapped(false)}
breakpoints={[0, 0.25, 0.5, 0.75, 1]}
initialBreakpoint={0.25}
backdropDismiss={false}
focusTrap={false}
handleBehavior="cycle"
>
<IonContent className="ion-padding">
<p>Non-trapped modal content</p>
<IonButton onClick={() => setShowNonTrapped(false)}>Close</IonButton>
</IonContent>
</IonModal>
<IonModal
isOpen={showTrapped}
onDidDismiss={() => setShowTrapped(false)}
breakpoints={[0, 0.25, 0.5, 0.75, 1]}
initialBreakpoint={0.5}
backdropDismiss={false}
focusTrap={true}
handleBehavior="cycle"
>
<IonContent className="ion-padding">
<p>Focus-trapped modal content</p>
<IonButton onClick={() => setShowTrapped(false)}>Close</IonButton>
</IonContent>
</IonModal>
</IonContent>
</IonPage>
);
};
export default ModalFocusTrap;

View File

@ -0,0 +1,71 @@
import React, { useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonModal,
IonPage,
IonTitle,
IonToolbar,
} from '@ionic/react';
const ModalTeleport: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [count, setCount] = useState(0);
return (
<IonPage>
<IonContent className="ion-padding">
<div id="example" style={{ minHeight: '40vh' }}></div>
<IonButton id="teleport-background-action" onClick={() => setCount((c) => c + 1)}>
Background Action
</IonButton>
<div>
Background action count: <span id="teleport-background-action-count">{count}</span>
</div>
<IonButton id="open-teleport-modal" onClick={() => setIsOpen(true)}>
Open Teleported Modal
</IonButton>
{isOpen && (
<IonModal
isOpen={true}
onDidDismiss={() => setIsOpen(false)}
onWillPresent={(event) => {
const container = document.getElementById('example');
if (container) {
container.appendChild(event.target as HTMLElement);
}
}}
breakpoints={[0.2, 0.5, 0.7]}
initialBreakpoint={0.5}
showBackdrop={false}
>
<IonHeader>
<IonToolbar>
<IonTitle>Modal</IonTitle>
<IonButtons slot="end">
<IonButton id="close-teleport-modal" onClick={() => setIsOpen(false)}>
Close
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<p>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni illum quidem recusandae ducimus quos
reprehenderit. Veniam, molestias quos, dolorum consequuntur nisi deserunt omnis id illo sit cum qui.
Eaque, dicta.
</p>
</IonContent>
</IonModal>
)}
</IonContent>
</IonPage>
);
};
export default ModalTeleport;

View File

@ -14,6 +14,8 @@ import ActionSheetComponent from './ActionSheetComponent';
import AlertComponent from './AlertComponent';
import LoadingComponent from './LoadingComponent';
import ModalComponent from './ModalComponent';
import ModalFocusTrap from './ModalFocusTrap';
import ModalTeleport from './ModalTeleport';
import PickerComponent from './PickerComponent';
import PopoverComponent from './PopoverComponent';
import ToastComponent from './ToastComponent';
@ -28,7 +30,9 @@ const OverlayHooks: React.FC<OverlayHooksProps> = () => {
<Route path="/overlay-components/actionsheet" component={ActionSheetComponent} />
<Route path="/overlay-components/alert" component={AlertComponent} />
<Route path="/overlay-components/loading" component={LoadingComponent} />
<Route path="/overlay-components/modal" component={ModalComponent} />
<Route path="/overlay-components/modal-basic" component={ModalComponent} />
<Route path="/overlay-components/modal-focus-trap" component={ModalFocusTrap} />
<Route path="/overlay-components/modal-teleport" component={ModalTeleport} />
<Route path="/overlay-components/picker" component={PickerComponent} />
<Route path="/overlay-components/popover" component={PopoverComponent} />
<Route path="/overlay-components/toast" component={ToastComponent} />
@ -46,10 +50,18 @@ const OverlayHooks: React.FC<OverlayHooksProps> = () => {
<IonIcon icon={addCircleOutline} />
<IonLabel>Loading</IonLabel>
</IonTabButton>
<IonTabButton tab="modal" href="/overlay-components/modal">
<IonTabButton tab="modal" href="/overlay-components/modal-basic">
<IonIcon icon={star} />
<IonLabel>Modal</IonLabel>
</IonTabButton>
<IonTabButton tab="modalFocus" href="/overlay-components/modal-focus-trap">
<IonIcon icon={star} />
<IonLabel>Modal Focus</IonLabel>
</IonTabButton>
<IonTabButton tab="modalTeleport" href="/overlay-components/modal-teleport">
<IonIcon icon={star} />
<IonLabel>Modal Teleport</IonLabel>
</IonTabButton>
<IonTabButton tab="picker" href="/overlay-components/picker">
<IonIcon icon={logoIonic} />
<IonLabel>Picker</IonLabel>

View File

@ -1,6 +1,6 @@
describe('IonModal', () => {
beforeEach(() => {
cy.visit('/overlay-components/modal');
cy.visit('/overlay-components/modal-basic');
});
it('display modal', () => {

View File

@ -0,0 +1,36 @@
describe('IonModal: focusTrap regression', () => {
beforeEach(() => {
cy.visit('/overlay-components/modal-focus-trap');
});
it('should allow interacting with background when focusTrap=false', () => {
cy.get('#open-non-trapped-modal').click();
cy.get('ion-modal').should('be.visible');
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');
// Ensure backdrop is active and capturing pointer events
cy.get('ion-backdrop').should('exist');
cy.get('ion-backdrop').should('have.css', 'pointer-events', 'auto');
// Baseline: counter is 0
cy.get('#background-action-count').should('have.text', '0');
// Click the center of the background button via body coordinates (topmost element will receive it)
cy.get('#background-action').then(($btn) => {
const rect = $btn[0].getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
cy.get('body').click(x, y);
});
// Counter should remain unchanged
cy.get('#background-action-count').should('have.text', '0');
});
});

View File

@ -0,0 +1,27 @@
describe('IonModal: inline teleport with showBackdrop=false', () => {
beforeEach(() => {
cy.visit('/overlay-components/modal-teleport');
});
it('should render and remain interactive when appended into a page container', () => {
cy.get('#open-teleport-modal').click();
cy.get('ion-modal').should('be.visible');
// Verify modal content is interactable: close button should dismiss the modal
cy.get('#close-teleport-modal').click();
cy.get('ion-modal').should('not.exist');
});
it('should allow background interaction when showBackdrop=false', () => {
cy.get('#open-teleport-modal').click();
cy.get('ion-modal').should('be.visible');
// Ensure the background button is clickable while modal is open
cy.get('#teleport-background-action').click();
cy.get('#teleport-background-action-count').should('have.text', '1');
// Cleanup
cy.get('#close-teleport-modal').click();
cy.get('ion-modal').should('not.exist');
});
});

View File

@ -1,7 +1,7 @@
describe('keepContentsMounted', () => {
describe('modal', () => {
it('should not mount component if false', () => {
cy.visit('/overlay-components/modal');
cy.visit('/overlay-components/modal-basic');
cy.get('ion-modal ion-content').should('not.exist');
});