Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions core/src/components/modal/gestures/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
62 changes: 44 additions & 18 deletions core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
};

Expand Down Expand Up @@ -529,15 +527,37 @@ export const present = async <OverlayPresentOptions>(
* focus traps.
*
* All other overlays should have focus traps to prevent
* the keyboard focus from leaving the overlay.
* the keyboard focus from leaving the overlay unless
* developers explicitly opt out (for example, sheet
* modals that should permit background interaction).
*
* Note: Some apps move inline overlays to a specific container
* during the willPresent lifecycle (e.g., React portals via
* onWillPresent). Defer applying aria-hidden/inert to the app
* root until after willPresent so we can detect where the
* overlay is finally inserted. If the overlay is inside the
* view container subtree, skip adding aria-hidden/inert there
* to avoid disabling the overlay.
*/
if (overlay.el.tagName !== 'ION-TOAST') {
setRootAriaHidden(true);
document.body.classList.add(BACKDROP_NO_SCROLL);
}
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false;
// Only lock out root content when backdrop is active. Developers relying on showBackdrop=false
// expect background interaction to remain enabled.
const shouldLockRoot = shouldTrapFocus && overlayEl.showBackdrop !== false;

overlay.presented = true;
overlay.willPresent.emit();

if (shouldLockRoot) {
const root = getAppRoot(document);
const viewContainer = root.querySelector('ion-router-outlet, #ion-view-container-root');
const overlayInsideViewContainer = viewContainer ? viewContainer.contains(overlayEl) : false;

if (!overlayInsideViewContainer) {
setRootAriaHidden(true);
}
document.body.classList.add(BACKDROP_NO_SCROLL);
}
overlay.willPresentShorthand?.emit();

const mode = getIonMode(overlay);
Expand Down Expand Up @@ -653,22 +673,28 @@ export const dismiss = async <OverlayDismissOptions>(
* For accessibility, toasts lack focus traps and don't receive
* `aria-hidden` on the root element when presented.
*
* All other overlays use focus traps to keep keyboard focus
* within the overlay, setting `aria-hidden` on the root element
* to enhance accessibility.
*
* Therefore, we must remove `aria-hidden` from the root element
* when the last non-toast overlay is dismissed.
* Overlays that opt into focus trapping set `aria-hidden`
* on the root element to keep keyboard focus and pointer
* events inside the overlay. We must remove `aria-hidden`
* from the root element when the last focus-trapping overlay
* is dismissed.
*/
const overlaysNotToast = presentedOverlays.filter((o) => o.tagName !== 'ION-TOAST');

const lastOverlayNotToast = overlaysNotToast.length === 1 && overlaysNotToast[0].id === overlay.el.id;
const overlaysLockingRoot = presentedOverlays.filter((o) => {
const el = o as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && el.showBackdrop !== false;
});
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
const locksRoot =
overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && overlayEl.showBackdrop !== false;

/**
* If this is the last visible overlay that is not a toast
* If this is the last visible overlay that is trapping focus
* then we want to re-add the root to the accessibility tree.
*/
if (lastOverlayNotToast) {
const lastOverlayTrappingFocus =
locksRoot && overlaysLockingRoot.length === 1 && overlaysLockingRoot[0].id === overlayEl.id;

if (lastOverlayTrappingFocus) {
setRootAriaHidden(false);
document.body.classList.remove(BACKDROP_NO_SCROLL);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down Expand Up @@ -90,4 +92,3 @@ export const routes: Routes = [
]
},
];

Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@
Modals Test
</ion-label>
</ion-item>
<ion-item routerLink="/lazy/modal-sheet-inline">
<ion-label>
Modal Sheet Inline Test
</ion-label>
</ion-item>
<ion-item routerLink="/lazy/modal-dynamic-wrapper">
<ion-label>
Modal Dynamic Wrapper Test
</ion-label>
</ion-item>
<ion-item routerLink="/lazy/router-link">
<ion-label>
Router link Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Component, ComponentRef, Input, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";

@Component({
selector: 'app-dynamic-component-wrapper',
template: `
<ion-content>
<ng-container #container></ng-container>
</ion-content>
`,
standalone: false
})
export class DynamicComponentWrapperComponent implements OnInit, OnDestroy {

@Input() componentRef?: ComponentRef<unknown>;
@ViewChild('container', { read: ViewContainerRef, static: true }) container!: ViewContainerRef;

ngOnInit(): void {
if (this.componentRef) {
this.container.insert(this.componentRef.hostView);
}
}

ngOnDestroy(): void {
this.componentRef?.destroy();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Component, EventEmitter, Output } from "@angular/core";

@Component({
selector: 'app-dynamic-modal-content',
template: `
<ion-header>
<ion-toolbar>
<ion-title>Dynamic Sheet Content</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p id="dynamic-component-loaded">Dynamic component rendered inside wrapper.</p>
<ion-button id="dismiss-dynamic-modal" (click)="dismiss.emit()">Close</ion-button>
</ion-content>
`,
standalone: false
})
export class DynamicModalContentComponent {
@Output() dismiss = new EventEmitter<void>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './modal-dynamic-wrapper.component';
export * from './modal-dynamic-wrapper.module';
Original file line number Diff line number Diff line change
@@ -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 { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<ion-button id="open-dynamic-modal" (click)="openModal()">Open Dynamic Sheet Modal</ion-button>
<ion-button id="open-focused-modal" color="primary" (click)="openFocusedModal()">Open Focus-Trapped Sheet Modal</ion-button>
<ion-button id="background-action" (click)="onBackgroundActionClick()">Background Action</ion-button>
<p>
Background action count: <span id="background-action-count">{{ backgroundActionCount }}</span>
</p>

<ng-template #modalHost></ng-template>
Loading
Loading