fix(modal): dismiss child modals when parent is dismissed (#30540)

Issue number: resolves #30389

---------

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

Currently, when a child modal is present and a parent modal is somehow
dismissed, the child modal stays open. This can cause issues in some
frameworks like React and Angular, where this cuts the connection to the
child modal and it can no longer be dismissed programmatically.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

This change enables modals to identify their children and close the
children when they're closed. This prevents orphaned modals that can
cause a poor UX. Note: This fix is only for inline modals, which is the
biggest cause of the above issue.

## 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

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

[Relevant test
page](https://ionic-framework-git-fw-6597-ionic1.vercel.app/src/components/modal/test/inline)

**Current dev build**: 
```
8.6.5-dev.11752242329.17d249a3
```
This commit is contained in:
Shane
2025-07-11 12:01:59 -07:00
committed by GitHub
parent 8bfd6d903e
commit 9b0099f462
3 changed files with 153 additions and 10 deletions

View File

@ -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<void> {
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)}
></button>
)}
<slot></slot>
<slot onSlotchange={this.onSlotChange}></slot>
</div>
</Host>
);

View File

@ -24,29 +24,76 @@
<ion-content class="ion-padding">
<button id="open-inline-modal" onclick="openModal(event)">Open Modal</button>
<div id="modal-container">
<ion-modal swipe-to-close="true">
<ion-header>
<ion-toolbar>
<ion-title> Modal </ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding"> This is my inline modal content! </ion-content>
<ion-content class="ion-padding">
<p>This is my inline modal content!</p>
<button id="open-child-modal" onclick="openChildModal(event)">Open Child Modal</button>
<ion-modal id="child-modal" swipe-to-close="true">
<ion-header>
<ion-toolbar>
<ion-title>Child Modal</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p>This is the child modal content!</p>
<p>When the parent modal is dismissed, this child modal should also be dismissed automatically.</p>
<button id="dismiss-parent" onclick="dismissParent(event)">Dismiss Parent Modal</button>
<button id="dismiss-child" onclick="dismissChild(event)">Dismiss Child Modal</button>
</ion-content>
</ion-modal>
</ion-content>
</ion-modal>
</div>
</ion-content>
</div>
</ion-app>
<script>
const modal = document.querySelector('ion-modal');
const childModal = document.querySelector('#child-modal');
modal.presentingElement = document.querySelector('.ion-page');
childModal.presentingElement = modal;
const openModal = () => {
modal.isOpen = true;
};
const openChildModal = () => {
childModal.isOpen = true;
};
const dismissParent = () => {
modal.isOpen = false;
};
const dismissChild = () => {
childModal.isOpen = false;
};
modal.addEventListener('didDismiss', () => {
modal.isOpen = false;
});
childModal.addEventListener('didDismiss', () => {
childModal.isOpen = false;
});
// Add event listeners to demonstrate the new functionality
modal.addEventListener('ionModalDidDismiss', (event) => {
console.log('Parent modal dismissed with role:', event.detail.role);
});
childModal.addEventListener('ionModalDidDismiss', (event) => {
console.log('Child modal dismissed with role:', event.detail.role);
});
</script>
</body>
</html>

View File

@ -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',