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

@ -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++;
}
}