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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.

## [8.7.15](https://github.com/ionic-team/ionic-framework/compare/v8.7.14...v8.7.15) (2025-12-23)


### Bug Fixes

* **core:** use Capacitor safe-area CSS variables on older WebViews ([#30865](https://github.com/ionic-team/ionic-framework/issues/30865)) ([8573bf8](https://github.com/ionic-team/ionic-framework/commit/8573bf8083f75eda13c954a56731a6aac8ca5724))
* **header:** show iOS condense header when app is in MD mode ([#30690](https://github.com/ionic-team/ionic-framework/issues/30690)) ([f83b000](https://github.com/ionic-team/ionic-framework/commit/f83b0005309400d674e43c497bdffbcb9d2c4d94)), closes [#29929](https://github.com/ionic-team/ionic-framework/issues/29929)
* **input-password-toggle:** improve screen reader announcements ([#30885](https://github.com/ionic-team/ionic-framework/issues/30885)) ([12ede4b](https://github.com/ionic-team/ionic-framework/commit/12ede4b79c8d5cffc2b014c7c8a0d2ef1d3bf90d))
* **modal:** dismiss top-most overlay when multiple IDs match ([#30883](https://github.com/ionic-team/ionic-framework/issues/30883)) ([3b60a1d](https://github.com/ionic-team/ionic-framework/commit/3b60a1d68a1df1606ffee0bde7db7a206bac404a)), closes [#30030](https://github.com/ionic-team/ionic-framework/issues/30030)





## [8.7.14](https://github.com/ionic-team/ionic-framework/compare/v8.7.13...v8.7.14) (2025-12-17)


Expand Down
14 changes: 14 additions & 0 deletions core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.

## [8.7.15](https://github.com/ionic-team/ionic-framework/compare/v8.7.14...v8.7.15) (2025-12-23)


### Bug Fixes

* **core:** use Capacitor safe-area CSS variables on older WebViews ([#30865](https://github.com/ionic-team/ionic-framework/issues/30865)) ([8573bf8](https://github.com/ionic-team/ionic-framework/commit/8573bf8083f75eda13c954a56731a6aac8ca5724))
* **header:** show iOS condense header when app is in MD mode ([#30690](https://github.com/ionic-team/ionic-framework/issues/30690)) ([f83b000](https://github.com/ionic-team/ionic-framework/commit/f83b0005309400d674e43c497bdffbcb9d2c4d94)), closes [#29929](https://github.com/ionic-team/ionic-framework/issues/29929)
* **input-password-toggle:** improve screen reader announcements ([#30885](https://github.com/ionic-team/ionic-framework/issues/30885)) ([12ede4b](https://github.com/ionic-team/ionic-framework/commit/12ede4b79c8d5cffc2b014c7c8a0d2ef1d3bf90d))
* **modal:** dismiss top-most overlay when multiple IDs match ([#30883](https://github.com/ionic-team/ionic-framework/issues/30883)) ([3b60a1d](https://github.com/ionic-team/ionic-framework/commit/3b60a1d68a1df1606ffee0bde7db7a206bac404a)), closes [#30030](https://github.com/ionic-team/ionic-framework/issues/30030)





## [8.7.14](https://github.com/ionic-team/ionic-framework/compare/v8.7.13...v8.7.14) (2025-12-17)

**Note:** Version bump only for package @ionic/core
Expand Down
4 changes: 2 additions & 2 deletions core/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ionic/core",
"version": "8.7.14",
"version": "8.7.15",
"description": "Base components for Ionic",
"engines": {
"node": ">= 16"
Expand Down
1 change: 1 addition & 0 deletions core/src/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
*/
@Watch('aria-checked')
@Watch('aria-label')
@Watch('aria-pressed')
onAriaChanged(newValue: string, _oldValue: string, propName: string) {
this.inheritedAttributes = {
...this.inheritedAttributes,
Expand Down
2 changes: 1 addition & 1 deletion core/src/components/header/header.md.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
box-shadow: $header-md-box-shadow;
}

.header-collapse-condense {
.header-md.header-collapse-condense {
display: none;
}

Expand Down
71 changes: 71 additions & 0 deletions core/src/components/header/test/condense-modal/header.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';

/**
* This test verifies that collapsible headers with mode="ios" work correctly
* when both iOS and MD stylesheets are loaded. The bug occurred because
* `.header-collapse-condense { display: none }` in the MD stylesheet was not
* scoped to `.header-md`, causing it to hide iOS condense headers when both
* stylesheets were present.
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('header: condense with iOS mode override'), () => {
test('should show iOS condense header when both MD and iOS styles are loaded', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29929',
});

// Include both an MD header and an iOS modal to force both stylesheets to load
await page.setContent(
`
<!-- MD header to force MD stylesheet to load -->
<ion-header mode="md" id="mdHeader">
<ion-toolbar>
<ion-title>MD Header</ion-title>
</ion-toolbar>
</ion-header>

<!-- Modal with iOS condense header -->
<ion-modal>
<ion-header mode="ios" id="smallTitleHeader">
<ion-toolbar>
<ion-title>Header</ion-title>
</ion-toolbar>
</ion-header>
<ion-content fullscreen="true">
<ion-header collapse="condense" mode="ios" id="largeTitleHeader">
<ion-toolbar>
<ion-title size="large">Large Header</ion-title>
</ion-toolbar>
</ion-header>
<p>Content</p>
</ion-content>
</ion-modal>
`,
config
);

const modal = page.locator('ion-modal');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');

await modal.evaluate((el: HTMLIonModalElement) => el.present());
await ionModalDidPresent.next();

const largeTitleHeader = modal.locator('#largeTitleHeader');

// The large title header should be visible, not hidden by MD styles
await expect(largeTitleHeader).toBeVisible();

// Verify it has the iOS mode class
await expect(largeTitleHeader).toHaveClass(/header-ios/);

// Verify it does NOT have display: none applied
// This would fail if the MD stylesheet's unscoped .header-collapse-condense rule applies
const display = await largeTitleHeader.evaluate((el) => {
return window.getComputedStyle(el).display;
});
expect(display).not.toBe('none');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,8 @@ export class InputPasswordToggle implements ComponentInterface {
color={color}
fill="clear"
shape="round"
aria-checked={isPasswordVisible ? 'true' : 'false'}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
role="switch"
aria-pressed={isPasswordVisible ? 'true' : 'false'}
type="button"
onPointerDown={(ev) => {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
});

test.describe(title('input password toggle: aria attributes'), () => {
test('should inherit aria attributes to inner button on load', async ({ page }) => {
test('should have correct aria attributes on load', async ({ page }) => {
await page.setContent(
`
<ion-input label="input" type="password">
Expand All @@ -35,9 +35,9 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
const nativeButton = page.locator('ion-input-password-toggle button');

await expect(nativeButton).toHaveAttribute('aria-label', 'Show password');
await expect(nativeButton).toHaveAttribute('aria-checked', 'false');
await expect(nativeButton).toHaveAttribute('aria-pressed', 'false');
});
test('should inherit aria attributes to inner button after toggle', async ({ page }) => {
test('should update aria attributes after toggle', async ({ page }) => {
await page.setContent(
`
<ion-input label="input" type="password">
Expand All @@ -51,7 +51,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
await nativeButton.click();

await expect(nativeButton).toHaveAttribute('aria-label', 'Hide password');
await expect(nativeButton).toHaveAttribute('aria-checked', 'true');
await expect(nativeButton).toHaveAttribute('aria-pressed', 'true');
});
});
});
97 changes: 97 additions & 0 deletions core/src/components/modal/test/dismiss-behavior/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Modal - Dismiss Behavior</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>

<body>
<ion-app>
<div class="ion-page">
<ion-header>
<ion-toolbar>
<ion-title>Modal - Dismiss Behavior</ion-title>
</ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
<button id="present-first-modal" onclick="presentFirstModal()">Present Modal</button>
</ion-content>
</div>
</ion-app>

<script type="module">
import { modalController } from '../../../../../dist/ionic/index.esm.js';

window.modalController = modalController;

const sharedId = 'shared-modal-id';
const maxModals = 5;
let modalCount = 0;

function createModalComponent(modalNumber) {
const element = document.createElement('div');
const canPresentNext = modalNumber < maxModals;
const presentNextButton = canPresentNext
? `<ion-button id="present-next-modal" onclick="presentNextModal(${modalNumber + 1})">Present Modal ${
modalNumber + 1
}</ion-button>`
: '';
element.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-title>Modal ${modalNumber}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p>This is modal number ${modalNumber}</p>
${presentNextButton}
<ion-button class="dismiss-by-id">Dismiss By ID</ion-button>
<ion-button class="dismiss-default">Dismiss Default</ion-button>
</ion-content>
`;
return element;
}

window.presentFirstModal = async () => {
modalCount = 0;
await presentNextModal(1);
};

window.presentNextModal = async (modalNumber) => {
if (modalNumber > maxModals) {
return;
}
modalCount = Math.max(modalCount, modalNumber);
const element = createModalComponent(modalNumber);
const modal = await modalController.create({
component: element,
htmlAttributes: {
id: sharedId,
'data-testid': `modal-${modalNumber}`,
},
});
await modal.present();

const dismissByIdButton = element.querySelector('ion-button.dismiss-by-id');
dismissByIdButton.addEventListener('click', () => {
modalController.dismiss(undefined, undefined, sharedId);
});

const dismissDefaultButton = element.querySelector('ion-button.dismiss-default');
dismissDefaultButton.addEventListener('click', () => {
modalController.dismiss();
});
};
</script>
</body>
</html>
58 changes: 58 additions & 0 deletions core/src/components/modal/test/dismiss-behavior/modal.e2e.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
});
19 changes: 18 additions & 1 deletion core/src/components/modal/test/modal-id.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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: () => [
<ion-modal id={sharedId} overlayIndex={1} is-open={true}></ion-modal>,
<ion-modal id={sharedId} overlayIndex={2} is-open={true}></ion-modal>,
],
});

const modals = page.body.querySelectorAll('ion-modal');
expect(modals.length).toBe(2);
expect(modals[0].id).toBe(sharedId);
expect(modals[1].id).toBe(sharedId);
});
});
4 changes: 3 additions & 1 deletion core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
};

/**
Expand Down
Loading
Loading