Skip to content

Commit 3b1da5d

Browse files
committed
fix(sheet): disabling backdrop when focus trap is disabled
1 parent df354a7 commit 3b1da5d

File tree

11 files changed

+245
-2
lines changed

11 files changed

+245
-2
lines changed

core/src/components/modal/gestures/sheet.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ export const createSheetGesture = (
9595
const contentAnimation = animation.childAnimations.find((ani) => ani.id === 'contentAnimation');
9696

9797
const enableBackdrop = () => {
98+
// Respect explicit opt-out of focus trapping/backdrop interactions
99+
// If focusTrap is false, do not enable the backdrop or re-enable focus trap
100+
if ((baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap === false) {
101+
return;
102+
}
98103
baseEl.style.setProperty('pointer-events', 'auto');
99104
backdropEl.style.setProperty('pointer-events', 'auto');
100105

@@ -235,7 +240,9 @@ export const createSheetGesture = (
235240
* ion-backdrop and .modal-wrapper always have pointer-events: auto
236241
* applied, so the modal content can still be interacted with.
237242
*/
238-
const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint;
243+
const shouldEnableBackdrop =
244+
currentBreakpoint > backdropBreakpoint &&
245+
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false;
239246
if (shouldEnableBackdrop) {
240247
enableBackdrop();
241248
} else {
@@ -582,7 +589,9 @@ export const createSheetGesture = (
582589
* Backdrop should become enabled
583590
* after the backdropBreakpoint value
584591
*/
585-
const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint;
592+
const shouldEnableBackdrop =
593+
currentBreakpoint > backdropBreakpoint &&
594+
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false;
586595
if (shouldEnableBackdrop) {
587596
enableBackdrop();
588597
} else {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
test.describe('Modals: Dynamic Wrapper', () => {
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('/lazy/modal-dynamic-wrapper');
6+
});
7+
8+
test('should render dynamic component inside modal', async ({ page }) => {
9+
await page.locator('#open-dynamic-modal').click();
10+
11+
await expect(page.locator('ion-modal')).toBeVisible();
12+
await expect(page.locator('#dynamic-component-loaded')).toBeVisible();
13+
});
14+
15+
test('should allow interacting with background content while sheet is open', async ({ page }) => {
16+
await page.locator('#open-dynamic-modal').click();
17+
18+
await expect(page.locator('ion-modal')).toBeVisible();
19+
20+
await page.locator('#background-action').click();
21+
22+
await expect(page.locator('#background-action-count')).toHaveText('1');
23+
});
24+
25+
test('should prevent interacting with background content when focus is trapped', async ({ page }) => {
26+
await page.locator('#open-focused-modal').click();
27+
28+
await expect(page.locator('ion-modal')).toBeVisible();
29+
30+
// Attempt to click the background button via coordinates; click should be intercepted by backdrop
31+
const box = await page.locator('#background-action').boundingBox();
32+
if (box) {
33+
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
34+
}
35+
36+
await expect(page.locator('#background-action-count')).toHaveText('0');
37+
});
38+
});

packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const routes: Routes = [
3838
{ path: 'modals', component: ModalComponent },
3939
{ path: 'modal-inline', loadChildren: () => import('../modal-inline').then(m => m.ModalInlineModule) },
4040
{ path: 'modal-sheet-inline', loadChildren: () => import('../modal-sheet-inline').then(m => m.ModalSheetInlineModule) },
41+
{ path: 'modal-dynamic-wrapper', loadChildren: () => import('../modal-dynamic-wrapper').then(m => m.ModalDynamicWrapperModule) },
4142
{ path: 'view-child', component: ViewChildComponent },
4243
{ path: 'keep-contents-mounted', loadChildren: () => import('../keep-contents-mounted').then(m => m.OverlayAutoMountModule) },
4344
{ path: 'overlays-inline', loadChildren: () => import('../overlays-inline').then(m => m.OverlaysInlineModule) },

packages/angular/test/base/src/app/lazy/home-page/home-page.component.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
4040
Modal Sheet Inline Test
4141
</ion-label>
4242
</ion-item>
43+
<ion-item routerLink="/lazy/modal-dynamic-wrapper">
44+
<ion-label>
45+
Modal Dynamic Wrapper Test
46+
</ion-label>
47+
</ion-item>
4348
<ion-item routerLink="/lazy/router-link">
4449
<ion-label>
4550
Router link Test
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Component, ComponentRef, Input, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
2+
3+
@Component({
4+
selector: 'app-dynamic-component-wrapper',
5+
template: `
6+
<ion-content>
7+
<ng-container #container></ng-container>
8+
</ion-content>
9+
`,
10+
standalone: false
11+
})
12+
export class DynamicComponentWrapperComponent implements OnInit, OnDestroy {
13+
14+
@Input() componentRef?: ComponentRef<unknown>;
15+
@ViewChild('container', { read: ViewContainerRef, static: true }) container!: ViewContainerRef;
16+
17+
ngOnInit(): void {
18+
if (this.componentRef) {
19+
this.container.insert(this.componentRef.hostView);
20+
}
21+
}
22+
23+
ngOnDestroy(): void {
24+
this.componentRef?.destroy();
25+
}
26+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Component, EventEmitter, Output } from "@angular/core";
2+
3+
@Component({
4+
selector: 'app-dynamic-modal-content',
5+
template: `
6+
<ion-header>
7+
<ion-toolbar>
8+
<ion-title>Dynamic Sheet Content</ion-title>
9+
</ion-toolbar>
10+
</ion-header>
11+
<ion-content class="ion-padding">
12+
<p id="dynamic-component-loaded">Dynamic component rendered inside wrapper.</p>
13+
<ion-button id="dismiss-dynamic-modal" (click)="dismiss.emit()">Close</ion-button>
14+
</ion-content>
15+
`,
16+
standalone: false
17+
})
18+
export class DynamicModalContentComponent {
19+
@Output() dismiss = new EventEmitter<void>();
20+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './modal-dynamic-wrapper.component';
2+
export * from './modal-dynamic-wrapper.module';
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { NgModule } from "@angular/core";
2+
import { RouterModule } from "@angular/router";
3+
import { ModalDynamicWrapperComponent } from ".";
4+
5+
@NgModule({
6+
imports: [
7+
RouterModule.forChild([
8+
{
9+
path: '',
10+
component: ModalDynamicWrapperComponent
11+
}
12+
])
13+
],
14+
exports: [RouterModule]
15+
})
16+
export class ModalDynamicWrapperRoutingModule { }
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<ion-button id="open-dynamic-modal" (click)="openModal()">Open Dynamic Sheet Modal</ion-button>
2+
<ion-button id="open-focused-modal" color="primary" (click)="openFocusedModal()">Open Focus-Trapped Sheet Modal</ion-button>
3+
<ion-button id="background-action" (click)="onBackgroundActionClick()">Background Action</ion-button>
4+
<p>
5+
Background action count: <span id="background-action-count">{{ backgroundActionCount }}</span>
6+
</p>
7+
8+
<ng-template #modalHost></ng-template>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Component, ComponentRef, OnDestroy, ViewChild, ViewContainerRef } from "@angular/core";
2+
import { ModalController } from "@ionic/angular";
3+
import { DynamicComponentWrapperComponent } from "./dynamic-component-wrapper.component";
4+
import { DynamicModalContentComponent } from "./dynamic-modal-content.component";
5+
6+
@Component({
7+
selector: 'app-modal-dynamic-wrapper',
8+
templateUrl: './modal-dynamic-wrapper.component.html',
9+
standalone: false
10+
})
11+
export class ModalDynamicWrapperComponent implements OnDestroy {
12+
13+
@ViewChild('modalHost', { read: ViewContainerRef, static: true }) modalHost!: ViewContainerRef;
14+
15+
backgroundActionCount = 0;
16+
17+
private currentModal?: HTMLIonModalElement;
18+
private currentComponentRef?: ComponentRef<DynamicModalContentComponent>;
19+
20+
constructor(private modalCtrl: ModalController) {}
21+
22+
async openModal() {
23+
await this.closeModal();
24+
25+
const componentRef = this.modalHost.createComponent(DynamicModalContentComponent);
26+
this.modalHost.detach();
27+
componentRef.instance.dismiss.subscribe(() => this.closeModal());
28+
29+
this.currentComponentRef = componentRef;
30+
31+
const modal = await this.modalCtrl.create({
32+
component: DynamicComponentWrapperComponent,
33+
componentProps: {
34+
componentRef
35+
},
36+
breakpoints: [0, 0.2, 0.75, 1],
37+
initialBreakpoint: 0.2,
38+
backdropDismiss: false,
39+
focusTrap: false,
40+
handleBehavior: 'cycle'
41+
});
42+
43+
this.currentModal = modal;
44+
45+
modal.onWillDismiss().then(() => this.destroyComponent());
46+
47+
await modal.present();
48+
}
49+
50+
async openFocusedModal() {
51+
await this.closeModal();
52+
53+
const componentRef = this.modalHost.createComponent(DynamicModalContentComponent);
54+
this.modalHost.detach();
55+
componentRef.instance.dismiss.subscribe(() => this.closeModal());
56+
57+
this.currentComponentRef = componentRef;
58+
59+
const modal = await this.modalCtrl.create({
60+
component: DynamicComponentWrapperComponent,
61+
componentProps: {
62+
componentRef,
63+
},
64+
// Choose a higher initial breakpoint to ensure backdrop is active immediately
65+
breakpoints: [0, 0.25, 0.5, 0.75, 1],
66+
initialBreakpoint: 0.5,
67+
// Keep backdrop active but do not dismiss on tap to avoid interfering with assertions
68+
backdropDismiss: false,
69+
// Explicitly enable focus trapping to block background interaction
70+
focusTrap: true,
71+
handleBehavior: 'cycle',
72+
});
73+
74+
this.currentModal = modal;
75+
76+
modal.onWillDismiss().then(() => this.destroyComponent());
77+
78+
await modal.present();
79+
}
80+
81+
async closeModal() {
82+
if (this.currentModal) {
83+
await this.currentModal.dismiss();
84+
this.currentModal = undefined;
85+
}
86+
87+
this.destroyComponent();
88+
}
89+
90+
private destroyComponent() {
91+
if (this.currentComponentRef) {
92+
this.currentComponentRef.destroy();
93+
this.currentComponentRef = undefined;
94+
}
95+
}
96+
97+
onBackgroundActionClick() {
98+
this.backgroundActionCount++;
99+
}
100+
101+
ngOnDestroy(): void {
102+
this.destroyComponent();
103+
}
104+
}

0 commit comments

Comments
 (0)