Skip to content

Commit 7d249a3

Browse files
committed
fix(modal): dismiss child modals when parent is dismissed
1 parent f1defba commit 7d249a3

File tree

3 files changed

+153
-10
lines changed

3 files changed

+153
-10
lines changed

core/src/components/modal/modal.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,13 @@ export class Modal implements ComponentInterface, OverlayInterface {
784784
*/
785785
const unlock = await this.lockController.lock();
786786

787+
/**
788+
* Dismiss all child modals. This is especially important in
789+
* Angular and React because it's possible to lose control of a child
790+
* modal when the parent modal is dismissed.
791+
*/
792+
await this.dismissNestedModals();
793+
787794
/**
788795
* If a canDismiss handler is responsible
789796
* for calling the dismiss method, we should
@@ -1115,6 +1122,34 @@ export class Modal implements ComponentInterface, OverlayInterface {
11151122
}
11161123
}
11171124

1125+
/**
1126+
* When the slot changes, we need to find all the modals in the slot
1127+
* and set the data-parent-ion-modal attribute on them so we can find them
1128+
* and dismiss them when we get dismissed.
1129+
* We need to do it this way because when a modal is opened, it's moved to
1130+
* the end of the body and is no longer an actual child of the modal.
1131+
*/
1132+
private onSlotChange = ({ target }: Event) => {
1133+
const slot = target as HTMLSlotElement;
1134+
slot.assignedElements().forEach((el) => {
1135+
el.querySelectorAll('ion-modal').forEach((childModal) => {
1136+
// We don't need to write to the DOM if the modal is already tagged
1137+
// If this is a deeply nested modal, this effect should cascade so we don't
1138+
// need to worry about another modal claiming the same child.
1139+
if (childModal.getAttribute('data-parent-ion-modal') === null) {
1140+
childModal.setAttribute('data-parent-ion-modal', this.el.id);
1141+
}
1142+
});
1143+
});
1144+
};
1145+
1146+
private async dismissNestedModals(): Promise<void> {
1147+
const nestedModals = document.querySelectorAll(`ion-modal[data-parent-ion-modal="${this.el.id}"]`);
1148+
nestedModals?.forEach(async (modal) => {
1149+
await (modal as HTMLIonModalElement).dismiss(undefined, 'parent-dismissed');
1150+
});
1151+
}
1152+
11181153
render() {
11191154
const {
11201155
handle,
@@ -1192,7 +1227,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
11921227
ref={(el) => (this.dragHandleEl = el)}
11931228
></button>
11941229
)}
1195-
<slot></slot>
1230+
<slot onSlotchange={this.onSlotChange}></slot>
11961231
</div>
11971232
</Host>
11981233
);

core/src/components/modal/test/inline/index.html

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,29 +24,76 @@
2424
<ion-content class="ion-padding">
2525
<button id="open-inline-modal" onclick="openModal(event)">Open Modal</button>
2626

27-
<ion-modal swipe-to-close="true">
28-
<ion-header>
29-
<ion-toolbar>
30-
<ion-title> Modal </ion-title>
31-
</ion-toolbar>
32-
</ion-header>
33-
<ion-content class="ion-padding"> This is my inline modal content! </ion-content>
34-
</ion-modal>
27+
<div id="modal-container">
28+
<ion-modal swipe-to-close="true">
29+
<ion-header>
30+
<ion-toolbar>
31+
<ion-title> Modal </ion-title>
32+
</ion-toolbar>
33+
</ion-header>
34+
<ion-content class="ion-padding">
35+
<p>This is my inline modal content!</p>
36+
<button id="open-child-modal" onclick="openChildModal(event)">Open Child Modal</button>
37+
38+
<ion-modal id="child-modal" swipe-to-close="true">
39+
<ion-header>
40+
<ion-toolbar>
41+
<ion-title>Child Modal</ion-title>
42+
</ion-toolbar>
43+
</ion-header>
44+
<ion-content class="ion-padding">
45+
<p>This is the child modal content!</p>
46+
<p>When the parent modal is dismissed, this child modal should also be dismissed automatically.</p>
47+
<button id="dismiss-parent" onclick="dismissParent(event)">Dismiss Parent Modal</button>
48+
<button id="dismiss-child" onclick="dismissChild(event)">Dismiss Child Modal</button>
49+
</ion-content>
50+
</ion-modal>
51+
</ion-content>
52+
</ion-modal>
53+
</div>
3554
</ion-content>
3655
</div>
3756
</ion-app>
3857

3958
<script>
4059
const modal = document.querySelector('ion-modal');
60+
const childModal = document.querySelector('#child-modal');
61+
4162
modal.presentingElement = document.querySelector('.ion-page');
63+
childModal.presentingElement = modal;
4264

4365
const openModal = () => {
4466
modal.isOpen = true;
4567
};
4668

69+
const openChildModal = () => {
70+
childModal.isOpen = true;
71+
};
72+
73+
const dismissParent = () => {
74+
modal.isOpen = false;
75+
};
76+
77+
const dismissChild = () => {
78+
childModal.isOpen = false;
79+
};
80+
4781
modal.addEventListener('didDismiss', () => {
4882
modal.isOpen = false;
4983
});
84+
85+
childModal.addEventListener('didDismiss', () => {
86+
childModal.isOpen = false;
87+
});
88+
89+
// Add event listeners to demonstrate the new functionality
90+
modal.addEventListener('ionModalDidDismiss', (event) => {
91+
console.log('Parent modal dismissed with role:', event.detail.role);
92+
});
93+
94+
childModal.addEventListener('ionModalDidDismiss', (event) => {
95+
console.log('Child modal dismissed with role:', event.detail.role);
96+
});
5097
</script>
5198
</body>
5299
</html>

core/src/components/modal/test/inline/modal.e2e.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
77
await page.goto('/src/components/modal/test/inline', config);
88
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
99
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
10-
const modal = page.locator('ion-modal');
10+
const modal = page.locator('ion-modal').first();
1111

1212
await page.click('#open-inline-modal');
1313

@@ -22,6 +22,67 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
2222
await expect(modal).toBeHidden();
2323
});
2424

25+
test('it should dismiss child modals when parent modal is dismissed', async ({ page }) => {
26+
await page.goto('/src/components/modal/test/inline', config);
27+
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
28+
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
29+
30+
const parentModal = page.locator('ion-modal').first();
31+
const childModal = page.locator('#child-modal');
32+
33+
// Open the parent modal
34+
await page.click('#open-inline-modal');
35+
await ionModalDidPresent.next();
36+
await expect(parentModal).toBeVisible();
37+
38+
// Open the child modal
39+
await page.click('#open-child-modal');
40+
await ionModalDidPresent.next();
41+
await expect(childModal).toBeVisible();
42+
43+
// Both modals should be visible
44+
await expect(parentModal).toBeVisible();
45+
await expect(childModal).toBeVisible();
46+
47+
// Dismiss the parent modal
48+
await page.click('#dismiss-parent');
49+
50+
// Wait for both modals to be dismissed
51+
await ionModalDidDismiss.next(); // child modal dismissed
52+
await ionModalDidDismiss.next(); // parent modal dismissed
53+
54+
// Both modals should be hidden
55+
await expect(parentModal).toBeHidden();
56+
await expect(childModal).toBeHidden();
57+
});
58+
59+
test('it should only dismiss child modal when child dismiss button is clicked', async ({ page }) => {
60+
await page.goto('/src/components/modal/test/inline', config);
61+
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
62+
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
63+
64+
const parentModal = page.locator('ion-modal').first();
65+
const childModal = page.locator('#child-modal');
66+
67+
// Open the parent modal
68+
await page.click('#open-inline-modal');
69+
await ionModalDidPresent.next();
70+
await expect(parentModal).toBeVisible();
71+
72+
// Open the child modal
73+
await page.click('#open-child-modal');
74+
await ionModalDidPresent.next();
75+
await expect(childModal).toBeVisible();
76+
77+
// Dismiss only the child modal
78+
await page.click('#dismiss-child');
79+
await ionModalDidDismiss.next();
80+
81+
// Parent modal should still be visible, child modal should be hidden
82+
await expect(parentModal).toBeVisible();
83+
await expect(childModal).toBeHidden();
84+
});
85+
2586
test('presenting should create a single root element with the ion-page class', async ({ page }, testInfo) => {
2687
testInfo.annotations.push({
2788
type: 'issue',

0 commit comments

Comments
 (0)