diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 8f528e658e5..e5e906204f8 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -784,6 +784,13 @@ export class Modal implements ComponentInterface, OverlayInterface { */ const unlock = await this.lockController.lock(); + /** + * Dismiss all child modals. This is especially important in + * Angular and React because it's possible to lose control of a child + * modal when the parent modal is dismissed. + */ + await this.dismissNestedModals(); + /** * If a canDismiss handler is responsible * for calling the dismiss method, we should @@ -1115,6 +1122,34 @@ export class Modal implements ComponentInterface, OverlayInterface { } } + /** + * When the slot changes, we need to find all the modals in the slot + * and set the data-parent-ion-modal attribute on them so we can find them + * and dismiss them when we get dismissed. + * We need to do it this way because when a modal is opened, it's moved to + * the end of the body and is no longer an actual child of the modal. + */ + private onSlotChange = ({ target }: Event) => { + const slot = target as HTMLSlotElement; + slot.assignedElements().forEach((el) => { + el.querySelectorAll('ion-modal').forEach((childModal) => { + // We don't need to write to the DOM if the modal is already tagged + // If this is a deeply nested modal, this effect should cascade so we don't + // need to worry about another modal claiming the same child. + if (childModal.getAttribute('data-parent-ion-modal') === null) { + childModal.setAttribute('data-parent-ion-modal', this.el.id); + } + }); + }); + }; + + private async dismissNestedModals(): Promise { + const nestedModals = document.querySelectorAll(`ion-modal[data-parent-ion-modal="${this.el.id}"]`); + nestedModals?.forEach(async (modal) => { + await (modal as HTMLIonModalElement).dismiss(undefined, 'parent-dismissed'); + }); + } + render() { const { handle, @@ -1192,7 +1227,7 @@ export class Modal implements ComponentInterface, OverlayInterface { ref={(el) => (this.dragHandleEl = el)} > )} - + ); diff --git a/core/src/components/modal/test/inline/index.html b/core/src/components/modal/test/inline/index.html index 726b682bd86..2e29f756b93 100644 --- a/core/src/components/modal/test/inline/index.html +++ b/core/src/components/modal/test/inline/index.html @@ -24,29 +24,76 @@ - - - - Modal - - - This is my inline modal content! - + diff --git a/core/src/components/modal/test/inline/modal.e2e.ts b/core/src/components/modal/test/inline/modal.e2e.ts index 05276722d95..35690fc2d8f 100644 --- a/core/src/components/modal/test/inline/modal.e2e.ts +++ b/core/src/components/modal/test/inline/modal.e2e.ts @@ -7,7 +7,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await page.goto('/src/components/modal/test/inline', config); const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); - const modal = page.locator('ion-modal'); + const modal = page.locator('ion-modal').first(); await page.click('#open-inline-modal'); @@ -22,6 +22,67 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await expect(modal).toBeHidden(); }); + test('it should dismiss child modals when parent modal is dismissed', async ({ page }) => { + await page.goto('/src/components/modal/test/inline', config); + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + + const parentModal = page.locator('ion-modal').first(); + const childModal = page.locator('#child-modal'); + + // Open the parent modal + await page.click('#open-inline-modal'); + await ionModalDidPresent.next(); + await expect(parentModal).toBeVisible(); + + // Open the child modal + await page.click('#open-child-modal'); + await ionModalDidPresent.next(); + await expect(childModal).toBeVisible(); + + // Both modals should be visible + await expect(parentModal).toBeVisible(); + await expect(childModal).toBeVisible(); + + // Dismiss the parent modal + await page.click('#dismiss-parent'); + + // Wait for both modals to be dismissed + await ionModalDidDismiss.next(); // child modal dismissed + await ionModalDidDismiss.next(); // parent modal dismissed + + // Both modals should be hidden + await expect(parentModal).toBeHidden(); + await expect(childModal).toBeHidden(); + }); + + test('it should only dismiss child modal when child dismiss button is clicked', async ({ page }) => { + await page.goto('/src/components/modal/test/inline', config); + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + + const parentModal = page.locator('ion-modal').first(); + const childModal = page.locator('#child-modal'); + + // Open the parent modal + await page.click('#open-inline-modal'); + await ionModalDidPresent.next(); + await expect(parentModal).toBeVisible(); + + // Open the child modal + await page.click('#open-child-modal'); + await ionModalDidPresent.next(); + await expect(childModal).toBeVisible(); + + // Dismiss only the child modal + await page.click('#dismiss-child'); + await ionModalDidDismiss.next(); + + // Parent modal should still be visible, child modal should be hidden + await expect(parentModal).toBeVisible(); + await expect(childModal).toBeHidden(); + }); + test('presenting should create a single root element with the ion-page class', async ({ page }, testInfo) => { testInfo.annotations.push({ type: 'issue',