diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts index 219769e4879..afac8f3d3ed 100644 --- a/core/src/components/modal/gestures/sheet.ts +++ b/core/src/components/modal/gestures/sheet.ts @@ -95,6 +95,12 @@ export const createSheetGesture = ( const contentAnimation = animation.childAnimations.find((ani) => ani.id === 'contentAnimation'); const enableBackdrop = () => { + // Respect explicit opt-out of focus trapping/backdrop interactions + // If focusTrap is false or showBackdrop is false, do not enable the backdrop or re-enable focus trap + const el = baseEl as HTMLIonModalElement & { focusTrap?: boolean; showBackdrop?: boolean }; + if (el.focusTrap === false || el.showBackdrop === false) { + return; + } baseEl.style.setProperty('pointer-events', 'auto'); backdropEl.style.setProperty('pointer-events', 'auto'); @@ -235,7 +241,10 @@ export const createSheetGesture = ( * ion-backdrop and .modal-wrapper always have pointer-events: auto * applied, so the modal content can still be interacted with. */ - const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint; + const shouldEnableBackdrop = + currentBreakpoint > backdropBreakpoint && + (baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false && + (baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false; if (shouldEnableBackdrop) { enableBackdrop(); } else { @@ -582,7 +591,10 @@ export const createSheetGesture = ( * Backdrop should become enabled * after the backdropBreakpoint value */ - const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint; + const shouldEnableBackdrop = + currentBreakpoint > backdropBreakpoint && + (baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false && + (baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false; if (shouldEnableBackdrop) { enableBackdrop(); } else { diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 0a362a6f8fc..5b983158aa5 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'); } }; @@ -529,15 +527,37 @@ 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). + * + * Note: Some apps move inline overlays to a specific container + * during the willPresent lifecycle (e.g., React portals via + * onWillPresent). Defer applying aria-hidden/inert to the app + * root until after willPresent so we can detect where the + * overlay is finally inserted. If the overlay is inside the + * view container subtree, skip adding aria-hidden/inert there + * to avoid disabling the overlay. */ - if (overlay.el.tagName !== 'ION-TOAST') { - setRootAriaHidden(true); - document.body.classList.add(BACKDROP_NO_SCROLL); - } + const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean }; + const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false; + // Only lock out root content when backdrop is active. Developers relying on showBackdrop=false + // expect background interaction to remain enabled. + const shouldLockRoot = shouldTrapFocus && overlayEl.showBackdrop !== false; overlay.presented = true; overlay.willPresent.emit(); + + if (shouldLockRoot) { + const root = getAppRoot(document); + const viewContainer = root.querySelector('ion-router-outlet, #ion-view-container-root'); + const overlayInsideViewContainer = viewContainer ? viewContainer.contains(overlayEl) : false; + + if (!overlayInsideViewContainer) { + setRootAriaHidden(true); + } + document.body.classList.add(BACKDROP_NO_SCROLL); + } overlay.willPresentShorthand?.emit(); const mode = getIonMode(overlay); @@ -653,22 +673,28 @@ export const dismiss = async ( * For accessibility, toasts lack focus traps and don't receive * `aria-hidden` on the root element when presented. * - * All other overlays use focus traps to keep keyboard focus - * within the overlay, setting `aria-hidden` on the root element - * to enhance accessibility. - * - * Therefore, we must remove `aria-hidden` from the root element - * when the last non-toast overlay is dismissed. + * Overlays that opt into focus trapping set `aria-hidden` + * on the root element to keep keyboard focus and pointer + * events inside the overlay. We must remove `aria-hidden` + * from the root element when the last focus-trapping overlay + * is dismissed. */ - const overlaysNotToast = presentedOverlays.filter((o) => o.tagName !== 'ION-TOAST'); - - const lastOverlayNotToast = overlaysNotToast.length === 1 && overlaysNotToast[0].id === overlay.el.id; + const overlaysLockingRoot = presentedOverlays.filter((o) => { + const el = o as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean }; + return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && el.showBackdrop !== false; + }); + const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean }; + const locksRoot = + overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && overlayEl.showBackdrop !== false; /** - * If this is the last visible overlay that is not a toast + * If this is the last visible overlay that is trapping focus * then we want to re-add the root to the accessibility tree. */ - if (lastOverlayNotToast) { + const lastOverlayTrappingFocus = + locksRoot && overlaysLockingRoot.length === 1 && overlaysLockingRoot[0].id === overlayEl.id; + + if (lastOverlayTrappingFocus) { setRootAriaHidden(false); document.body.classList.remove(BACKDROP_NO_SCROLL); } 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/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/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/lazy/app-lazy/app.routes.ts b/packages/angular/test/base/src/app/lazy/app-lazy/app.routes.ts index 1a46992f92c..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 @@ -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 = [ ] }, ]; - 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..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 @@ -35,6 +35,16 @@ Modals Test + + + 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 { } 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 { } 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/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..19aebc9081c 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'; @@ -28,7 +30,9 @@ const OverlayHooks: React.FC = () => { - + + + @@ -46,10 +50,18 @@ const OverlayHooks: React.FC = () => { Loading - + Modal + + + Modal Focus + + + + Modal Teleport + Picker 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/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'); + }); +}); 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'); });