From 3b60a1d68a1df1606ffee0bde7db7a206bac404a Mon Sep 17 00:00:00 2001 From: Brandy Smith Date: Fri, 19 Dec 2025 14:03:04 -0500 Subject: [PATCH 1/5] fix(modal): dismiss top-most overlay when multiple IDs match (#30883) Issue number: resolves #30030 --------- ## What is the current behavior? When modals are presented one after another with matching IDs and then dismissed by ID it will dismiss the first presented modal. ## What is the new behavior? - When modals are presented one after another with matching IDs and then dismissed by ID it will dismiss the last (top-most) presented modal. - Added e2e tests to verify this behavior works the same as the default dismiss (not passing an ID). ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information [Modal: Dismiss Behavior](https://ionic-framework-git-fw-7016-ionic1.vercel.app/src/components/modal/test/dismiss-behavior) --------- Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> --- .../modal/test/dismiss-behavior/index.html | 97 +++++++++++++++++++ .../modal/test/dismiss-behavior/modal.e2e.ts | 58 +++++++++++ .../components/modal/test/modal-id.spec.tsx | 19 +++- core/src/utils/overlays.ts | 4 +- 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 core/src/components/modal/test/dismiss-behavior/index.html create mode 100644 core/src/components/modal/test/dismiss-behavior/modal.e2e.ts diff --git a/core/src/components/modal/test/dismiss-behavior/index.html b/core/src/components/modal/test/dismiss-behavior/index.html new file mode 100644 index 00000000000..448b84457d9 --- /dev/null +++ b/core/src/components/modal/test/dismiss-behavior/index.html @@ -0,0 +1,97 @@ + + + + + Modal - Dismiss Behavior + + + + + + + + + + +
+ + + Modal - Dismiss Behavior + + + + + + +
+
+ + + + diff --git a/core/src/components/modal/test/dismiss-behavior/modal.e2e.ts b/core/src/components/modal/test/dismiss-behavior/modal.e2e.ts new file mode 100644 index 00000000000..7969317c447 --- /dev/null +++ b/core/src/components/modal/test/dismiss-behavior/modal.e2e.ts @@ -0,0 +1,58 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('modal: dismiss behavior'), () => { + test.describe(title('modal: default dismiss'), () => { + test('should dismiss the last presented modal when the default dismiss button is clicked', async ({ page }) => { + await page.goto('/src/components/modal/test/dismiss-behavior', config); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + + await page.click('#present-first-modal'); + await ionModalDidPresent.next(); + const firstModal = page.locator('ion-modal[data-testid="modal-1"]'); + await expect(firstModal).toBeVisible(); + + await page.click('#present-next-modal'); + await ionModalDidPresent.next(); + const secondModal = page.locator('ion-modal[data-testid="modal-2"]'); + await expect(secondModal).toBeVisible(); + + await page.click('ion-modal[data-testid="modal-2"] ion-button.dismiss-default'); + await ionModalDidDismiss.next(); + await secondModal.waitFor({ state: 'detached' }); + + await expect(firstModal).toBeVisible(); + await expect(secondModal).toBeHidden(); + }); + }); + + test.describe(title('modal: dismiss by id'), () => { + test('should dismiss the last presented modal when the dismiss by id button is clicked', async ({ page }) => { + await page.goto('/src/components/modal/test/dismiss-behavior', config); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + + await page.click('#present-first-modal'); + await ionModalDidPresent.next(); + const firstModal = page.locator('ion-modal[data-testid="modal-1"]'); + await expect(firstModal).toBeVisible(); + + await page.click('#present-next-modal'); + await ionModalDidPresent.next(); + const secondModal = page.locator('ion-modal[data-testid="modal-2"]'); + await expect(secondModal).toBeVisible(); + + await page.click('ion-modal[data-testid="modal-2"] ion-button.dismiss-by-id'); + await ionModalDidDismiss.next(); + await secondModal.waitFor({ state: 'detached' }); + + await expect(firstModal).toBeVisible(); + await expect(secondModal).toBeHidden(); + }); + }); + }); +}); diff --git a/core/src/components/modal/test/modal-id.spec.tsx b/core/src/components/modal/test/modal-id.spec.tsx index 43f1a9eaa16..93b6d34fa91 100644 --- a/core/src/components/modal/test/modal-id.spec.tsx +++ b/core/src/components/modal/test/modal-id.spec.tsx @@ -1,7 +1,7 @@ +import { h } from '@stencil/core'; import { newSpecPage } from '@stencil/core/testing'; import { Modal } from '../modal'; -import { h } from '@stencil/core'; describe('modal: id', () => { it('modal should be assigned an incrementing id', async () => { @@ -52,4 +52,21 @@ describe('modal: id', () => { const alert = page.body.querySelector('ion-modal')!; expect(alert.id).toBe(id); }); + + it('should allow multiple modals with the same id', async () => { + const sharedId = 'shared-modal-id'; + + const page = await newSpecPage({ + components: [Modal], + template: () => [ + , + , + ], + }); + + const modals = page.body.querySelectorAll('ion-modal'); + expect(modals.length).toBe(2); + expect(modals[0].id).toBe(sharedId); + expect(modals[1].id).toBe(sharedId); + }); }); diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 472a57559d3..59b341a7d52 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -473,7 +473,9 @@ export const getPresentedOverlay = ( id?: string ): HTMLIonOverlayElement | undefined => { const overlays = getPresentedOverlays(doc, overlayTag); - return id === undefined ? overlays[overlays.length - 1] : overlays.find((o) => o.id === id); + // If no id is provided, return the last presented overlay + // Otherwise, return the last overlay with the given id + return (id === undefined ? overlays : overlays.filter((o: HTMLIonOverlayElement) => o.id === id)).slice(-1)[0]; }; /** From f83b0005309400d674e43c497bdffbcb9d2c4d94 Mon Sep 17 00:00:00 2001 From: Israel de la Barrera Date: Mon, 22 Dec 2025 18:21:16 +0100 Subject: [PATCH 2/5] fix(header): show iOS condense header when app is in MD mode (#30690) Issue number: resolves #29929 --------- ## What is the current behavior? When forcing `mode=ios` in a collapsible header, `.header-collapse-condense` would still be applied from the `header.md.scss` file, leaving the collapsible header always hidden. ## What is the new behavior? When forcing `mode=ios` in a collapsible header, the `.header-collapse-condense` styles from the `header.md.scss` file won't be applied, and the collapsible header will be visible. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information Something worth mentioning is that this behavior only appears after initial load: if the route is loaded refreshing the page, the header will appear and work correctly, but navigating forth and back will apply both the .ios and .md style files. I showcase this with a modal because It'll always display the broken hehavior. | Before | After | |--------|-------| |