From 8573bf8083f75eda13c954a56731a6aac8ca5724 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Thu, 18 Dec 2025 10:16:56 -0800 Subject: [PATCH 1/6] fix(core): use Capacitor safe-area CSS variables on older WebViews (#30865) Issue number: internal --------- ## What is the current behavior? The safe area variables are only reliant on `env` variables that are provided by devices. ## What is the new behavior? Capacitor 8 has released [safe area variable fallbacks](https://capacitorjs.com/docs/apis/system-bars#android-note) to provide consistent behaviors with older Android devices: > Due to a [bug](https://issues.chromium.org/issues/40699457) in some older versions of Android WebView (< 140), correct safe area values are not available via the safe-area-inset-x CSS env variables. This plugin will inject the correct inset values into a new CSS variable(s) named --safe-area-inset-x that you can use as a fallback in your frontend styles. - Updated safe area variables to use the fallbacks provided by Capacitor. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information Dev build: `8.7.13-dev.11765920447.1a01ab8b` --------- Co-authored-by: Brandy Smith --- .../components/app/test/safe-area/app.e2e.ts | 66 ++++++++++++++++--- core/src/css/core.scss | 10 +-- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/core/src/components/app/test/safe-area/app.e2e.ts b/core/src/components/app/test/safe-area/app.e2e.ts index f2f5c8872e2..336cdffcc45 100644 --- a/core/src/components/app/test/safe-area/app.e2e.ts +++ b/core/src/components/app/test/safe-area/app.e2e.ts @@ -18,20 +18,66 @@ configs({ directions: ['ltr'] }).forEach(({ config, title, screenshot }) => { await expect(page).toHaveScreenshot(screenshot(`app-${screenshotModifier}-diff`)); }; + test.beforeEach(async ({ page }) => { await page.goto(`/src/components/app/test/safe-area`, config); }); - test('should not have visual regressions with action sheet', async ({ page }) => { - await testOverlay(page, '#show-action-sheet', 'ionActionSheetDidPresent', 'action-sheet'); - }); - test('should not have visual regressions with menu', async ({ page }) => { - await testOverlay(page, '#show-menu', 'ionDidOpen', 'menu'); - }); - test('should not have visual regressions with picker', async ({ page }) => { - await testOverlay(page, '#show-picker', 'ionPickerDidPresent', 'picker'); + + test.describe(title('Ionic safe area variables'), () => { + test.beforeEach(async ({ page }) => { + const htmlTag = page.locator('html'); + const hasSafeAreaClass = await htmlTag.evaluate((el) => el.classList.contains('safe-area')); + + expect(hasSafeAreaClass).toBe(true); + }); + + test('should not have visual regressions with action sheet', async ({ page }) => { + await testOverlay(page, '#show-action-sheet', 'ionActionSheetDidPresent', 'action-sheet'); + }); + test('should not have visual regressions with menu', async ({ page }) => { + await testOverlay(page, '#show-menu', 'ionDidOpen', 'menu'); + }); + test('should not have visual regressions with picker', async ({ page }) => { + await testOverlay(page, '#show-picker', 'ionPickerDidPresent', 'picker'); + }); + test('should not have visual regressions with toast', async ({ page }) => { + await testOverlay(page, '#show-toast', 'ionToastDidPresent', 'toast'); + }); }); - test('should not have visual regressions with toast', async ({ page }) => { - await testOverlay(page, '#show-toast', 'ionToastDidPresent', 'toast'); + + test.describe(title('Capacitor safe area variables'), () => { + test('should use safe-area-inset vars when safe-area class is not defined', async ({ page }) => { + await page.evaluate(() => { + const html = document.documentElement; + + // Remove the safe area class + html.classList.remove('safe-area'); + + // Set the safe area inset variables + html.style.setProperty('--safe-area-inset-top', '10px'); + html.style.setProperty('--safe-area-inset-bottom', '20px'); + html.style.setProperty('--safe-area-inset-left', '30px'); + html.style.setProperty('--safe-area-inset-right', '40px'); + }); + + const top = await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--ion-safe-area-top').trim() + ); + const bottom = await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--ion-safe-area-bottom').trim() + ); + const left = await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--ion-safe-area-left').trim() + ); + const right = await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--ion-safe-area-right').trim() + ); + + expect(top).toBe('10px'); + expect(bottom).toBe('20px'); + expect(left).toBe('30px'); + expect(right).toBe('40px'); + }); }); }); }); diff --git a/core/src/css/core.scss b/core/src/css/core.scss index db694fc6a07..c7f7357ab46 100644 --- a/core/src/css/core.scss +++ b/core/src/css/core.scss @@ -252,10 +252,12 @@ html.plt-ios.plt-hybrid, html.plt-ios.plt-pwa { @supports (padding-top: env(safe-area-inset-top)) { html { - --ion-safe-area-top: env(safe-area-inset-top); - --ion-safe-area-bottom: env(safe-area-inset-bottom); - --ion-safe-area-left: env(safe-area-inset-left); - --ion-safe-area-right: env(safe-area-inset-right); + // `--safe-area-inset-*` are set by Capacitor + // @see https://capacitorjs.com/docs/apis/system-bars#android-note + --ion-safe-area-top: var(--safe-area-inset-top, env(safe-area-inset-top)); + --ion-safe-area-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom)); + --ion-safe-area-left: var(--safe-area-inset-left, env(safe-area-inset-left)); + --ion-safe-area-right: var(--safe-area-inset-right, env(safe-area-inset-right)); } } From 3b60a1d68a1df1606ffee0bde7db7a206bac404a Mon Sep 17 00:00:00 2001 From: Brandy Smith Date: Fri, 19 Dec 2025 14:03:04 -0500 Subject: [PATCH 2/6] 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 3/6] 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 | |--------|-------| |