mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-03 19:43:27 +08:00
fix(modal): allow sheet modals to skip focus trap (#30689)
Issue number: resolves #30684 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> Recently, we [fixed some issues with aria-hidden in modals](https://github.com/ionic-team/ionic-framework/pull/30563), unfortunately at this time we neglected modals that opt out of focus trapping. As a result, a lot of modals that disable focus trapping still have it happening and it doesn't get cleaned up properly on dismiss. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> We're now properly checking for and skipping focus traps on modals that do not want them. ## Does this introduce a breaking change? - [ ] Yes - [X] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information I created regression tests for Angular in this to prevent this from happening again. I initially tried to do this with core, but the issue doesn't seem to reproduce with core. <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Current dev build: ``` 8.7.5-dev.11758652700.103435a3 ```
This commit is contained in:
@ -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 {
|
||||
|
||||
@ -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 <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);
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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 = [
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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>();
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from './modal-dynamic-wrapper.component';
|
||||
export * from './modal-dynamic-wrapper.module';
|
||||
@ -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 { }
|
||||
@ -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>
|
||||
@ -0,0 +1,104 @@
|
||||
import { Component, ComponentRef, OnDestroy, ViewChild, ViewContainerRef } from "@angular/core";
|
||||
import { ModalController } from "@ionic/angular";
|
||||
import { DynamicComponentWrapperComponent } from "./dynamic-component-wrapper.component";
|
||||
import { DynamicModalContentComponent } from "./dynamic-modal-content.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-modal-dynamic-wrapper',
|
||||
templateUrl: './modal-dynamic-wrapper.component.html',
|
||||
standalone: false
|
||||
})
|
||||
export class ModalDynamicWrapperComponent implements OnDestroy {
|
||||
|
||||
@ViewChild('modalHost', { read: ViewContainerRef, static: true }) modalHost!: ViewContainerRef;
|
||||
|
||||
backgroundActionCount = 0;
|
||||
|
||||
private currentModal?: HTMLIonModalElement;
|
||||
private currentComponentRef?: ComponentRef<DynamicModalContentComponent>;
|
||||
|
||||
constructor(private modalCtrl: ModalController) {}
|
||||
|
||||
async openModal() {
|
||||
await this.closeModal();
|
||||
|
||||
const componentRef = this.modalHost.createComponent(DynamicModalContentComponent);
|
||||
this.modalHost.detach();
|
||||
componentRef.instance.dismiss.subscribe(() => this.closeModal());
|
||||
|
||||
this.currentComponentRef = componentRef;
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: DynamicComponentWrapperComponent,
|
||||
componentProps: {
|
||||
componentRef
|
||||
},
|
||||
breakpoints: [0, 0.2, 0.75, 1],
|
||||
initialBreakpoint: 0.2,
|
||||
backdropDismiss: false,
|
||||
focusTrap: false,
|
||||
handleBehavior: 'cycle'
|
||||
});
|
||||
|
||||
this.currentModal = modal;
|
||||
|
||||
modal.onWillDismiss().then(() => this.destroyComponent());
|
||||
|
||||
await modal.present();
|
||||
}
|
||||
|
||||
async openFocusedModal() {
|
||||
await this.closeModal();
|
||||
|
||||
const componentRef = this.modalHost.createComponent(DynamicModalContentComponent);
|
||||
this.modalHost.detach();
|
||||
componentRef.instance.dismiss.subscribe(() => this.closeModal());
|
||||
|
||||
this.currentComponentRef = componentRef;
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: DynamicComponentWrapperComponent,
|
||||
componentProps: {
|
||||
componentRef,
|
||||
},
|
||||
// Choose a higher initial breakpoint to ensure backdrop is active immediately
|
||||
breakpoints: [0, 0.25, 0.5, 0.75, 1],
|
||||
initialBreakpoint: 0.5,
|
||||
// Keep backdrop active but do not dismiss on tap to avoid interfering with assertions
|
||||
backdropDismiss: false,
|
||||
// Explicitly enable focus trapping to block background interaction
|
||||
focusTrap: true,
|
||||
handleBehavior: 'cycle',
|
||||
});
|
||||
|
||||
this.currentModal = modal;
|
||||
|
||||
modal.onWillDismiss().then(() => this.destroyComponent());
|
||||
|
||||
await modal.present();
|
||||
}
|
||||
|
||||
async closeModal() {
|
||||
if (this.currentModal) {
|
||||
await this.currentModal.dismiss();
|
||||
this.currentModal = undefined;
|
||||
}
|
||||
|
||||
this.destroyComponent();
|
||||
}
|
||||
|
||||
private destroyComponent() {
|
||||
if (this.currentComponentRef) {
|
||||
this.currentComponentRef.destroy();
|
||||
this.currentComponentRef = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
onBackgroundActionClick() {
|
||||
this.backgroundActionCount++;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyComponent();
|
||||
}
|
||||
}
|
||||
@ -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 { }
|
||||
@ -0,0 +1,2 @@
|
||||
export * from './modal-sheet-inline.component';
|
||||
export * from './modal-sheet-inline.module';
|
||||
@ -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 { }
|
||||
@ -0,0 +1,46 @@
|
||||
<ion-button id="present-inline-sheet-modal" (click)="presentInlineSheetModal()">
|
||||
Present Inline Sheet Modal
|
||||
</ion-button>
|
||||
|
||||
<p>
|
||||
Current breakpoint: <span id="current-breakpoint">{{ currentBreakpoint }}</span>
|
||||
</p>
|
||||
|
||||
<ion-button id="background-action" (click)="onBackgroundActionClick()">
|
||||
Background Action
|
||||
</ion-button>
|
||||
|
||||
<p>
|
||||
Background action count: <span id="background-action-count">{{ backgroundActionCount }}</span>
|
||||
</p>
|
||||
|
||||
<ion-modal
|
||||
#inlineSheetModal
|
||||
mode="md"
|
||||
[isOpen]="isSheetOpen"
|
||||
[initialBreakpoint]="0.2"
|
||||
[breakpoints]="breakpoints"
|
||||
[backdropDismiss]="false"
|
||||
[backdropBreakpoint]="0.5"
|
||||
[focusTrap]="false"
|
||||
handleBehavior="cycle"
|
||||
(ionBreakpointDidChange)="onSheetBreakpointDidChange($event)"
|
||||
(didDismiss)="onSheetDidDismiss()"
|
||||
>
|
||||
<ng-template>
|
||||
<ion-content>
|
||||
<ion-searchbar placeholder="Search" (click)="expandInlineSheet()"></ion-searchbar>
|
||||
<ion-list>
|
||||
<ion-item *ngFor="let contact of contacts">
|
||||
<ion-avatar slot="start">
|
||||
<ion-img [src]="contact.avatar"></ion-img>
|
||||
</ion-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ contact.name }}</h2>
|
||||
<p>{{ contact.title }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
</ng-template>
|
||||
</ion-modal>
|
||||
@ -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++;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 { }
|
||||
@ -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) },
|
||||
|
||||
@ -90,6 +90,16 @@
|
||||
Modal Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/standalone/modal-sheet-inline">
|
||||
<ion-label>
|
||||
Modal Sheet Inline Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/standalone/modal-dynamic-wrapper">
|
||||
<ion-label>
|
||||
Modal Dynamic Wrapper Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/standalone/programmatic-modal">
|
||||
<ion-label>
|
||||
Programmatic Modal Test
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
import { Component, ComponentRef, Input, OnDestroy, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
|
||||
import { IonContent } from '@ionic/angular/standalone';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dynamic-component-wrapper',
|
||||
template: `
|
||||
<ion-content>
|
||||
<ng-container #container></ng-container>
|
||||
</ion-content>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [IonContent],
|
||||
})
|
||||
export class DynamicComponentWrapperComponent implements OnInit, OnDestroy {
|
||||
@Input() componentRef?: ComponentRef<unknown>;
|
||||
@ViewChild('container', { read: ViewContainerRef, static: true }) container!: ViewContainerRef;
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.componentRef) {
|
||||
this.container.insert(this.componentRef.hostView);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.componentRef?.destroy();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core';
|
||||
import {
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from '@ionic/angular/standalone';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dynamic-modal-content',
|
||||
template: `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Dynamic Sheet Content</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<p id="dynamic-component-loaded">Dynamic component rendered inside wrapper.</p>
|
||||
<ion-button id="dismiss-dynamic-modal" (click)="dismiss.emit()">Close</ion-button>
|
||||
</ion-content>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [IonButton, IonContent, IonHeader, IonTitle, IonToolbar],
|
||||
})
|
||||
export class DynamicModalContentComponent {
|
||||
@Output() dismiss = new EventEmitter<void>();
|
||||
}
|
||||
@ -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>
|
||||
@ -0,0 +1,103 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ComponentRef, OnDestroy, ViewChild, ViewContainerRef } from '@angular/core';
|
||||
import { IonButton, ModalController } from '@ionic/angular/standalone';
|
||||
|
||||
import { DynamicComponentWrapperComponent } from './dynamic-component-wrapper.component';
|
||||
import { DynamicModalContentComponent } from './dynamic-modal-content.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-modal-dynamic-wrapper',
|
||||
templateUrl: './modal-dynamic-wrapper.component.html',
|
||||
standalone: true,
|
||||
imports: [CommonModule, IonButton],
|
||||
})
|
||||
export class ModalDynamicWrapperComponent implements OnDestroy {
|
||||
@ViewChild('modalHost', { read: ViewContainerRef, static: true }) modalHost!: ViewContainerRef;
|
||||
|
||||
backgroundActionCount = 0;
|
||||
|
||||
private currentModal?: HTMLIonModalElement;
|
||||
private currentComponentRef?: ComponentRef<DynamicModalContentComponent>;
|
||||
|
||||
constructor(private modalCtrl: ModalController) {}
|
||||
|
||||
async openModal() {
|
||||
await this.closeModal();
|
||||
|
||||
const componentRef = this.modalHost.createComponent(DynamicModalContentComponent);
|
||||
this.modalHost.detach();
|
||||
componentRef.instance.dismiss.subscribe(() => this.closeModal());
|
||||
|
||||
this.currentComponentRef = componentRef;
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: DynamicComponentWrapperComponent,
|
||||
componentProps: {
|
||||
componentRef,
|
||||
},
|
||||
breakpoints: [0, 0.2, 0.75, 1],
|
||||
initialBreakpoint: 0.2,
|
||||
backdropDismiss: false,
|
||||
focusTrap: false,
|
||||
handleBehavior: 'cycle',
|
||||
});
|
||||
|
||||
this.currentModal = modal;
|
||||
|
||||
modal.onWillDismiss().then(() => this.destroyComponent());
|
||||
|
||||
await modal.present();
|
||||
}
|
||||
|
||||
async openFocusedModal() {
|
||||
await this.closeModal();
|
||||
|
||||
const componentRef = this.modalHost.createComponent(DynamicModalContentComponent);
|
||||
this.modalHost.detach();
|
||||
componentRef.instance.dismiss.subscribe(() => this.closeModal());
|
||||
|
||||
this.currentComponentRef = componentRef;
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: DynamicComponentWrapperComponent,
|
||||
componentProps: {
|
||||
componentRef,
|
||||
},
|
||||
breakpoints: [0, 0.25, 0.5, 0.75, 1],
|
||||
initialBreakpoint: 0.5,
|
||||
backdropDismiss: false,
|
||||
focusTrap: true,
|
||||
handleBehavior: 'cycle',
|
||||
});
|
||||
|
||||
this.currentModal = modal;
|
||||
|
||||
modal.onWillDismiss().then(() => this.destroyComponent());
|
||||
|
||||
await modal.present();
|
||||
}
|
||||
|
||||
async closeModal() {
|
||||
if (this.currentModal) {
|
||||
await this.currentModal.dismiss();
|
||||
this.currentModal = undefined;
|
||||
}
|
||||
|
||||
this.destroyComponent();
|
||||
}
|
||||
|
||||
private destroyComponent() {
|
||||
if (this.currentComponentRef) {
|
||||
this.currentComponentRef.destroy();
|
||||
this.currentComponentRef = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
onBackgroundActionClick() {
|
||||
this.backgroundActionCount++;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyComponent();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
<ion-button id="present-inline-sheet-modal" (click)="presentInlineSheetModal()">
|
||||
Present Inline Sheet Modal
|
||||
</ion-button>
|
||||
|
||||
<p>
|
||||
Current breakpoint: <span id="current-breakpoint">{{ currentBreakpoint }}</span>
|
||||
</p>
|
||||
|
||||
<ion-button id="background-action" (click)="onBackgroundActionClick()">
|
||||
Background Action
|
||||
</ion-button>
|
||||
|
||||
<p>
|
||||
Background action count: <span id="background-action-count">{{ backgroundActionCount }}</span>
|
||||
</p>
|
||||
|
||||
<ion-modal
|
||||
#inlineSheetModal
|
||||
mode="md"
|
||||
[isOpen]="isSheetOpen"
|
||||
[initialBreakpoint]="0.2"
|
||||
[breakpoints]="breakpoints"
|
||||
[backdropDismiss]="false"
|
||||
[backdropBreakpoint]="0.5"
|
||||
[focusTrap]="false"
|
||||
handleBehavior="cycle"
|
||||
(ionBreakpointDidChange)="onSheetBreakpointDidChange($event)"
|
||||
(didDismiss)="onSheetDidDismiss()"
|
||||
>
|
||||
<ng-template>
|
||||
<ion-content>
|
||||
<ion-searchbar placeholder="Search" (click)="expandInlineSheet()"></ion-searchbar>
|
||||
<ion-list>
|
||||
<ion-item *ngFor="let contact of contacts">
|
||||
<ion-avatar slot="start">
|
||||
<ion-img [src]="contact.avatar"></ion-img>
|
||||
</ion-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ contact.name }}</h2>
|
||||
<p>{{ contact.title }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
</ng-template>
|
||||
</ion-modal>
|
||||
@ -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++;
|
||||
}
|
||||
}
|
||||
@ -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 (
|
||||
<IonPage>
|
||||
<IonContent className="ion-padding">
|
||||
<IonButton id="open-non-trapped-modal" onClick={() => setShowNonTrapped(true)}>
|
||||
Open Non-Trapped Sheet Modal
|
||||
</IonButton>
|
||||
<IonButton id="open-trapped-modal" color="primary" onClick={() => setShowTrapped(true)}>
|
||||
Open Focus-Trapped Sheet Modal
|
||||
</IonButton>
|
||||
|
||||
<IonButton id="background-action" onClick={() => setCount((c) => c + 1)}>
|
||||
Background Action
|
||||
</IonButton>
|
||||
<div>
|
||||
Background action count: <span id="background-action-count">{count}</span>
|
||||
</div>
|
||||
|
||||
<IonModal
|
||||
isOpen={showNonTrapped}
|
||||
onDidDismiss={() => setShowNonTrapped(false)}
|
||||
breakpoints={[0, 0.25, 0.5, 0.75, 1]}
|
||||
initialBreakpoint={0.25}
|
||||
backdropDismiss={false}
|
||||
focusTrap={false}
|
||||
handleBehavior="cycle"
|
||||
>
|
||||
<IonContent className="ion-padding">
|
||||
<p>Non-trapped modal content</p>
|
||||
<IonButton onClick={() => setShowNonTrapped(false)}>Close</IonButton>
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
|
||||
<IonModal
|
||||
isOpen={showTrapped}
|
||||
onDidDismiss={() => setShowTrapped(false)}
|
||||
breakpoints={[0, 0.25, 0.5, 0.75, 1]}
|
||||
initialBreakpoint={0.5}
|
||||
backdropDismiss={false}
|
||||
focusTrap={true}
|
||||
handleBehavior="cycle"
|
||||
>
|
||||
<IonContent className="ion-padding">
|
||||
<p>Focus-trapped modal content</p>
|
||||
<IonButton onClick={() => setShowTrapped(false)}>Close</IonButton>
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalFocusTrap;
|
||||
|
||||
@ -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 (
|
||||
<IonPage>
|
||||
<IonContent className="ion-padding">
|
||||
<div id="example" style={{ minHeight: '40vh' }}></div>
|
||||
|
||||
<IonButton id="teleport-background-action" onClick={() => setCount((c) => c + 1)}>
|
||||
Background Action
|
||||
</IonButton>
|
||||
<div>
|
||||
Background action count: <span id="teleport-background-action-count">{count}</span>
|
||||
</div>
|
||||
|
||||
<IonButton id="open-teleport-modal" onClick={() => setIsOpen(true)}>
|
||||
Open Teleported Modal
|
||||
</IonButton>
|
||||
|
||||
{isOpen && (
|
||||
<IonModal
|
||||
isOpen={true}
|
||||
onDidDismiss={() => 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}
|
||||
>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonTitle>Modal</IonTitle>
|
||||
<IonButtons slot="end">
|
||||
<IonButton id="close-teleport-modal" onClick={() => setIsOpen(false)}>
|
||||
Close
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent className="ion-padding">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
)}
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalTeleport;
|
||||
@ -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<OverlayHooksProps> = () => {
|
||||
<Route path="/overlay-components/actionsheet" component={ActionSheetComponent} />
|
||||
<Route path="/overlay-components/alert" component={AlertComponent} />
|
||||
<Route path="/overlay-components/loading" component={LoadingComponent} />
|
||||
<Route path="/overlay-components/modal" component={ModalComponent} />
|
||||
<Route path="/overlay-components/modal-basic" component={ModalComponent} />
|
||||
<Route path="/overlay-components/modal-focus-trap" component={ModalFocusTrap} />
|
||||
<Route path="/overlay-components/modal-teleport" component={ModalTeleport} />
|
||||
<Route path="/overlay-components/picker" component={PickerComponent} />
|
||||
<Route path="/overlay-components/popover" component={PopoverComponent} />
|
||||
<Route path="/overlay-components/toast" component={ToastComponent} />
|
||||
@ -46,10 +50,18 @@ const OverlayHooks: React.FC<OverlayHooksProps> = () => {
|
||||
<IonIcon icon={addCircleOutline} />
|
||||
<IonLabel>Loading</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="modal" href="/overlay-components/modal">
|
||||
<IonTabButton tab="modal" href="/overlay-components/modal-basic">
|
||||
<IonIcon icon={star} />
|
||||
<IonLabel>Modal</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="modalFocus" href="/overlay-components/modal-focus-trap">
|
||||
<IonIcon icon={star} />
|
||||
<IonLabel>Modal Focus</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="modalTeleport" href="/overlay-components/modal-teleport">
|
||||
<IonIcon icon={star} />
|
||||
<IonLabel>Modal Teleport</IonLabel>
|
||||
</IonTabButton>
|
||||
<IonTabButton tab="picker" href="/overlay-components/picker">
|
||||
<IonIcon icon={logoIonic} />
|
||||
<IonLabel>Picker</IonLabel>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
describe('IonModal', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/overlay-components/modal');
|
||||
cy.visit('/overlay-components/modal-basic');
|
||||
});
|
||||
|
||||
it('display modal', () => {
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user