From 3c0863050abbd783e3576e8953300e56c93909c6 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 22 Sep 2025 09:31:37 -0700 Subject: [PATCH 01/10] test(modal): adding test for sheet modals that should help us prevent this regression in the future --- .../components/modal/test/sheet/index.html | 77 ++++++++++++++++++ .../e2e/src/lazy/modal-sheet-inline.spec.ts | 34 ++++++++ .../base/src/app/lazy/app-lazy/app.routes.ts | 2 +- .../src/app/lazy/modal-sheet-inline/index.ts | 2 + .../modal-sheet-inline-routing.module.ts | 16 ++++ .../modal-sheet-inline.component.html | 46 +++++++++++ .../modal-sheet-inline.component.ts | 79 +++++++++++++++++++ .../modal-sheet-inline.module.ts | 12 +++ 8 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 packages/angular/test/base/e2e/src/lazy/modal-sheet-inline.spec.ts create mode 100644 packages/angular/test/base/src/app/lazy/modal-sheet-inline/index.ts create mode 100644 packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline-routing.module.ts create mode 100644 packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline.component.html create mode 100644 packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline.component.ts create mode 100644 packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline.module.ts diff --git a/core/src/components/modal/test/sheet/index.html b/core/src/components/modal/test/sheet/index.html index bc78bb10535..92063d05177 100644 --- a/core/src/components/modal/test/sheet/index.html +++ b/core/src/components/modal/test/sheet/index.html @@ -151,6 +151,53 @@ > Backdrop is inactive + + +
+ + + + + + + + + +

Connor Smith

+

Sales Rep

+
+
+ + + + + +

Daniel Smith

+

Product Designer

+
+
+ + + + + +

Greg Smith

+

Director of Operations

+
+
+ + + + + +

Zoey Smith

+

CEO

+
+
+
+
+
+
@@ -173,6 +220,36 @@ console.log('WillDismiss', e); }); + const inlineSheetModal = document.querySelector('#inline-sheet-modal'); + const inlineSheetTrigger = document.querySelector('#present-inline-sheet-modal'); + const inlineSheetSearchbar = document.querySelector('#inline-sheet-searchbar'); + + if (inlineSheetModal) { + inlineSheetModal.initialBreakpoint = 0.2; + inlineSheetModal.breakpoints = [0, 0.2, 0.75, 1]; + inlineSheetModal.backdropDismiss = false; + inlineSheetModal.backdropBreakpoint = 0.5; + inlineSheetModal.focusTrap = false; + inlineSheetModal.handleBehavior = 'cycle'; + inlineSheetModal.presentingElement = document.querySelector('.ion-page'); + + inlineSheetModal.addEventListener('ionModalDidDismiss', () => { + inlineSheetModal.isOpen = false; + }); + + if (inlineSheetTrigger) { + inlineSheetTrigger.addEventListener('click', () => { + inlineSheetModal.isOpen = true; + }); + } + + if (inlineSheetSearchbar) { + inlineSheetSearchbar.addEventListener('click', () => { + inlineSheetModal.setCurrentBreakpoint(0.75); + }); + } + } + function createModal(options) { let items = ''; diff --git a/packages/angular/test/base/e2e/src/lazy/modal-sheet-inline.spec.ts b/packages/angular/test/base/e2e/src/lazy/modal-sheet-inline.spec.ts new file mode 100644 index 00000000000..0e23c057d0a --- /dev/null +++ b/packages/angular/test/base/e2e/src/lazy/modal-sheet-inline.spec.ts @@ -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'); + }); +}); diff --git a/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts b/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts index 1a46992f92c..c8f8912a642 100644 --- a/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts +++ b/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts @@ -37,6 +37,7 @@ 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: '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 +91,3 @@ export const routes: Routes = [ ] }, ]; - diff --git a/packages/angular/test/base/src/app/lazy/modal-sheet-inline/index.ts b/packages/angular/test/base/src/app/lazy/modal-sheet-inline/index.ts new file mode 100644 index 00000000000..0283c2cd952 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/modal-sheet-inline/index.ts @@ -0,0 +1,2 @@ +export * from './modal-sheet-inline.component'; +export * from './modal-sheet-inline.module'; diff --git a/packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline-routing.module.ts b/packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline-routing.module.ts new file mode 100644 index 00000000000..54c208d7022 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline-routing.module.ts @@ -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 { } diff --git a/packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline.component.html b/packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline.component.html new file mode 100644 index 00000000000..6224ed10919 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline.component.html @@ -0,0 +1,46 @@ + + Present Inline Sheet Modal + + +

+ Current breakpoint: {{ currentBreakpoint }} +

+ + + Background Action + + +

+ Background action count: {{ backgroundActionCount }} +

+ + + + + + + + + + + +

{{ contact.name }}

+

{{ contact.title }}

+
+
+
+
+
+
diff --git a/packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline.component.ts b/packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline.component.ts new file mode 100644 index 00000000000..2e0dc32e089 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline.component.ts @@ -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++; + } + +} diff --git a/packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline.module.ts b/packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline.module.ts new file mode 100644 index 00000000000..9dd75b0a53b --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/modal-sheet-inline/modal-sheet-inline.module.ts @@ -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 { } From 846b7cded122525fc17b3957bba6de83a58874a3 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 22 Sep 2025 09:46:41 -0700 Subject: [PATCH 02/10] fix(modal): allow sheet modals to skip focus trap --- core/src/utils/overlays.ts | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 0a362a6f8fc..c2db3fa46a2 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -529,9 +529,14 @@ export const present = async ( * 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). */ - if (overlay.el.tagName !== 'ION-TOAST') { + const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean }; + const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false; + + if (shouldTrapFocus) { setRootAriaHidden(true); document.body.classList.add(BACKDROP_NO_SCROLL); } @@ -653,22 +658,24 @@ export const dismiss = async ( * 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 overlaysTrappingFocus = presentedOverlays.filter((o) => o.tagName !== 'ION-TOAST' && (o as any).focusTrap !== false); + const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean }; + const trapsFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== 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 = + trapsFocus && overlaysTrappingFocus.length === 1 && overlaysTrappingFocus[0].id === overlayEl.id; + + if (lastOverlayTrappingFocus) { setRootAriaHidden(false); document.body.classList.remove(BACKDROP_NO_SCROLL); } From 69e51288a6ee7964909b98968fea21b6c1632cb4 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 22 Sep 2025 09:50:26 -0700 Subject: [PATCH 03/10] fix(lint): ran lint fix --- core/src/utils/overlays.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index c2db3fa46a2..1b14e76ffde 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -664,7 +664,9 @@ export const dismiss = async ( * from the root element when the last focus-trapping overlay * is dismissed. */ - const overlaysTrappingFocus = presentedOverlays.filter((o) => o.tagName !== 'ION-TOAST' && (o as any).focusTrap !== false); + const overlaysTrappingFocus = presentedOverlays.filter( + (o) => o.tagName !== 'ION-TOAST' && (o as any).focusTrap !== false + ); const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean }; const trapsFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false; From d0c4f0a4619a4685e03b8b429e10cdeb8c4a3072 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 22 Sep 2025 09:53:31 -0700 Subject: [PATCH 04/10] test(angular): adding link to new inline modal test --- .../base/src/app/lazy/home-page/home-page.component.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html b/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html index 136a0119d34..80e59d2de29 100644 --- a/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html +++ b/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html @@ -35,6 +35,11 @@ Modals Test + + + Modal Sheet Inline Test + + Router link Test From 38179d597365e1ce48d35889cbd2b87dda2d715c Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 22 Sep 2025 10:08:05 -0700 Subject: [PATCH 05/10] tests(core): removing test that didn't do anything but broke automated tests because of their bad selectors --- .../components/modal/test/sheet/index.html | 77 ------------------- 1 file changed, 77 deletions(-) diff --git a/core/src/components/modal/test/sheet/index.html b/core/src/components/modal/test/sheet/index.html index 92063d05177..bc78bb10535 100644 --- a/core/src/components/modal/test/sheet/index.html +++ b/core/src/components/modal/test/sheet/index.html @@ -151,53 +151,6 @@ > Backdrop is inactive - - -
- - - - - - - - - -

Connor Smith

-

Sales Rep

-
-
- - - - - -

Daniel Smith

-

Product Designer

-
-
- - - - - -

Greg Smith

-

Director of Operations

-
-
- - - - - -

Zoey Smith

-

CEO

-
-
-
-
-
-
@@ -220,36 +173,6 @@

Zoey Smith

console.log('WillDismiss', e); }); - const inlineSheetModal = document.querySelector('#inline-sheet-modal'); - const inlineSheetTrigger = document.querySelector('#present-inline-sheet-modal'); - const inlineSheetSearchbar = document.querySelector('#inline-sheet-searchbar'); - - if (inlineSheetModal) { - inlineSheetModal.initialBreakpoint = 0.2; - inlineSheetModal.breakpoints = [0, 0.2, 0.75, 1]; - inlineSheetModal.backdropDismiss = false; - inlineSheetModal.backdropBreakpoint = 0.5; - inlineSheetModal.focusTrap = false; - inlineSheetModal.handleBehavior = 'cycle'; - inlineSheetModal.presentingElement = document.querySelector('.ion-page'); - - inlineSheetModal.addEventListener('ionModalDidDismiss', () => { - inlineSheetModal.isOpen = false; - }); - - if (inlineSheetTrigger) { - inlineSheetTrigger.addEventListener('click', () => { - inlineSheetModal.isOpen = true; - }); - } - - if (inlineSheetSearchbar) { - inlineSheetSearchbar.addEventListener('click', () => { - inlineSheetModal.setCurrentBreakpoint(0.75); - }); - } - } - function createModal(options) { let items = ''; From 3b1da5d618bddd0df9dbbac08c3a8147b3506d15 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 23 Sep 2025 08:45:44 -0700 Subject: [PATCH 06/10] fix(sheet): disabling backdrop when focus trap is disabled --- core/src/components/modal/gestures/sheet.ts | 13 ++- .../src/lazy/modal-dynamic-wrapper.spec.ts | 38 +++++++ .../base/src/app/lazy/app-lazy/app.routes.ts | 1 + .../lazy/home-page/home-page.component.html | 5 + .../dynamic-component-wrapper.component.ts | 26 +++++ .../dynamic-modal-content.component.ts | 20 ++++ .../app/lazy/modal-dynamic-wrapper/index.ts | 2 + .../modal-dynamic-wrapper-routing.module.ts | 16 +++ .../modal-dynamic-wrapper.component.html | 8 ++ .../modal-dynamic-wrapper.component.ts | 104 ++++++++++++++++++ .../modal-dynamic-wrapper.module.ts | 14 +++ 11 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 packages/angular/test/base/e2e/src/lazy/modal-dynamic-wrapper.spec.ts create mode 100644 packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/dynamic-component-wrapper.component.ts create mode 100644 packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/dynamic-modal-content.component.ts create mode 100644 packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/index.ts create mode 100644 packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper-routing.module.ts create mode 100644 packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper.component.html create mode 100644 packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper.component.ts create mode 100644 packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper.module.ts diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts index 219769e4879..26a9841f920 100644 --- a/core/src/components/modal/gestures/sheet.ts +++ b/core/src/components/modal/gestures/sheet.ts @@ -95,6 +95,11 @@ 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, do not enable the backdrop or re-enable focus trap + if ((baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap === false) { + return; + } baseEl.style.setProperty('pointer-events', 'auto'); backdropEl.style.setProperty('pointer-events', 'auto'); @@ -235,7 +240,9 @@ 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; if (shouldEnableBackdrop) { enableBackdrop(); } else { @@ -582,7 +589,9 @@ 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; if (shouldEnableBackdrop) { enableBackdrop(); } else { diff --git a/packages/angular/test/base/e2e/src/lazy/modal-dynamic-wrapper.spec.ts b/packages/angular/test/base/e2e/src/lazy/modal-dynamic-wrapper.spec.ts new file mode 100644 index 00000000000..b76f16015bf --- /dev/null +++ b/packages/angular/test/base/e2e/src/lazy/modal-dynamic-wrapper.spec.ts @@ -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'); + }); +}); diff --git a/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts b/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts index c8f8912a642..35a77b19cf1 100644 --- a/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts +++ b/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts @@ -38,6 +38,7 @@ export const routes: Routes = [ { 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) }, diff --git a/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html b/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html index 80e59d2de29..c3ac2659151 100644 --- a/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html +++ b/packages/angular/test/base/src/app/lazy/home-page/home-page.component.html @@ -40,6 +40,11 @@ Modal Sheet Inline Test + + + Modal Dynamic Wrapper Test + + Router link Test diff --git a/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/dynamic-component-wrapper.component.ts b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/dynamic-component-wrapper.component.ts new file mode 100644 index 00000000000..f4304c03a50 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/dynamic-component-wrapper.component.ts @@ -0,0 +1,26 @@ +import { Component, ComponentRef, Input, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; + +@Component({ + selector: 'app-dynamic-component-wrapper', + template: ` + + + + `, + standalone: false +}) +export class DynamicComponentWrapperComponent implements OnInit, OnDestroy { + + @Input() componentRef?: ComponentRef; + @ViewChild('container', { read: ViewContainerRef, static: true }) container!: ViewContainerRef; + + ngOnInit(): void { + if (this.componentRef) { + this.container.insert(this.componentRef.hostView); + } + } + + ngOnDestroy(): void { + this.componentRef?.destroy(); + } +} diff --git a/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/dynamic-modal-content.component.ts b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/dynamic-modal-content.component.ts new file mode 100644 index 00000000000..ad1b76c05a7 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/dynamic-modal-content.component.ts @@ -0,0 +1,20 @@ +import { Component, EventEmitter, Output } from "@angular/core"; + +@Component({ + selector: 'app-dynamic-modal-content', + template: ` + + + Dynamic Sheet Content + + + +

Dynamic component rendered inside wrapper.

+ Close +
+ `, + standalone: false +}) +export class DynamicModalContentComponent { + @Output() dismiss = new EventEmitter(); +} diff --git a/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/index.ts b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/index.ts new file mode 100644 index 00000000000..ca0da1dfbe7 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/index.ts @@ -0,0 +1,2 @@ +export * from './modal-dynamic-wrapper.component'; +export * from './modal-dynamic-wrapper.module'; diff --git a/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper-routing.module.ts b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper-routing.module.ts new file mode 100644 index 00000000000..a8ef812c715 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper-routing.module.ts @@ -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 { } diff --git a/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper.component.html b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper.component.html new file mode 100644 index 00000000000..c3655c56252 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper.component.html @@ -0,0 +1,8 @@ +Open Dynamic Sheet Modal +Open Focus-Trapped Sheet Modal +Background Action +

+ Background action count: {{ backgroundActionCount }} +

+ + diff --git a/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper.component.ts b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper.component.ts new file mode 100644 index 00000000000..dd05d3c6f10 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper.component.ts @@ -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; + + 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(); + } +} diff --git a/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper.module.ts b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper.module.ts new file mode 100644 index 00000000000..2ab22c5aca3 --- /dev/null +++ b/packages/angular/test/base/src/app/lazy/modal-dynamic-wrapper/modal-dynamic-wrapper.module.ts @@ -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 { } From 435d7e21c6a4806eb9c49ca1dd23b3cb62686f91 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 23 Sep 2025 11:36:33 -0700 Subject: [PATCH 07/10] fix(overlay): fixing issues with teleported modals --- core/src/components/modal/gestures/sheet.ts | 11 +-- core/src/utils/overlays.ts | 44 ++++++++---- .../overlay-components/ModalFocusTrap.tsx | 61 ++++++++++++++++ .../overlay-components/ModalTeleport.tsx | 71 +++++++++++++++++++ .../overlay-components/OverlayComponents.tsx | 12 ++++ .../IonModalFocusTrap.cy.ts | 36 ++++++++++ .../overlay-components/IonModalTeleport.cy.ts | 27 +++++++ 7 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 packages/react/test/base/src/pages/overlay-components/ModalFocusTrap.tsx create mode 100644 packages/react/test/base/src/pages/overlay-components/ModalTeleport.tsx create mode 100644 packages/react/test/base/tests/e2e/specs/overlay-components/IonModalFocusTrap.cy.ts create mode 100644 packages/react/test/base/tests/e2e/specs/overlay-components/IonModalTeleport.cy.ts diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts index 26a9841f920..afac8f3d3ed 100644 --- a/core/src/components/modal/gestures/sheet.ts +++ b/core/src/components/modal/gestures/sheet.ts @@ -96,8 +96,9 @@ export const createSheetGesture = ( const enableBackdrop = () => { // Respect explicit opt-out of focus trapping/backdrop interactions - // If focusTrap is false, do not enable the backdrop or re-enable focus trap - if ((baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap === false) { + // 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'); @@ -242,7 +243,8 @@ export const createSheetGesture = ( */ const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint && - (baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false; + (baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false && + (baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false; if (shouldEnableBackdrop) { enableBackdrop(); } else { @@ -591,7 +593,8 @@ export const createSheetGesture = ( */ const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint && - (baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false; + (baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false && + (baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false; if (shouldEnableBackdrop) { enableBackdrop(); } else { diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 1b14e76ffde..d2401bd0490 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -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'); } }; @@ -532,17 +530,34 @@ export const present = async ( * 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. */ - const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean }; + const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean }; const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false; - - if (shouldTrapFocus) { - setRootAriaHidden(true); - document.body.classList.add(BACKDROP_NO_SCROLL); - } + // 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); @@ -664,18 +679,19 @@ export const dismiss = async ( * from the root element when the last focus-trapping overlay * is dismissed. */ - const overlaysTrappingFocus = presentedOverlays.filter( - (o) => o.tagName !== 'ION-TOAST' && (o as any).focusTrap !== false - ); - const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean }; - const trapsFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false; + 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 trapping focus * then we want to re-add the root to the accessibility tree. */ const lastOverlayTrappingFocus = - trapsFocus && overlaysTrappingFocus.length === 1 && overlaysTrappingFocus[0].id === overlayEl.id; + locksRoot && overlaysLockingRoot.length === 1 && overlaysLockingRoot[0].id === overlayEl.id; if (lastOverlayTrappingFocus) { setRootAriaHidden(false); diff --git a/packages/react/test/base/src/pages/overlay-components/ModalFocusTrap.tsx b/packages/react/test/base/src/pages/overlay-components/ModalFocusTrap.tsx new file mode 100644 index 00000000000..7227946b772 --- /dev/null +++ b/packages/react/test/base/src/pages/overlay-components/ModalFocusTrap.tsx @@ -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 ( + + + setShowNonTrapped(true)}> + Open Non-Trapped Sheet Modal + + setShowTrapped(true)}> + Open Focus-Trapped Sheet Modal + + + setCount((c) => c + 1)}> + Background Action + +
+ Background action count: {count} +
+ + setShowNonTrapped(false)} + breakpoints={[0, 0.25, 0.5, 0.75, 1]} + initialBreakpoint={0.25} + backdropDismiss={false} + focusTrap={false} + handleBehavior="cycle" + > + +

Non-trapped modal content

+ setShowNonTrapped(false)}>Close +
+
+ + setShowTrapped(false)} + breakpoints={[0, 0.25, 0.5, 0.75, 1]} + initialBreakpoint={0.5} + backdropDismiss={false} + focusTrap={true} + handleBehavior="cycle" + > + +

Focus-trapped modal content

+ setShowTrapped(false)}>Close +
+
+
+
+ ); +}; + +export default ModalFocusTrap; + diff --git a/packages/react/test/base/src/pages/overlay-components/ModalTeleport.tsx b/packages/react/test/base/src/pages/overlay-components/ModalTeleport.tsx new file mode 100644 index 00000000000..bd9028f842a --- /dev/null +++ b/packages/react/test/base/src/pages/overlay-components/ModalTeleport.tsx @@ -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 ( + + +
+ + setCount((c) => c + 1)}> + Background Action + +
+ Background action count: {count} +
+ + setIsOpen(true)}> + Open Teleported Modal + + + {isOpen && ( + 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} + > + + + Modal + + setIsOpen(false)}> + Close + + + + + +

+ 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. +

+
+
+ )} +
+
+ ); +}; + +export default ModalTeleport; 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 cd9a4ff1045..b73a1d9a8c8 100644 --- a/packages/react/test/base/src/pages/overlay-components/OverlayComponents.tsx +++ b/packages/react/test/base/src/pages/overlay-components/OverlayComponents.tsx @@ -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'; @@ -29,6 +31,8 @@ const OverlayHooks: React.FC = () => { + + @@ -50,6 +54,14 @@ const OverlayHooks: React.FC = () => { Modal + + + Modal Focus + + + + Modal Teleport + 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 new file mode 100644 index 00000000000..395c642dcc4 --- /dev/null +++ b/packages/react/test/base/tests/e2e/specs/overlay-components/IonModalFocusTrap.cy.ts @@ -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'); + }); +}); diff --git a/packages/react/test/base/tests/e2e/specs/overlay-components/IonModalTeleport.cy.ts b/packages/react/test/base/tests/e2e/specs/overlay-components/IonModalTeleport.cy.ts new file mode 100644 index 00000000000..738f2cd9859 --- /dev/null +++ b/packages/react/test/base/tests/e2e/specs/overlay-components/IonModalTeleport.cy.ts @@ -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'); + }); +}); From 03435a3d492b662f7680f15fec7a5b88b8604443 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 23 Sep 2025 11:37:35 -0700 Subject: [PATCH 08/10] fix(lint): fixing lint --- core/src/utils/overlays.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index d2401bd0490..5b983158aa5 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -684,7 +684,8 @@ export const dismiss = async ( 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; + const locksRoot = + overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && overlayEl.showBackdrop !== false; /** * If this is the last visible overlay that is trapping focus From bbe415485f76b0e713c52f9d875c7b2bb922f250 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 24 Sep 2025 11:58:07 -0700 Subject: [PATCH 09/10] test(angular): copying new lazy tests to standalone, fixing react route tab issue --- .../standalone/modal-dynamic-wrapper.spec.ts | 38 +++++++ .../src/standalone/modal-sheet-inline.spec.ts | 34 ++++++ .../standalone/app-standalone/app.routes.ts | 2 + .../home-page/home-page.component.html | 10 ++ .../dynamic-component-wrapper.component.ts | 27 +++++ .../dynamic-modal-content.component.ts | 28 +++++ .../modal-dynamic-wrapper.component.html | 8 ++ .../modal-dynamic-wrapper.component.ts | 103 ++++++++++++++++++ .../modal-sheet-inline.component.html | 46 ++++++++ .../modal-sheet-inline.component.ts | 100 +++++++++++++++++ .../overlay-components/OverlayComponents.tsx | 4 +- 11 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 packages/angular/test/base/e2e/src/standalone/modal-dynamic-wrapper.spec.ts create mode 100644 packages/angular/test/base/e2e/src/standalone/modal-sheet-inline.spec.ts create mode 100644 packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/dynamic-component-wrapper.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/dynamic-modal-content.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/modal-dynamic-wrapper.component.html create mode 100644 packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/modal-dynamic-wrapper.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/modal-sheet-inline/modal-sheet-inline.component.html create mode 100644 packages/angular/test/base/src/app/standalone/modal-sheet-inline/modal-sheet-inline.component.ts diff --git a/packages/angular/test/base/e2e/src/standalone/modal-dynamic-wrapper.spec.ts b/packages/angular/test/base/e2e/src/standalone/modal-dynamic-wrapper.spec.ts new file mode 100644 index 00000000000..6f3e0c6a03f --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/modal-dynamic-wrapper.spec.ts @@ -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'); + }); +}); diff --git a/packages/angular/test/base/e2e/src/standalone/modal-sheet-inline.spec.ts b/packages/angular/test/base/e2e/src/standalone/modal-sheet-inline.spec.ts new file mode 100644 index 00000000000..3bebd5173cd --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/modal-sheet-inline.spec.ts @@ -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'); + }); +}); 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 fafb69c62ad..ed9628ae7c9 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 @@ -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) }, 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 7900bdfb64e..fd6ae409a3b 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 @@ -90,6 +90,16 @@ Modal Test
+ + + Modal Sheet Inline Test + + + + + Modal Dynamic Wrapper Test + + Programmatic Modal Test diff --git a/packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/dynamic-component-wrapper.component.ts b/packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/dynamic-component-wrapper.component.ts new file mode 100644 index 00000000000..d79ec4abff8 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/dynamic-component-wrapper.component.ts @@ -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: ` + + + + `, + standalone: true, + imports: [IonContent], +}) +export class DynamicComponentWrapperComponent implements OnInit, OnDestroy { + @Input() componentRef?: ComponentRef; + @ViewChild('container', { read: ViewContainerRef, static: true }) container!: ViewContainerRef; + + ngOnInit(): void { + if (this.componentRef) { + this.container.insert(this.componentRef.hostView); + } + } + + ngOnDestroy(): void { + this.componentRef?.destroy(); + } +} diff --git a/packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/dynamic-modal-content.component.ts b/packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/dynamic-modal-content.component.ts new file mode 100644 index 00000000000..4022f65b5ee --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/dynamic-modal-content.component.ts @@ -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: ` + + + Dynamic Sheet Content + + + +

Dynamic component rendered inside wrapper.

+ Close +
+ `, + standalone: true, + imports: [IonButton, IonContent, IonHeader, IonTitle, IonToolbar], +}) +export class DynamicModalContentComponent { + @Output() dismiss = new EventEmitter(); +} diff --git a/packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/modal-dynamic-wrapper.component.html b/packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/modal-dynamic-wrapper.component.html new file mode 100644 index 00000000000..c3655c56252 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/modal-dynamic-wrapper.component.html @@ -0,0 +1,8 @@ +Open Dynamic Sheet Modal +Open Focus-Trapped Sheet Modal +Background Action +

+ Background action count: {{ backgroundActionCount }} +

+ + diff --git a/packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/modal-dynamic-wrapper.component.ts b/packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/modal-dynamic-wrapper.component.ts new file mode 100644 index 00000000000..a88f18ef3e2 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/modal-dynamic-wrapper/modal-dynamic-wrapper.component.ts @@ -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; + + 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(); + } +} diff --git a/packages/angular/test/base/src/app/standalone/modal-sheet-inline/modal-sheet-inline.component.html b/packages/angular/test/base/src/app/standalone/modal-sheet-inline/modal-sheet-inline.component.html new file mode 100644 index 00000000000..6224ed10919 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/modal-sheet-inline/modal-sheet-inline.component.html @@ -0,0 +1,46 @@ + + Present Inline Sheet Modal + + +

+ Current breakpoint: {{ currentBreakpoint }} +

+ + + Background Action + + +

+ Background action count: {{ backgroundActionCount }} +

+ + + + + + + + + + + +

{{ contact.name }}

+

{{ contact.title }}

+
+
+
+
+
+
diff --git a/packages/angular/test/base/src/app/standalone/modal-sheet-inline/modal-sheet-inline.component.ts b/packages/angular/test/base/src/app/standalone/modal-sheet-inline/modal-sheet-inline.component.ts new file mode 100644 index 00000000000..2cdee8df097 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/modal-sheet-inline/modal-sheet-inline.component.ts @@ -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++; + } +} 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 b73a1d9a8c8..19aebc9081c 100644 --- a/packages/react/test/base/src/pages/overlay-components/OverlayComponents.tsx +++ b/packages/react/test/base/src/pages/overlay-components/OverlayComponents.tsx @@ -30,7 +30,7 @@ const OverlayHooks: React.FC = () => { - + @@ -50,7 +50,7 @@ const OverlayHooks: React.FC = () => { Loading - + Modal From bbc80c892875c32368214bcef2fdeb7e36bd6ace Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 24 Sep 2025 12:08:48 -0700 Subject: [PATCH 10/10] test(react): fixing changed route in tests --- .../test/base/tests/e2e/specs/overlay-components/IonModal.cy.ts | 2 +- .../e2e/specs/overlay-components/KeepContentsMounted.cy.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/test/base/tests/e2e/specs/overlay-components/IonModal.cy.ts b/packages/react/test/base/tests/e2e/specs/overlay-components/IonModal.cy.ts index b3dc847074d..bf6ce5aca3c 100644 --- a/packages/react/test/base/tests/e2e/specs/overlay-components/IonModal.cy.ts +++ b/packages/react/test/base/tests/e2e/specs/overlay-components/IonModal.cy.ts @@ -1,6 +1,6 @@ describe('IonModal', () => { beforeEach(() => { - cy.visit('/overlay-components/modal'); + cy.visit('/overlay-components/modal-basic'); }); it('display modal', () => { diff --git a/packages/react/test/base/tests/e2e/specs/overlay-components/KeepContentsMounted.cy.ts b/packages/react/test/base/tests/e2e/specs/overlay-components/KeepContentsMounted.cy.ts index 8e4b0bdf833..ee5086d2f1a 100644 --- a/packages/react/test/base/tests/e2e/specs/overlay-components/KeepContentsMounted.cy.ts +++ b/packages/react/test/base/tests/e2e/specs/overlay-components/KeepContentsMounted.cy.ts @@ -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'); });