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
37 changes: 36 additions & 1 deletion core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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>
);
Expand Down
63 changes: 55 additions & 8 deletions core/src/components/modal/test/inline/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,29 +24,76 @@
<ion-content class="ion-padding">
<button id="open-inline-modal" onclick="openModal(event)">Open Modal</button>

<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-modal>
<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">
<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>
63 changes: 62 additions & 1 deletion core/src/components/modal/test/inline/modal.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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',
Expand Down
Loading