Compare commits

..

91 Commits

Author SHA1 Message Date
ionitron
1e4e9b9ff8 chore(): add updated snapshots 2026-02-03 21:23:00 +00:00
ShaneK
9097a1d146 Trying to improve accuracy 2026-02-03 13:11:38 -08:00
ShaneK
edc202db34 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2026-02-03 12:07:20 -08:00
ShaneK
1d232c8202 fix(modal): attempting to fix scenarios where the modal would become fully constrained 2026-02-03 10:31:00 -08:00
ShaneK
7584e617f1 chore(test): resetting screenshot test for irrelevant issue 2026-02-03 07:52:50 -08:00
Brandy Smith
cc75ff42e1 chore(scripts): remove no longer used test.e2e.script (#30943)
Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
2026-02-03 14:51:30 +00:00
ShaneK
fdb24e4f5f Merge branch 'FW-6830' of github.com:ionic-team/ionic-framework into FW-6830 2026-02-02 06:00:49 -08:00
ShaneK
bc5b3a3a84 fix(popover): apply safe area adjustments for edge positioning in md mode 2026-02-02 06:00:40 -08:00
ionitron
b943db479e chore(): add updated snapshots 2026-01-30 16:58:29 +00:00
ShaneK
04c10985b1 Merge branch 'FW-6830' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-30 08:47:04 -08:00
ShaneK
ab7c863e36 test(safe-area): adding left/right safe area tests 2026-01-30 08:46:49 -08:00
ionitron
1895f8fb20 chore(): add updated snapshots 2026-01-30 16:28:49 +00:00
ShaneK
d4f646104b chore(tests): resetting screenshots to remove pointless diffs 2026-01-30 08:17:34 -08:00
ionitron
82584aaf83 chore(): add updated snapshots 2026-01-30 16:12:27 +00:00
ShaneK
3f1e2c0644 Merge branch 'FW-6830' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-30 08:00:45 -08:00
ShaneK
fcc50d6d16 fix(modal): fixing safe area in certain situations, adding some tests 2026-01-30 07:01:34 -08:00
renovate[bot]
893d523997 chore(deps): update dependency @capacitor/core to v8.0.2 (#30938)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@capacitor/core](https://capacitorjs.com)
([source](https://redirect.github.com/ionic-team/capacitor)) | [`8.0.1`
→
`8.0.2`](https://renovatebot.com/diffs/npm/@capacitor%2fcore/8.0.1/8.0.2)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@capacitor%2fcore/8.0.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@capacitor%2fcore/8.0.1/8.0.2?slim=true)
|

---

### Release Notes

<details>
<summary>ionic-team/capacitor (@&#8203;capacitor/core)</summary>

###
[`v8.0.2`](https://redirect.github.com/ionic-team/capacitor/blob/HEAD/CHANGELOG.md#802-2026-01-27)

[Compare
Source](https://redirect.github.com/ionic-team/capacitor/compare/8.0.1...8.0.2)

##### Bug Fixes

- **android:** AGP 9.0 no longer supports `proguard-android.txt`
([#&#8203;8315](https://redirect.github.com/ionic-team/capacitor/issues/8315))
([dcc76c3](dcc76c3750))
- **cli:** Update tar package
([#&#8203;8311](https://redirect.github.com/ionic-team/capacitor/issues/8311))
([0969c5c](0969c5cd0b))
- **core:** make SystemBars hide and show options optional
([#&#8203;8305](https://redirect.github.com/ionic-team/capacitor/issues/8305))
([95dc7d8](95dc7d8ace))
- **SystemBars:** get correct style on handleOnConfigurationChanged
([#&#8203;8295](https://redirect.github.com/ionic-team/capacitor/issues/8295))
([2a66b44](2a66b44915))
- **SystemBars:** Set window background color according to theme
([#&#8203;8306](https://redirect.github.com/ionic-team/capacitor/issues/8306))
([6037e38](6037e3836e))
- **SystemBars:** Skipping margin manipulation when on a fixed WebView
([#&#8203;8309](https://redirect.github.com/ionic-team/capacitor/issues/8309))
([53c33b6](53c33b6142))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Never, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/ionic-team/ionic-framework).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi45Mi4xIiwidXBkYXRlZEluVmVyIjoiNDIuOTIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-30 14:18:12 +00:00
ShaneK
1f68ad48f2 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-29 10:09:29 -08:00
OS-jacobbell
be14dc4bb8 chore(ci): persist updates to core/package.json in stencil nightly build (#30937)
<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->
The Stencil Nightly Build workflow tests Ionic with the latest nightly
build of Stencil. The first step of the workflow updates Stencil, builds
Ionic core, and uploads the build files. Later steps download these
build files. Core's updated package.json is not uploaded with the build
files, so later steps are installing an old Stencil version, leading to
conflicts.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->
Add core's package.json to the artifact upload. This will make all later
steps use the correct Stencil version.

Seven of the tests run `git diff` to ensure tests did not cause changes
in tracked files. Core's package.json would register as a change, so a
new step reverts package.json before running `git diff`.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
2026-01-27 21:57:29 +00:00
renovate[bot]
364faced75 chore(deps): update actions/checkout action to v6.0.2 (#30935)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/checkout](https://redirect.github.com/actions/checkout) |
action | patch | `v6.0.1` → `v6.0.2` |

---

### Release Notes

<details>
<summary>actions/checkout (actions/checkout)</summary>

###
[`v6.0.2`](https://redirect.github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v602)

[Compare
Source](https://redirect.github.com/actions/checkout/compare/v6.0.1...v6.0.2)

- Fix tag handling: preserve annotations and explicit fetch-tags by
[@&#8203;ericsciple](https://redirect.github.com/ericsciple) in
[#&#8203;2356](https://redirect.github.com/actions/checkout/pull/2356)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Never, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/ionic-team/ionic-framework).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi44NS4xIiwidXBkYXRlZEluVmVyIjoiNDIuODUuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 20:43:30 +00:00
Brandy Smith
442e3e9831 fix(toast): keep icon on the same line as long message in stacked layout (#30923)
Issue number: resolves #30908

---------

## What is the current behavior?
Toast with an icon and long message using a stacked layout will wrap the
message below the icon.

## What is the new behavior?
- Apply `flex: 1` to `.toast-content` regardless of layout, which makes
sure the content does not get wrapped under the icon
- Adds an e2e test for a stacked toast with a long message to
`toast/test/layout`

## Does this introduce a breaking change?
- [ ] Yes
- [x] No

## Other information

- Dev build: `8.7.18-dev.11768592717.14a59d2f`
- Preview:
[Layout](https://ionic-framework-git-fw-7035-ionic1.vercel.app/src/components/toast/test/layout/)

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
2026-01-19 21:48:13 +00:00
renovate[bot]
62d880d620 chore(deps): update dependency @capacitor/core to v8.0.1 (#30914)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@capacitor/core](https://capacitorjs.com)
([source](https://redirect.github.com/ionic-team/capacitor)) | [`8.0.0`
→
`8.0.1`](https://renovatebot.com/diffs/npm/@capacitor%2fcore/8.0.0/8.0.1)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@capacitor%2fcore/8.0.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@capacitor%2fcore/8.0.0/8.0.1?slim=true)
|

---

### Release Notes

<details>
<summary>ionic-team/capacitor (@&#8203;capacitor/core)</summary>

###
[`v8.0.1`](https://redirect.github.com/ionic-team/capacitor/blob/HEAD/CHANGELOG.md#801-2026-01-13)

[Compare
Source](https://redirect.github.com/ionic-team/capacitor/compare/8.0.0...8.0.1)

##### Bug Fixes

- **android:** Remove calculated bottom inset if keyboard is visible
([#&#8203;8280](https://redirect.github.com/ionic-team/capacitor/issues/8280))
([196b642](196b642236))
- **cli:** Support wireless iOS devices in `cap run`
([#&#8203;8301](https://redirect.github.com/ionic-team/capacitor/issues/8301))
([dcb368c](dcb368c335))
- **cli:** use latest native-run
([#&#8203;8296](https://redirect.github.com/ionic-team/capacitor/issues/8296))
([121d830](121d83013f))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Never, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/ionic-team/ionic-framework).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi43NC41IiwidXBkYXRlZEluVmVyIjoiNDIuNzQuNSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 14:56:28 +00:00
renovate[bot]
4eca8d39d8 chore(deps): update actions/setup-node action to v6.2.0 (#30918)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/setup-node](https://redirect.github.com/actions/setup-node) |
action | minor | `v6.1.0` → `v6.2.0` |

---

### Release Notes

<details>
<summary>actions/setup-node (actions/setup-node)</summary>

###
[`v6.2.0`](https://redirect.github.com/actions/setup-node/compare/v6.1.0...v6.2.0)

[Compare
Source](https://redirect.github.com/actions/setup-node/compare/v6.1.0...v6.2.0)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Never, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/ionic-team/ionic-framework).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi43NC41IiwidXBkYXRlZEluVmVyIjoiNDIuNzQuNSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 14:54:52 +00:00
Brandy Smith
040bdf78c5 merge release-8.7.17 (#30916)
v8.7.17
2026-01-14 14:43:10 -05:00
Brandy Smith
1bccf76d35 chore(changelog): remove duplicate fixes in wrong version 2026-01-14 14:22:32 -05:00
ionitron
dd1c1e8fa3 chore(): update package lock files 2026-01-14 18:59:30 +00:00
ionitron
d7b4d0690b v8.7.17 2026-01-14 18:58:56 +00:00
ionitron
3bbb0a78f3 chore(): add updated snapshots 2026-01-14 18:19:42 +00:00
ShaneK
4d81e2d820 Resetting unnecessary screenshot changes 2026-01-14 10:08:14 -08:00
ShaneK
e1388e646a Merge branch 'FW-6830' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-14 10:04:54 -08:00
ShaneK
56190b2c79 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-14 10:04:39 -08:00
Brandy Smith
95b87020d6 chore(github): do not close issues as stale when they are external bugs (#30915)
Ionitron keeps closing issues with `bug: external` as stale:
https://github.com/ionic-team/ionic-framework/issues/27052#event-21879561018

This PRs adds `bug: external` as an exempt label when closing issues as
stale.

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
2026-01-14 16:18:00 +00:00
Shane
ab733b71dd fix(input): prevent Android TalkBack from focusing label separately (#30895)
Issue number: resolves internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
When using `ion-input` with a label on Android, TalkBack treats the
visual label text as a separate focusable element. This causes the
initial focus to land on the label instead of the input field, creating
a confusing experience for screen reader users.

## What is the new behavior?
The label text wrapper is now hidden from the accessibility tree via
`aria-hidden="true"`, while the native input maintains proper labeling
through `aria-labelledby`. This ensures Android TalkBack focuses
directly on the input field while still announcing the label correctly.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Current dev build:
```
8.7.16-dev.11767032989.1ae720d0
```
2026-01-14 16:17:33 +00:00
Shane
f99d0007a8 fix(tab-bar): prevent keyboard controller memory leak on rapid mount/unmount (#30906)
Issue number: resolves internal

---------

## What is the current behavior?
When `ion-tab-bar` is rapidly mounted and unmounted, a race condition in
connectedCallback can cause the keyboard controller to be created after
the component has been disconnected. This results in orphaned event
listeners (`keyboardWillShow`, `keyboardWillHide`) on the window object
that are never cleaned up, causing a memory leak.

## What is the new behavior?
The keyboard controller is now properly destroyed in all scenarios:
- If the component is disconnected while createKeyboardController is
pending, the promise is tracked and destroyed when it resolves
- If a new connectedCallback runs before the previous async completes,
the stale controller is destroyed

The promise tracking pattern ensures only the most recent async
operation assigns its result

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
Current dev build:
```
8.7.17-dev.11767895575.16ea7cef
```

I was unable to find a way to create tests that accurately identified if
this problem was occurring. Memory leaks are notoriously difficult to
created automated tests for. I ultimately removed my previous attempts
because I didn't want to give a false sense of security.
2026-01-14 15:59:14 +00:00
Shane
3b3318da51 fix(input): prevent placeholder from overlapping start slot during scroll assist (#30896)
Issue number: resolves internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
On iOS, when focusing an `ion-input` or `ion-textarea` that requires
scrolling into view (scroll assist), the placeholder text shifts to the
left and overlaps any content in the start slot (e.g., icons). This
occurs because the cloned input used during scroll assist is positioned
at the container's left edge rather than at the native input's actual
position. Additionally, when quickly switching between inputs before
scroll assist completes, focus jumps back to the original input.

## What is the new behavior?
The cloned input is now positioned at the same offset as the native
input, preventing the placeholder from shifting or overlapping start
slot content during scroll assist. This works correctly for both LTR and
RTL layouts. Also, scroll assist no longer steals focus back if the user
has moved focus to another element while scrolling was in progress.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Current dev build:
```
8.7.16-dev.11767042721.11309185
```
2026-01-13 18:42:50 +00:00
ionitron
d8abf4ce35 chore(): add updated snapshots 2026-01-12 17:43:04 +00:00
ShaneK
8ee1069b93 Reverting changes to be focused on modal and popover safe area support 2026-01-12 09:28:49 -08:00
ShaneK
3b7beca8d0 chore(lint): ignore disallowed property in content.scss 2026-01-09 11:31:51 -08:00
ionitron
0174a3938c chore(): add updated snapshots 2026-01-09 19:08:34 +00:00
ShaneK
f9159e1e90 fix(content): support side safe area content 2026-01-09 10:40:57 -08:00
ShaneK
095b72ef30 fix(content): detect dynamic tab bar changes for safe-area handling 2026-01-09 09:26:46 -08:00
ShaneK
e953f7b506 chore(tests): fix safe-area tests for Mobile Firefox 2026-01-08 10:14:21 -08:00
ShaneK
a63afa3db6 fix(modal): addressing edge cases, cleaning up 2026-01-08 09:02:51 -08:00
ShaneK
26b6b7bb02 fix(content): exclude nested content from safe-area handling 2026-01-08 06:59:16 -08:00
ShaneK
553aa65376 chore(tests): fixing tests having issues with mutation observers 2026-01-07 09:28:53 -08:00
ionitron
a5bd1dd518 chore(): add updated snapshots 2026-01-07 16:24:35 +00:00
ShaneK
48e4bc4776 fix(content): detect header/footer wrapped in custom components 2026-01-07 06:33:33 -08:00
ShaneK
fc496043d8 chore(test): zero out safe-area insets in test environments 2026-01-06 09:48:49 -08:00
ShaneK
4fe98a42ff fix(content): apply safe-area insets when header/footer absent 2026-01-06 08:41:31 -08:00
ShaneK
7c197c2c99 fix(popover): extending safe are protections to top/bottom overlap 2026-01-05 13:17:55 -08:00
ShaneK
4a165bc26c fix(modal): correct safe-area handling for MD mode and edge detection 2026-01-05 11:25:45 -08:00
ShaneK
9c404a6839 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-05 10:33:41 -08:00
ShaneK
39b15cb3b0 chore: fixing phone viewport tests 2026-01-02 08:15:39 -08:00
ShaneK
fa16c3a7bd chore: test fix 2026-01-02 07:00:32 -08:00
ShaneK
d6eb8ce8e9 fix(modal): apply safe-area padding to card modals on phones 2026-01-02 06:47:40 -08:00
Shane
07b46d745a release-8.7.16 (#30903)
v8.7.16

---------

Co-authored-by: ionitron <hi@ionicframework.com>
2025-12-31 13:32:10 -08:00
ionitron
37f87b39c4 chore(): update package lock files 2025-12-31 13:20:48 -08:00
ionitron
f71f4bf454 v8.7.16 2025-12-31 13:20:48 -08:00
Shane
36f4b4d600 fix(modal): prevent card modal animation on viewport resize when modal is closed (#30894)
Issue number: resolves #30679

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
When a page contains a card modal with a `presentingElement`, resizing
the viewport (e.g., rotating from portrait to landscape) triggers the
card modal's "lean back" animation on the presenting element, even when
the modal has never been opened.

## What is the new behavior?
Viewport resize events no longer trigger the presenting element
animation when the modal is not presented. The animation only runs when
the modal is actually open.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Current dev build:
```
8.7.16-dev.11767028735.16932cea
```
2025-12-31 13:20:48 -08:00
ionitron
3fac5ccbf8 chore(): add updated snapshots 2025-12-31 21:05:04 +00:00
ShaneK
35579250d5 fix(modal): dynamically handle safe-area insets for edge-to-edge mode 2025-12-31 10:28:09 -08:00
ShaneK
61b588c6b9 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2025-12-31 05:31:53 -08:00
Shane
e5634d45ee fix(modal): prevent card modal animation on viewport resize when modal is closed (#30894)
Issue number: resolves #30679

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
When a page contains a card modal with a `presentingElement`, resizing
the viewport (e.g., rotating from portrait to landscape) triggers the
card modal's "lean back" animation on the presenting element, even when
the modal has never been opened.

## What is the new behavior?
Viewport resize events no longer trigger the presenting element
animation when the modal is not presented. The animation only runs when
the modal is actually open.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Current dev build:
```
8.7.16-dev.11767028735.16932cea
```
2025-12-30 10:33:41 -08:00
ShaneK
4b7f2fadef Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2025-12-26 13:51:44 -08:00
ShaneK
415245b9b4 resetting unchanged snapshot 2025-12-26 11:01:53 -08:00
ionitron
61dc7eb4f0 chore(): add updated snapshots 2025-12-26 18:57:09 +00:00
ionitron
b87cd07e91 chore(): add updated snapshots 2025-12-26 18:25:42 +00:00
ShaneK
fea0a3da0f fix(modal): dynamically handle safe-area insets based on modal type and position 2025-12-26 10:15:02 -08:00
ShaneK
f66c84a9b9 fix(modal): dynamically apply safe-area insets based on viewport edge contact 2025-12-26 09:57:44 -08:00
ShaneK
c54f257633 fix(modal): respect safe area insets on tablet-sized screens 2025-12-23 14:28:12 -08:00
Shane
7d6430738e merge release-8.7.15 (#30887)
v8.7.15
2025-12-23 11:36:36 -08:00
Shane
72826edf9a merge release-8.7.15 (#30886)
v8.7.15

---------

Co-authored-by: ionitron <hi@ionicframework.com>
2025-12-23 11:14:57 -08:00
ionitron
4360e39a58 chore(): update package lock files 2025-12-23 18:55:54 +00:00
ionitron
622d62a3f4 v8.7.15 2025-12-23 18:55:19 +00:00
Shane
12ede4b79c fix(input-password-toggle): improve screen reader announcements (#30885)
Issue number: resolves internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?

The `ion-input-password-toggle` button uses `role="switch"` with
`aria-checked`, causing screen readers like VoiceOver to announce both a
state ("On/Off") and an action ("Show/Hide password"). This results in
confusing, redundant output such as "On, Hide Password" or "Off, Show
Password".

## What is the new behavior?

The password toggle button now uses `aria-pressed` instead of
`role="switch"` with `aria-checked`. Screen readers announce the
action-based label ("Show password" or "Hide password") along with the
pressed state, and properly announce state changes when the button is
activated.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

[Old
Preview](https://ionic-framework-git-main-ionic1.vercel.app/src/components/input-password-toggle/test/basic)

[New
Preview](https://ionic-framework-git-fw-6920-ionic1.vercel.app/src/components/input-password-toggle/test/basic)

Current dev build:
```
8.7.15-dev.11766421552.180757ca
```
2025-12-22 17:24:32 +00:00
Israel de la Barrera
f83b000530 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 |
|--------|-------|
| <video
src="https://github.com/user-attachments/assets/1307ee9f-452a-4b00-877d-0b8e360d3bf7">
| <video
src="https://github.com/user-attachments/assets/f9ee3851-ce94-4a27-9947-37aa1f5433b9">
|

---------

Co-authored-by: ShaneK <shane@shanessite.net>
2025-12-22 17:21:16 +00:00
Brandy Smith
3b60a1d68a 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>
2025-12-19 19:03:04 +00:00
Maria Hutt
8573bf8083 fix(core): use Capacitor safe-area CSS variables on older WebViews (#30865)
Issue number: internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

The safe area variables are only reliant on `env` variables that are
provided by devices.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

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

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Dev build: `8.7.13-dev.11765920447.1a01ab8b`

---------

Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
2025-12-18 18:16:56 +00:00
Brandy Smith
2c6fac9060 merge release-8.7.14 (#30879)
v8.7.14

---------

Co-authored-by: ionitron <hi@ionicframework.com>
2025-12-17 13:22:41 -05:00
ionitron
b9fdfab667 chore(): update package lock files 2025-12-17 17:51:54 +00:00
ionitron
f7af5d3ca5 v8.7.14 2025-12-17 17:50:44 +00:00
Shane
03fb422bfa fix(tabs): select correct tab when routes have similar prefixes (#30863)
Issue number: resolves #30448 

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?

When using ion-tabs with routes that share a common prefix (e.g.,
`/home`, `/home2`, `/home3`), navigating to `/home2` incorrectly
highlights the `/home` tab. This occurs because the tab matching logic
uses `pathname.startsWith(href)`, which causes `/home2` to match `/home`
since `/home2` starts with `/home`.

## What is the new behavior?

Tab selection now uses path segment matching instead of simple prefix
matching. A tab's href will only match if the pathname is an exact match
OR starts with the href followed by a / (for nested routes). This
ensures /home2 no longer incorrectly matches /home, while still allowing
/home/details to correctly match the /home tab.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

Current dev build:
```
8.7.13-dev.11765486444.14025098
```

---------

Co-authored-by: Maria Hutt <thetaPC@users.noreply.github.com>
Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
2025-12-17 00:11:10 +00:00
renovate[bot]
82de33b96e chore(deps): update download + upload artifacts (major) (#30872)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[actions/download-artifact](https://redirect.github.com/actions/download-artifact)
| action | major | `v6` -> `v7` |
|
[actions/upload-artifact](https://redirect.github.com/actions/upload-artifact)
| action | major | `v5` -> `v6` |

---

### Release Notes

<details>
<summary>actions/download-artifact (actions/download-artifact)</summary>

###
[`v7`](https://redirect.github.com/actions/download-artifact/compare/v6...v7)

[Compare
Source](https://redirect.github.com/actions/download-artifact/compare/v6...v7)

</details>

<details>
<summary>actions/upload-artifact (actions/upload-artifact)</summary>

###
[`v6`](https://redirect.github.com/actions/upload-artifact/compare/v5...v6)

[Compare
Source](https://redirect.github.com/actions/upload-artifact/compare/v5...v6)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Never, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/ionic-team/ionic-framework).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi40Mi4yIiwidXBkYXRlZEluVmVyIjoiNDIuNDIuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-15 21:31:47 +00:00
Shane
76b715874a release-8.7.13 (#30870)
v8.7.13

---------

Co-authored-by: ionitron <hi@ionicframework.com>
2025-12-13 08:16:52 -08:00
ionitron
6205338620 chore(): update package lock files 2025-12-13 15:52:33 +00:00
ionitron
f775815a13 v8.7.13 2025-12-13 15:51:51 +00:00
Shane
cf3caa287e chore(core): aligning core engine requirement with main ionic-framework requirement (#30869)
Issue number: resolves #30868

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?

Currently, users are unable to use ionic-framework with node < 24. This
was an accidental change, not something we actually require.

## What is the new behavior?

This change aligns the core file with the [top level
package.json](https://github.com/ionic-team/ionic-framework/blob/main/package.json#L9)
requirement. We may want to look into upping this at some point in the
future, but right now this should be fine.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
2025-12-13 15:00:20 +00:00
renovate[bot]
2ee52d77c8 chore(deps): update capacitor to v8 (major) (#30847)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@capacitor/core](https://capacitorjs.com)
([source](https://redirect.github.com/ionic-team/capacitor)) | [`^7.0.0`
->
`^8.0.0`](https://renovatebot.com/diffs/npm/@capacitor%2fcore/7.4.4/8.0.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@capacitor%2fcore/8.0.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@capacitor%2fcore/7.4.4/8.0.0?slim=true)
|
|
[@capacitor/haptics](https://redirect.github.com/ionic-team/capacitor-haptics)
| [`^7.0.0` ->
`^8.0.0`](https://renovatebot.com/diffs/npm/@capacitor%2fhaptics/7.0.3/8.0.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@capacitor%2fhaptics/8.0.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@capacitor%2fhaptics/7.0.3/8.0.0?slim=true)
|
|
[@capacitor/keyboard](https://redirect.github.com/ionic-team/capacitor-keyboard)
| [`^7.0.0` ->
`^8.0.0`](https://renovatebot.com/diffs/npm/@capacitor%2fkeyboard/7.0.4/8.0.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@capacitor%2fkeyboard/8.0.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@capacitor%2fkeyboard/7.0.4/8.0.0?slim=true)
|
|
[@capacitor/status-bar](https://redirect.github.com/ionic-team/capacitor-plugins)
| [`^7.0.0` ->
`^8.0.0`](https://renovatebot.com/diffs/npm/@capacitor%2fstatus-bar/7.0.4/8.0.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@capacitor%2fstatus-bar/8.0.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@capacitor%2fstatus-bar/7.0.4/8.0.0?slim=true)
|

---

### Release Notes

<details>
<summary>ionic-team/capacitor (@&#8203;capacitor/core)</summary>

###
[`v8.0.0`](https://redirect.github.com/ionic-team/capacitor/blob/HEAD/CHANGELOG.md#800-2025-12-08)

[Compare
Source](https://redirect.github.com/ionic-team/capacitor/compare/7.4.4...8.0.0)

##### Bug Fixes

- **cli:** Android apk name multi flavor dimensions parsing
([#&#8203;7598](https://redirect.github.com/ionic-team/capacitor/issues/7598))
([2dc20ee](2dc20ee894))
- **cli:** make migrate update to 8.0.0
([#&#8203;8250](https://redirect.github.com/ionic-team/capacitor/issues/8250))
([ee8ba7b](ee8ba7bbee))
- **ios:** move PrivacyInfo.xcprivacy to resource\_bundles to fix build…
([#&#8203;8264](https://redirect.github.com/ionic-team/capacitor/issues/8264))
([e6f50b8](e6f50b8c0c))

##### Features

- **android:** Improving SystemBars inset handling
([#&#8203;8268](https://redirect.github.com/ionic-team/capacitor/issues/8268))
([81ae30a](81ae30a503))

</details>

<details>
<summary>ionic-team/capacitor-haptics
(@&#8203;capacitor/haptics)</summary>

###
[`v8.0.0`](https://redirect.github.com/ionic-team/capacitor-haptics/blob/HEAD/CHANGELOG.md#800-2025-12-08)

[Compare
Source](e492876ac8...v8.0.0)

##### Bug Fixes

- **android:** use 'propName = value' assignment syntax in build.gradle
files
([e0d3987](e0d3987eea))

##### feature

- Capacitor 8 support
([7d840c7](7d840c7825))

##### BREAKING CHANGES

- Capacitor 8 requires major update

</details>

<details>
<summary>ionic-team/capacitor-keyboard
(@&#8203;capacitor/keyboard)</summary>

###
[`v8.0.0`](https://redirect.github.com/ionic-team/capacitor-keyboard/blob/HEAD/CHANGELOG.md#800-2025-12-08)

[Compare
Source](e492876ac8...v8.0.0)

##### Bug Fixes

- **android:** Adjust WebView sizing during keyboard size changes
([ae75247](ae752478ff))
- **android:** use 'propName = value' assignment syntax in build.gradle
files
([37b39b0](37b39b0c64))

##### feature

- Capacitor 8 support
([f8c21ef](f8c21ef521))

##### BREAKING CHANGES

- Capacitor 8 requires major update

</details>

<details>
<summary>ionic-team/capacitor-plugins
(@&#8203;capacitor/status-bar)</summary>

###
[`v8.0.0`](https://redirect.github.com/ionic-team/capacitor-plugins/releases/tag/%40capacitor/app%408.0.0)

[Compare
Source](https://redirect.github.com/ionic-team/capacitor-plugins/compare/@capacitor/status-bar@7.0.4...@capacitor/status-bar@8.0.0)

**Note:** Version bump only for package
[@&#8203;capacitor/app](https://redirect.github.com/capacitor/app)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Never, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/ionic-team/ionic-framework).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi4zMi4yIiwidXBkYXRlZEluVmVyIjoiNDIuNDIuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 19:26:50 +00:00
renovate[bot]
0e110de5e3 chore(deps): update capacitor to v7.0.3 (#30846)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[@capacitor/haptics](https://redirect.github.com/ionic-team/capacitor-haptics)
| [`7.0.2` ->
`7.0.3`](https://renovatebot.com/diffs/npm/@capacitor%2fhaptics/7.0.2/7.0.3)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@capacitor%2fhaptics/7.0.3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@capacitor%2fhaptics/7.0.2/7.0.3?slim=true)
|
|
[@capacitor/keyboard](https://redirect.github.com/ionic-team/capacitor-keyboard)
| [`7.0.3` ->
`7.0.4`](https://renovatebot.com/diffs/npm/@capacitor%2fkeyboard/7.0.3/7.0.4)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@capacitor%2fkeyboard/7.0.4?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@capacitor%2fkeyboard/7.0.3/7.0.4?slim=true)
|
|
[@capacitor/status-bar](https://redirect.github.com/ionic-team/capacitor-plugins)
| [`7.0.3` ->
`7.0.4`](https://renovatebot.com/diffs/npm/@capacitor%2fstatus-bar/7.0.3/7.0.4)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@capacitor%2fstatus-bar/7.0.4?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@capacitor%2fstatus-bar/7.0.3/7.0.4?slim=true)
|

---

### Release Notes

<details>
<summary>ionic-team/capacitor-haptics
(@&#8203;capacitor/haptics)</summary>

###
[`v7.0.3`](https://redirect.github.com/ionic-team/capacitor-haptics/compare/v7.0.2...e492876ac85661078e39664652b01ac9e0ab08c7)

[Compare
Source](https://redirect.github.com/ionic-team/capacitor-haptics/compare/v7.0.2...e492876ac85661078e39664652b01ac9e0ab08c7)

</details>

<details>
<summary>ionic-team/capacitor-keyboard
(@&#8203;capacitor/keyboard)</summary>

###
[`v7.0.4`](https://redirect.github.com/ionic-team/capacitor-keyboard/compare/v7.0.3...e492876ac85661078e39664652b01ac9e0ab08c7)

[Compare
Source](https://redirect.github.com/ionic-team/capacitor-keyboard/compare/v7.0.3...e492876ac85661078e39664652b01ac9e0ab08c7)

</details>

<details>
<summary>ionic-team/capacitor-plugins
(@&#8203;capacitor/status-bar)</summary>

###
[`v7.0.4`](https://redirect.github.com/ionic-team/capacitor-plugins/releases/tag/%40capacitor/splash-screen%407.0.4)

[Compare
Source](https://redirect.github.com/ionic-team/capacitor-plugins/compare/@capacitor/status-bar@7.0.3...@capacitor/status-bar@7.0.4)

**Note:** Version bump only for package
[@&#8203;capacitor/splash-screen](https://redirect.github.com/capacitor/splash-screen)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Never, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/ionic-team/ionic-framework).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi4zMi4yIiwidXBkYXRlZEluVmVyIjoiNDIuNDIuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 18:57:45 +00:00
Shane
f50994a6ef merge release-8.7.12 (#30856)
v8.7.12

---------

Co-authored-by: ionitron <hi@ionicframework.com>
2025-12-10 13:52:10 -08:00
ionitron
5bf6f6e825 chore(): update package lock files 2025-12-10 21:34:28 +00:00
178 changed files with 3197 additions and 578 deletions

View File

@@ -22,7 +22,7 @@ runs:
using: 'composite'
steps:
- name: 🟢 Configure Node for Publish
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: ${{ inputs.node-version }}
registry-url: 'https://registry.npmjs.org'

View File

@@ -40,7 +40,7 @@ comment:
If the requested feature is something you would find useful for your applications, please react to the original post with 👍 (`+1`). If you would like to provide an additional use case for the feature, please post a comment.
The team will review this feedback and make a final decision. Any decision will be posted on this thread, but please note that we may ultimately decide not to pursue this feature.
@@ -83,6 +83,7 @@ stale:
exemptLabels:
- "good first issue"
- "triage"
- "bug: external"
- "type: bug"
- "type: feature request"
- "needs: investigation"

View File

@@ -3,7 +3,7 @@ description: 'Build Ionic Angular Server'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive

View File

@@ -27,6 +27,10 @@ runs:
run: npm run build
shell: bash
working-directory: ./packages/angular
- name: Clean core package.json
run: git checkout ./package.json
shell: bash
working-directory: ./core
- name: 🔍 Check Diff
run: git diff --exit-code
shell: bash

View File

@@ -8,8 +8,8 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
@@ -29,4 +29,4 @@ runs:
with:
name: ionic-core
output: core/CoreBuild.zip
paths: core/dist core/components core/css core/hydrate core/loader core/src/components.d.ts
paths: core/dist core/components core/css core/hydrate core/loader core/src/components.d.ts core/package.json

View File

@@ -8,8 +8,8 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- name: 🕸️ Install Dependencies

View File

@@ -3,7 +3,7 @@ description: 'Build Ionic React Router'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive

View File

@@ -3,7 +3,7 @@ description: 'Build Ionic React'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -31,6 +31,10 @@ runs:
run: npm run test.spec
shell: bash
working-directory: ./packages/react
- name: Clean core package.json
run: git checkout ./package.json
shell: bash
working-directory: ./core
- name: 🔍 Check Diff
run: git diff --exit-code
shell: bash

View File

@@ -3,7 +3,7 @@ description: 'Builds Ionic Vue Router'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive

View File

@@ -3,7 +3,7 @@ description: 'Build Ionic Vue'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -27,6 +27,10 @@ runs:
run: npm run build
shell: bash
working-directory: ./packages/vue
- name: Clean core package.json
run: git checkout ./package.json
shell: bash
working-directory: ./core
- name: 🔍 Check Diff
run: git diff --exit-code
shell: bash

View File

@@ -10,7 +10,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v7
with:
name: ${{ inputs.name }}
path: ${{ inputs.path }}

View File

@@ -6,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -17,7 +17,7 @@ runs:
- uses: ./.github/workflows/actions/download-archive
with:
name: ionic-angular
path: ./angular
path: ./packages/angular
filename: AngularBuild.zip
- uses: ./.github/workflows/actions/download-archive
with:

View File

@@ -3,7 +3,7 @@ description: 'Test Core Clean Build'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
@@ -12,6 +12,10 @@ runs:
name: ionic-core
path: ./core
filename: CoreBuild.zip
- name: Clean core package.json
run: git checkout ./package.json
shell: bash
working-directory: ./core
- name: 🔍 Check Diff
run: |
git diff --exit-code || {

View File

@@ -3,13 +3,17 @@ description: 'Test Core Lint'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- name: 🕸️ Install Dependencies
run: npm ci
working-directory: ./core
shell: bash
- name: Clean core package.json
run: git checkout ./package.json
shell: bash
working-directory: ./core
- name: 🖌️ Lint
run: npm run lint
shell: bash

View File

@@ -13,7 +13,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -30,6 +30,10 @@ runs:
run: npm run test.e2e.docker.ci ${{ inputs.component }} -- --shard=${{ inputs.shard }}/${{ inputs.totalShards }}
shell: bash
working-directory: ./core
- name: Clean core package.json
run: git checkout ./package.json
shell: bash
working-directory: ./core
- name: Test and Update
id: test-and-update
if: inputs.update == 'true'
@@ -62,7 +66,7 @@ runs:
working-directory: ./core
- name: 📦 Archive Updated Screenshots
if: inputs.update == 'true' && steps.test-and-update.outputs.hasUpdatedScreenshots == 'true'
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: updated-screenshots-${{ inputs.shard }}-${{ inputs.totalShards }}
path: UpdatedScreenshots-${{ inputs.shard }}-${{ inputs.totalShards }}.zip

View File

@@ -6,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- name: 🕸️ Install Dependencies

View File

@@ -6,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive

View File

@@ -6,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive

View File

@@ -6,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive

View File

@@ -7,10 +7,10 @@ on:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v7
with:
path: ./artifacts
- name: 🔎 Extract Archives
@@ -21,6 +21,10 @@ runs:
find . -type f -name 'UpdatedScreenshots-*.zip' -exec unzip -q -o -d ../ {} \;
shell: bash
working-directory: ./artifacts
- name: Clean core package.json
run: git checkout ./package.json
shell: bash
working-directory: ./core
- name: 📸 Push Screenshots
# Configure user as Ionitron
# and push only the changed .png snapshots

View File

@@ -13,7 +13,7 @@ runs:
- name: 🗄️ Create Archive
run: zip -q -r ${{ inputs.output }} ${{ inputs.paths }}
shell: bash
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
with:
name: ${{ inputs.name }}
path: ${{ inputs.output }}

View File

@@ -22,7 +22,7 @@ jobs:
build-core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-core
with:
ionicons-version: ${{ inputs.ionicons_npm_release_tag }}
@@ -31,21 +31,21 @@ jobs:
needs: [build-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-core-clean-build
test-core-lint:
needs: [build-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-core-lint
test-core-spec:
needs: [build-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-core-spec
test-core-screenshot:
@@ -62,7 +62,7 @@ jobs:
needs: [build-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-core-screenshot
with:
shard: ${{ matrix.shard }}
@@ -90,14 +90,14 @@ jobs:
needs: [build-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-vue
build-vue-router:
needs: [build-vue]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-vue-router
test-vue-e2e:
@@ -108,7 +108,7 @@ jobs:
needs: [build-vue, build-vue-router]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-vue-e2e
with:
app: ${{ matrix.apps }}
@@ -126,14 +126,14 @@ jobs:
needs: [build-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-angular
build-angular-server:
needs: [build-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-angular-server
test-angular-e2e:
@@ -144,7 +144,7 @@ jobs:
needs: [build-angular, build-angular-server]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-angular-e2e
with:
app: ${{ matrix.apps }}
@@ -162,14 +162,14 @@ jobs:
needs: [build-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-react
build-react-router:
needs: [build-react]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-react-router
test-react-router-e2e:
@@ -180,7 +180,7 @@ jobs:
needs: [build-react, build-react-router]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-react-router-e2e
with:
app: ${{ matrix.apps }}
@@ -202,7 +202,7 @@ jobs:
needs: [build-react, build-react-router]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-react-e2e
with:
app: ${{ matrix.apps }}

View File

@@ -14,7 +14,7 @@ jobs:
permissions:
security-events: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: github/codeql-action/init@v4
with:
languages: javascript

View File

@@ -13,7 +13,7 @@ jobs:
outputs:
dev-hash: ${{ steps.create-dev-hash.outputs.DEV_HASH }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# A 1 is required before the timestamp
# as lerna will fail when there is a leading 0
# See https://github.com/lerna/lerna/issues/2840

View File

@@ -13,7 +13,7 @@ jobs:
outputs:
nightly-hash: ${{ steps.create-nightly-hash.outputs.NIGHTLY_HASH }}
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# A 1 is required before the timestamp
# as lerna will fail when there is a leading 0
# See https://github.com/lerna/lerna/issues/2840

View File

@@ -23,7 +23,7 @@ jobs:
release-core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/actions/publish-npm
with:
scope: '@ionic/core'
@@ -48,7 +48,7 @@ jobs:
needs: [release-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore @ionic/docs built cache
uses: ./.github/workflows/actions/download-archive
with:
@@ -67,7 +67,7 @@ jobs:
needs: [release-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore @ionic/core built cache
uses: ./.github/workflows/actions/download-archive
with:
@@ -93,7 +93,7 @@ jobs:
needs: [release-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore @ionic/core built cache
uses: ./.github/workflows/actions/download-archive
with:
@@ -118,7 +118,7 @@ jobs:
needs: [release-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore @ionic/core built cache
uses: ./.github/workflows/actions/download-archive
with:
@@ -143,7 +143,7 @@ jobs:
needs: [release-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore @ionic/core built cache
uses: ./.github/workflows/actions/download-archive
with:
@@ -163,7 +163,7 @@ jobs:
needs: [release-react]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore @ionic/core built cache
uses: ./.github/workflows/actions/download-archive
with:
@@ -188,7 +188,7 @@ jobs:
needs: [release-vue]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore @ionic/core built cache
uses: ./.github/workflows/actions/download-archive
with:

View File

@@ -58,7 +58,7 @@ jobs:
contents: write
id-token: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ secrets.IONITRON_TOKEN }}
fetch-depth: 0
@@ -89,7 +89,7 @@ jobs:
contents: write
id-token: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Pull the latest version of the reference
# branch instead of the revision that triggered
# the workflow otherwise we won't get the commit

View File

@@ -26,7 +26,7 @@ jobs:
build-core-with-stencil-nightly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-core-stencil-prerelease
with:
stencil-version: ${{ inputs.npm_release_tag || 'nightly' }}
@@ -35,21 +35,21 @@ jobs:
needs: [build-core-with-stencil-nightly]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-core-clean-build
test-core-lint:
needs: [build-core-with-stencil-nightly]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-core-lint
test-core-spec:
needs: [build-core-with-stencil-nightly]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-core-spec
with:
stencil-version: ${{ inputs.npm_release_tag || 'nightly' }}
@@ -72,7 +72,7 @@ jobs:
needs: [build-core-with-stencil-nightly]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-core-screenshot
with:
shard: ${{ matrix.shard }}
@@ -100,14 +100,14 @@ jobs:
needs: [build-core-with-stencil-nightly]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-vue
build-vue-router:
needs: [build-vue]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-vue-router
test-vue-e2e:
@@ -118,7 +118,7 @@ jobs:
needs: [build-vue, build-vue-router]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-vue-e2e
with:
app: ${{ matrix.apps }}
@@ -136,14 +136,14 @@ jobs:
needs: [build-core-with-stencil-nightly]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-angular
build-angular-server:
needs: [build-core-with-stencil-nightly]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-angular-server
test-angular-e2e:
@@ -154,7 +154,7 @@ jobs:
needs: [build-angular, build-angular-server]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-angular-e2e
with:
app: ${{ matrix.apps }}
@@ -172,14 +172,14 @@ jobs:
needs: [build-core-with-stencil-nightly]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-react
build-react-router:
needs: [build-react]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-react-router
test-react-router-e2e:
@@ -190,7 +190,7 @@ jobs:
needs: [build-react, build-react-router]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-react-router-e2e
with:
app: ${{ matrix.apps }}
@@ -212,7 +212,7 @@ jobs:
needs: [build-react, build-react-router]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-react-e2e
with:
app: ${{ matrix.apps }}

View File

@@ -26,7 +26,7 @@ jobs:
build-core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/build-core
test-core-screenshot:
@@ -47,7 +47,7 @@ jobs:
needs: [build-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ./.github/workflows/actions/test-core-screenshot
with:
shard: ${{ matrix.shard }}
@@ -59,7 +59,7 @@ jobs:
runs-on: ubuntu-latest
needs: [test-core-screenshot]
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Normally, we could just push with the
# default GITHUB_TOKEN, but that will
# not cause the build workflow

View File

@@ -3,6 +3,63 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [8.7.17](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.17) (2026-01-14)
### Bug Fixes
* **input:** prevent Android TalkBack from focusing label separately ([#30895](https://github.com/ionic-team/ionic-framework/issues/30895)) ([ab733b7](https://github.com/ionic-team/ionic-framework/commit/ab733b71dd355d9486757f219fe09acaefeeefcc))
* **input:** prevent placeholder from overlapping start slot during scroll assist ([#30896](https://github.com/ionic-team/ionic-framework/issues/30896)) ([3b3318d](https://github.com/ionic-team/ionic-framework/commit/3b3318da513b199128f3822bd8226797cd118b0f))
* **tab-bar:** prevent keyboard controller memory leak on rapid mount/unmount ([#30906](https://github.com/ionic-team/ionic-framework/issues/30906)) ([f99d000](https://github.com/ionic-team/ionic-framework/commit/f99d0007a8ffc9c7d3d2636e912c37c12112b21d))
## [8.7.16](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.16) (2025-12-31)
### Bug Fixes
* **modal:** prevent card modal animation on viewport resize when modal is closed ([#30894](https://github.com/ionic-team/ionic-framework/issues/30894)) ([e5634d4](https://github.com/ionic-team/ionic-framework/commit/e5634d45ee5fd32715f6e6b75e0448f74ee1f8f2)), closes [#30679](https://github.com/ionic-team/ionic-framework/issues/30679)
## [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)
### Bug Fixes
* **tabs:** select correct tab when routes have similar prefixes ([#30863](https://github.com/ionic-team/ionic-framework/issues/30863)) ([03fb422](https://github.com/ionic-team/ionic-framework/commit/03fb422bfa775e3e9dd695ea1857fa88d4245ecd)), closes [#30448](https://github.com/ionic-team/ionic-framework/issues/30448)
## [8.7.13](https://github.com/ionic-team/ionic-framework/compare/v8.7.12...v8.7.13) (2025-12-13)
**Note:** Version bump only for package ionic-framework
## [8.7.12](https://github.com/ionic-team/ionic-framework/compare/v8.7.11...v8.7.12) (2025-12-10)

View File

@@ -3,6 +3,60 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [8.7.17](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.17) (2026-01-14)
### Bug Fixes
* **input:** prevent Android TalkBack from focusing label separately ([#30895](https://github.com/ionic-team/ionic-framework/issues/30895)) ([ab733b7](https://github.com/ionic-team/ionic-framework/commit/ab733b71dd355d9486757f219fe09acaefeeefcc))
* **input:** prevent placeholder from overlapping start slot during scroll assist ([#30896](https://github.com/ionic-team/ionic-framework/issues/30896)) ([3b3318d](https://github.com/ionic-team/ionic-framework/commit/3b3318da513b199128f3822bd8226797cd118b0f))
* **tab-bar:** prevent keyboard controller memory leak on rapid mount/unmount ([#30906](https://github.com/ionic-team/ionic-framework/issues/30906)) ([f99d000](https://github.com/ionic-team/ionic-framework/commit/f99d0007a8ffc9c7d3d2636e912c37c12112b21d))
## [8.7.16](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.16) (2025-12-31)
### Bug Fixes
* **modal:** prevent card modal animation on viewport resize when modal is closed ([#30894](https://github.com/ionic-team/ionic-framework/issues/30894)) ([e5634d4](https://github.com/ionic-team/ionic-framework/commit/e5634d45ee5fd32715f6e6b75e0448f74ee1f8f2)), closes [#30679](https://github.com/ionic-team/ionic-framework/issues/30679)
## [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
## [8.7.13](https://github.com/ionic-team/ionic-framework/compare/v8.7.12...v8.7.13) (2025-12-13)
**Note:** Version bump only for package @ionic/core
## [8.7.12](https://github.com/ionic-team/ionic-framework/compare/v8.7.11...v8.7.12) (2025-12-10)

59
core/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@ionic/core",
"version": "8.7.12",
"version": "8.7.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ionic/core",
"version": "8.7.12",
"version": "8.7.17",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
@@ -15,11 +15,10 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.11.0",
"@capacitor/core": "^7.0.0",
"@capacitor/haptics": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
"@capacitor/status-bar": "^7.0.0",
"@clack/prompts": "^0.11.0",
"@capacitor/core": "^8.0.0",
"@capacitor/haptics": "^8.0.0",
"@capacitor/keyboard": "^8.0.0",
"@capacitor/status-bar": "^8.0.0",
"@ionic/eslint-config": "^0.3.0",
"@ionic/prettier-config": "^2.0.0",
"@playwright/test": "^1.56.1",
@@ -50,6 +49,9 @@
"serve": "^14.0.1",
"stylelint": "^13.13.1",
"stylelint-order": "^4.1.0"
},
"engines": {
"node": ">= 16"
}
},
"custom-rules": {
@@ -626,7 +628,9 @@
"license": "MIT"
},
"node_modules/@capacitor/core": {
"version": "7.4.4",
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.0.2.tgz",
"integrity": "sha512-EXZfxkL6GFJS2cb7TIBR7RiHA5iz6ufDcl1VmUpI2pga3lJ5Ck2+iqbx7N+osL3XYem9ad4XCidJEMm64DX6UQ==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -635,46 +639,33 @@
}
},
"node_modules/@capacitor/haptics": {
"version": "7.0.2",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.0.tgz",
"integrity": "sha512-DY1IUOjke1T4ITl7mFHQIKCaJJyHYAYRYHG9bVApU7PDOZiMVGMp48Yjzdqjya+wv/AHS5mDabSTUmhJ5uDvBA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/keyboard": {
"version": "7.0.3",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.0.tgz",
"integrity": "sha512-ycPW6iQyFwzDK95jihesj5EGiyyGSfbBqNek11iNp9tBOB7zDeYkUA2S/vPpOETt3dhP6pWr7a9gNVGuEfj11g==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/status-bar": {
"version": "7.0.3",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.0.tgz",
"integrity": "sha512-aIj3bc7z8lfPgOen8HlrBrkfnxpFnh21OCx6jCUx4Mvv+B6eEkUQ49b32DOddgVfr+igRHLX2SYi7duqIsNDXg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@clack/core": {
"version": "0.5.0",
"dev": true,
"license": "MIT",
"dependencies": {
"picocolors": "^1.0.0",
"sisteransi": "^1.0.5"
}
},
"node_modules/@clack/prompts": {
"version": "0.11.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@clack/core": "0.5.0",
"picocolors": "^1.0.0",
"sisteransi": "^1.0.5"
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@eslint-community/eslint-utils": {
@@ -9823,4 +9814,4 @@
}
}
}
}
}

View File

@@ -1,9 +1,9 @@
{
"name": "@ionic/core",
"version": "8.7.12",
"version": "8.7.17",
"description": "Base components for Ionic",
"engines": {
"node": "24.x"
"node": ">= 16"
},
"keywords": [
"ionic",
@@ -40,11 +40,10 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.11.0",
"@capacitor/core": "^7.0.0",
"@capacitor/haptics": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
"@capacitor/status-bar": "^7.0.0",
"@clack/prompts": "^0.11.0",
"@capacitor/core": "^8.0.0",
"@capacitor/haptics": "^8.0.0",
"@capacitor/keyboard": "^8.0.0",
"@capacitor/status-bar": "^8.0.0",
"@ionic/eslint-config": "^0.3.0",
"@ionic/prettier-config": "^2.0.0",
"@playwright/test": "^1.56.1",
@@ -104,8 +103,7 @@
"docker.build": "docker build -t ionic-playwright .",
"test.e2e.docker": "npm run docker.build && node ./scripts/docker.mjs",
"test.e2e.docker.update-snapshots": "npm run test.e2e.docker -- --update-snapshots='changed'",
"test.e2e.docker.ci": "npm run docker.build && CI=true node ./scripts/docker.mjs",
"test.e2e.script": "node scripts/testing/e2e-script.mjs"
"test.e2e.docker.ci": "npm run docker.build && CI=true node ./scripts/docker.mjs"
},
"author": "Ionic Team",
"license": "MIT",

View File

@@ -1,260 +0,0 @@
// The purpose of this script is to provide a way run the E2E tests
// without having the developer to manually run multiple commands based
// on the desired end result.
// E.g. update the local ground truths for a specific component or
// open the Playwright report after running the E2E tests.
import {
intro,
outro,
confirm,
spinner,
isCancel,
cancel,
text,
log,
} from '@clack/prompts';
import { exec, spawn } from 'child_process';
import fs from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';
import util from 'node:util';
import color from 'picocolors';
async function main() {
const execAsync = util.promisify(exec);
const cleanUpFiles = async () => {
// Clean up the local ground truths.
const cleanUp = spinner();
// Inform the user that the local ground truths are being cleaned up.
cleanUp.start('Restoring local ground truths');
// Reset the local ground truths.
await execAsync('git reset -- src/**/*-linux.png').catch((error) => {
cleanUp.stop('Failed to reset local ground truths');
console.error(error);
return process.exit(0);
});
// Restore the local ground truths.
await execAsync('git restore -- src/**/*-linux.png').catch((error) => {
cleanUp.stop('Failed to restore local ground truths');
console.error(error);
return process.exit(0);
});
// Inform the user that the local ground truths have been cleaned up.
cleanUp.stop('Local ground truths have been restored to their original state in order to avoid committing them.');
};
intro(color.inverse(' Update Local Ground Truths'));
// Ask user for the component name they want to test.
const componentValue = await text({
message: 'Enter the component or path you want to test (e.g. chip, src/components/chip)',
placeholder: 'Empty for all components',
});
// User cancelled the operation with `Ctrl+C` or `CMD+C`.
if (isCancel(componentValue)) {
cancel('Operation cancelled');
return process.exit(0);
}
// Ask user if they want to update their local ground truths.
const shouldUpdateTruths = await confirm({
message: 'Do you want to update your local ground truths?',
});
// User cancelled the operation with `Ctrl+C` or `CMD+C`.
if (isCancel(shouldUpdateTruths)) {
cancel('Operation cancelled');
return process.exit(0);
}
if (shouldUpdateTruths) {
const defaultBaseBranch = 'main';
// Ask user for the base branch.
let baseBranch = await text({
message: 'Enter the base branch name:',
placeholder: `default: ${defaultBaseBranch}`,
})
// User cancelled the operation with `Ctrl+C` or `CMD+C`.
if (isCancel(baseBranch)) {
cancel('Operation cancelled');
return process.exit(0);
}
// User didn't provide a base branch.
if (!baseBranch) {
baseBranch = defaultBaseBranch;
}
/**
* The provided base branch needs to be fetched.
* This ensures that the local base branch is up-to-date with the
* remote base branch. Otherwise, there might be errors stating that
* certain files don't exist in the local base branch.
*/
const fetchBaseBranch = spinner();
// Inform the user that the base branch is being fetched.
fetchBaseBranch.start(`Fetching "${baseBranch}" to have the latest changes`);
// Fetch the base branch.
await execAsync(`git fetch origin ${baseBranch}`).catch((error) => {
fetchBaseBranch.stop(`Failed to fetch "${baseBranch}"`);
console.error(error);
return process.exit(0);
});
// Inform the user that the base branch has been fetched.
fetchBaseBranch.stop(`Fetched "${baseBranch}"`);
const updateGroundTruth = spinner();
// Inform the user that the local ground truths are being updated.
updateGroundTruth.start('Updating local ground truths');
// Check if user provided an existing file or directory.
const isValidLocation = fs.existsSync(componentValue);
// User provided an existing file or directory.
if (isValidLocation) {
const stats = fs.statSync(componentValue);
// User provided a file as the component.
// ex: `componentValue` = `src/components/chip/test/basic/chip.e2e.ts`
if (stats.isFile()) {
// Update the local ground truths for the provided path.
await execAsync(`git checkout origin/${baseBranch} -- ${componentValue}-snapshots/*-linux.png`).catch((error) => {
updateGroundTruth.stop('Failed to update local ground truths');
console.error(error);
return process.exit(0);
});
}
// User provided a directory as the component.
// ex: `componentValue` = `src/components/chip`
if (stats.isDirectory()) {
// Update the local ground truths for the provided directory.
await execAsync(`git checkout origin/${baseBranch} -- ${componentValue}/test/*/*.e2e.ts-snapshots/*-linux.png`).catch((error) => {
updateGroundTruth.stop('Failed to update local ground truths');
console.error(error);
return process.exit(0);
});
}
}
// User provided a component name as the component.
// ex: `componentValue` = `chip`
else if (componentValue) {
// Update the local ground truths for the provided component.
await execAsync(`git checkout origin/${baseBranch} -- src/components/${componentValue}/test/*/${componentValue}.e2e.ts-snapshots/*-linux.png`).catch((error) => {
updateGroundTruth.stop('Failed to update local ground truths');
console.error(error);
return process.exit(0);
});
}
// User provided an empty string.
else {
// Update the local ground truths for all components.
await execAsync(`git checkout origin/${baseBranch} -- src/components/*/test/*/*.e2e.ts-snapshots/*-linux.png`).catch((error) => {
updateGroundTruth.stop('Failed to update local ground truths');
console.error(error);
return process.exit(0);
});
}
// Inform the user that the local ground truths have been updated.
updateGroundTruth.stop('Updated local ground truths');
}
const buildCore = spinner();
// Inform the user that the core is being built.
buildCore.start('Building core');
/**
* Build core
* Otherwise, the uncommitted changes will not be reflected in the tests because:
* - popping the stash doesn't trigger a re-render even if `npm start` is running
* - app is not running the `npm start` command
*/
await execAsync('npm run build').catch((error) => {
// Clean up the local ground truths.
cleanUpFiles();
buildCore.stop('Failed to build core');
console.error(error);
return process.exit(0);
});
buildCore.stop('Built core');
const runE2ETests = spinner();
// Inform the user that the E2E tests are being run.
runE2ETests.start('Running E2E tests');
// User provided a component value.
if (componentValue) {
await execAsync(`npm run test.e2e.docker.ci ${componentValue}`).catch((error) => {
// Clean up the local ground truths.
cleanUpFiles();
runE2ETests.stop('Failed to run E2E tests');
console.error(error);
return process.exit(0);
});
} else {
await execAsync('npm run test.e2e.docker.ci').catch((error) => {
// Clean up the local ground truths.
cleanUpFiles();
runE2ETests.stop('Failed to run E2E tests');
console.error(error);
return process.exit(0);
});
}
runE2ETests.stop('Ran E2E tests');
// Clean up the local ground truths.
await cleanUpFiles();
// Ask user if they want to open the Playwright report.
const shouldOpenReport = await confirm({
message: 'Do you want to open the Playwright report?',
});
// User cancelled the operation with `Ctrl+C` or `CMD+C`.
if (isCancel(shouldOpenReport)) {
cancel('Operation cancelled');
return process.exit(0);
}
// User chose to open the Playwright report.
if (shouldOpenReport) {
// Use spawn to display the server information and the key to quit the server.
spawn('npx', ['playwright', 'show-report'], {
stdio: 'inherit',
});
} else {
// Inform the user that the Playwright report can be opened by running the following command.
log.info('If you change your mind, you can open the Playwright report by running the following command:');
log.info(color.bold('npx playwright show-report'));
}
if (shouldOpenReport) {
outro("You're all set! Don't forget to quit serving the Playwright report when you're done.");
} else {
outro("You're all set!");
}
await sleep(1000);
}
main().catch(console.error);

View File

@@ -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.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 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.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');
});
});
});
});

View File

@@ -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,

View File

@@ -22,6 +22,7 @@ export class Footer implements ComponentInterface {
private scrollEl?: HTMLElement;
private contentScrollCallback?: () => void;
private keyboardCtrl: KeyboardController | null = null;
private keyboardCtrlPromise: Promise<KeyboardController> | null = null;
@State() private keyboardVisible = false;
@@ -52,7 +53,7 @@ export class Footer implements ComponentInterface {
}
async connectedCallback() {
this.keyboardCtrl = await createKeyboardController(async (keyboardOpen, waitForResize) => {
const promise = createKeyboardController(async (keyboardOpen, waitForResize) => {
/**
* If the keyboard is hiding, then we need to wait
* for the webview to resize. Otherwise, the footer
@@ -64,11 +65,32 @@ export class Footer implements ComponentInterface {
this.keyboardVisible = keyboardOpen; // trigger re-render by updating state
});
this.keyboardCtrlPromise = promise;
const keyboardCtrl = await promise;
/**
* Only assign if this is still the current promise.
* Otherwise, a new connectedCallback has started or
* disconnectedCallback was called, so destroy this instance.
*/
if (this.keyboardCtrlPromise === promise) {
this.keyboardCtrl = keyboardCtrl;
this.keyboardCtrlPromise = null;
} else {
keyboardCtrl.destroy();
}
}
disconnectedCallback() {
if (this.keyboardCtrlPromise) {
this.keyboardCtrlPromise.then((ctrl) => ctrl.destroy());
this.keyboardCtrlPromise = null;
}
if (this.keyboardCtrl) {
this.keyboardCtrl.destroy();
this.keyboardCtrl = null;
}
}

View File

@@ -8,7 +8,7 @@
box-shadow: $header-md-box-shadow;
}
.header-collapse-condense {
.header-md.header-collapse-condense {
display: none;
}

View File

@@ -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');
});
});
});

View File

@@ -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) => {
/**

View File

@@ -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">
@@ -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">
@@ -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');
});
});
});

View File

@@ -165,9 +165,13 @@
// otherwise the .input-cover will not be rendered at all
// The input cover is not clickable when the input is disabled
.cloned-input {
@include position(0, null, 0, 0);
position: absolute;
top: 0;
bottom: 0;
// Reset height since absolute positioning with top/bottom handles sizing
height: auto;
max-height: none;
pointer-events: none;
}

View File

@@ -48,6 +48,7 @@ export class Input implements ComponentInterface {
private inputId = `ion-input-${inputIds++}`;
private helperTextId = `${this.inputId}-helper-text`;
private errorTextId = `${this.inputId}-error-text`;
private labelTextId = `${this.inputId}-label`;
private inheritedAttributes: Attributes = {};
private isComposing = false;
private slotMutationController?: SlotMutationController;
@@ -406,7 +407,12 @@ export class Input implements ComponentInterface {
connectedCallback() {
const { el } = this;
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => {
this.setSlottedLabelId();
forceUpdate(this);
});
this.setSlottedLabelId();
this.notchController = createNotchController(
el,
() => this.notchSpacerEl,
@@ -721,7 +727,7 @@ export class Input implements ComponentInterface {
}
private renderLabel() {
const { label } = this;
const { label, labelTextId } = this;
return (
<div
@@ -729,8 +735,17 @@ export class Input implements ComponentInterface {
'label-text-wrapper': true,
'label-text-wrapper-hidden': !this.hasLabel,
}}
// Prevents Android TalkBack from focusing the label separately.
// The input remains labelled via aria-labelledby.
aria-hidden={this.hasLabel ? 'true' : null}
>
{label === undefined ? <slot name="label"></slot> : <div class="label-text">{label}</div>}
{label === undefined ? (
<slot name="label"></slot>
) : (
<div class="label-text" id={labelTextId}>
{label}
</div>
)}
</div>
);
}
@@ -743,6 +758,33 @@ export class Input implements ComponentInterface {
return this.el.querySelector('[slot="label"]');
}
/**
* Ensures the slotted label element has an ID for aria-labelledby.
* If no ID exists, we assign one using our generated labelTextId.
*/
private setSlottedLabelId() {
const slottedLabel = this.labelSlot;
if (slottedLabel && !slottedLabel.id) {
slottedLabel.id = this.labelTextId;
}
}
/**
* Returns the ID to use for aria-labelledby on the native input,
* or undefined if aria-label is explicitly set (to avoid conflicts).
*/
private getLabelledById(): string | undefined {
if (this.inheritedAttributes['aria-label']) {
return undefined;
}
if (this.label !== undefined) {
return this.labelTextId;
}
return this.labelSlot?.id || undefined;
}
/**
* Returns `true` if label content is provided
* either by a prop or a content. If you want
@@ -898,6 +940,7 @@ export class Input implements ComponentInterface {
onCompositionend={this.onCompositionEnd}
aria-describedby={this.getHintTextID()}
aria-invalid={this.isInvalid ? 'true' : undefined}
aria-labelledby={this.getLabelledById()}
{...this.inheritedAttributes}
/>
{this.clearInput && !readonly && !disabled && (

View File

@@ -57,6 +57,104 @@ configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title,
});
});
configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => {
test.describe(title('input: label a11y for Android TalkBack'), () => {
/**
* Android TalkBack treats visible text elements as separate focusable items.
* These tests verify that the label is hidden from a11y tree (aria-hidden)
* while remaining associated with the input via aria-labelledby.
*/
test('label text wrapper should be hidden from accessibility tree when using label prop', async ({ page }) => {
await page.setContent(
`
<ion-input label="Email" value="test@example.com"></ion-input>
`,
config
);
const labelTextWrapper = page.locator('ion-input .label-text-wrapper');
await expect(labelTextWrapper).toHaveAttribute('aria-hidden', 'true');
});
test('label text wrapper should be hidden from accessibility tree when using label slot', async ({ page }) => {
await page.setContent(
`
<ion-input value="test@example.com">
<div slot="label">Email</div>
</ion-input>
`,
config
);
const labelTextWrapper = page.locator('ion-input .label-text-wrapper');
await expect(labelTextWrapper).toHaveAttribute('aria-hidden', 'true');
});
test('native input should have aria-labelledby pointing to label text when using label prop', async ({ page }) => {
await page.setContent(
`
<ion-input label="Email" value="test@example.com"></ion-input>
`,
config
);
const nativeInput = page.locator('ion-input input');
const labelText = page.locator('ion-input .label-text');
const labelTextId = await labelText.getAttribute('id');
expect(labelTextId).not.toBeNull();
await expect(nativeInput).toHaveAttribute('aria-labelledby', labelTextId!);
});
test('native input should have aria-labelledby pointing to slotted label when using label slot', async ({
page,
}) => {
await page.setContent(
`
<ion-input value="test@example.com">
<div slot="label">Email</div>
</ion-input>
`,
config
);
const nativeInput = page.locator('ion-input input');
const slottedLabel = page.locator('ion-input [slot="label"]');
const slottedLabelId = await slottedLabel.getAttribute('id');
expect(slottedLabelId).not.toBeNull();
await expect(nativeInput).toHaveAttribute('aria-labelledby', slottedLabelId!);
});
test('should not add aria-labelledby when aria-label is provided on host', async ({ page }) => {
await page.setContent(
`
<ion-input aria-label="Custom Label" value="test@example.com"></ion-input>
`,
config
);
const nativeInput = page.locator('ion-input input');
await expect(nativeInput).toHaveAttribute('aria-label', 'Custom Label');
await expect(nativeInput).not.toHaveAttribute('aria-labelledby');
});
test('should not add aria-hidden to label wrapper when no label is present', async ({ page }) => {
await page.setContent(
`
<ion-input aria-label="Hidden Label" value="test@example.com"></ion-input>
`,
config
);
const labelTextWrapper = page.locator('ion-input .label-text-wrapper');
await expect(labelTextWrapper).not.toHaveAttribute('aria-hidden', 'true');
});
});
});
configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => {
test.describe(title('input: font scaling'), () => {
test('should scale text on larger font sizes', async ({ page }) => {

View File

@@ -52,7 +52,8 @@ export const createSheetGesture = (
expandToScroll: boolean,
getCurrentBreakpoint: () => number,
onDismiss: () => void,
onBreakpointChange: (breakpoint: number) => void
onBreakpointChange: (breakpoint: number) => void,
onGestureMove?: () => void
) => {
// Defaults for the sheet swipe animation
const defaultBackdrop = [
@@ -423,6 +424,9 @@ export const createSheetGesture = (
offset = clamp(0.0001, processedStep, maxStep);
animation.progressStep(offset);
// Notify modal of position change for safe-area updates
onGestureMove?.();
};
const onEnd = (detail: GestureDetail) => {

View File

@@ -20,7 +20,8 @@ export const createSwipeToCloseGesture = (
el: HTMLIonModalElement,
animation: Animation,
statusBarStyle: StatusBarStyle,
onDismiss: () => void
onDismiss: () => void,
onGestureMove?: () => void
) => {
/**
* The step value at which a card modal
@@ -199,6 +200,9 @@ export const createSwipeToCloseGesture = (
animation.progressStep(clampedStep);
// Notify modal of position change for safe-area updates
onGestureMove?.();
/**
* When swiping down half way, the status bar style
* should be reset to its default value.

View File

@@ -94,10 +94,6 @@ ion-backdrop {
:host {
--width: #{$modal-inset-width};
--height: #{$modal-inset-height-small};
--ion-safe-area-top: 0px;
--ion-safe-area-bottom: 0px;
--ion-safe-area-right: 0px;
--ion-safe-area-left: 0px;
}
}

View File

@@ -1,5 +1,6 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h, writeTask } from '@stencil/core';
import { win } from '@utils/browser';
import { findIonContent, printIonContentErrorMsg } from '@utils/content';
import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework-delegate';
import { raf, inheritAttributes, hasLazyBuild, getElementRoot } from '@utils/helpers';
@@ -74,6 +75,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
@State() private isSheetModal = false;
private currentBreakpoint?: number;
private wrapperEl?: HTMLElement;
private shadowEl?: HTMLElement;
private backdropEl?: HTMLIonBackdropElement;
private dragHandleEl?: HTMLButtonElement;
private sortedBreakpoints?: number[];
@@ -98,10 +100,18 @@ export class Modal implements ComponentInterface, OverlayInterface {
// Mutation observer to watch for parent removal
private parentRemovalObserver?: MutationObserver;
// Watches for dynamic footer additions/removals to update safe-area padding
private footerObserver?: MutationObserver;
// Cached original parent from before modal is moved to body during presentation
private cachedOriginalParent?: HTMLElement;
// Cached ion-page ancestor for child route passthrough
private cachedPageParent?: HTMLElement | null;
// Whether to skip coordinate-based safe-area detection (for fullscreen phone modals)
private skipSafeAreaCoordinateDetection = false;
// Cached safe-area values to avoid getComputedStyle calls during gestures
private cachedSafeAreas?: { top: number; bottom: number; left: number; right: number };
// Track previous safe-area state to avoid redundant DOM writes
private prevSafeAreaState = { top: false, bottom: false, left: false, right: false };
lastFocus?: HTMLElement;
animation?: Animation;
@@ -276,7 +286,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
@Listen('resize', { target: 'window' })
onWindowResize() {
// Only handle resize for iOS card modals when no custom animations are provided
// Invalidate safe-area cache on resize (device rotation may change values)
this.cachedSafeAreas = undefined;
this.updateSafeAreaOverrides();
// Only handle view transition for iOS card modals when no custom animations are provided
if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
return;
}
@@ -406,6 +420,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.triggerController.removeClickListener();
this.cleanupViewTransitionListener();
this.cleanupParentRemovalObserver();
// Reset safe-area state to handle removal without dismiss (e.g., framework unmount)
this.resetSafeAreaState();
}
componentWillLoad() {
@@ -592,6 +608,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
await waitForMount();
}
// Predict safe-area needs based on modal configuration to avoid visual snap
this.setInitialSafeAreaOverrides(presentingElement);
writeTask(() => this.el.classList.add('show-modal'));
const hasCardModal = presentingElement !== undefined;
@@ -659,6 +678,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.initSwipeToClose();
}
// Now that animation is complete, update safe-area based on actual position
this.updateSafeAreaOverrides();
// Initialize view transition listener for iOS card modals
this.initViewTransitionListener();
@@ -692,33 +714,39 @@ export class Modal implements ComponentInterface, OverlayInterface {
const statusBarStyle = this.statusBarStyle ?? StatusBarStyle.Default;
this.gesture = createSwipeToCloseGesture(el, ani, statusBarStyle, () => {
/**
* While the gesture animation is finishing
* it is possible for a user to tap the backdrop.
* This would result in the dismiss animation
* being played again. Typically this is avoided
* by setting `presented = false` on the overlay
* component; however, we cannot do that here as
* that would prevent the element from being
* removed from the DOM.
*/
this.gestureAnimationDismissing = true;
this.gesture = createSwipeToCloseGesture(
el,
ani,
statusBarStyle,
() => {
/**
* While the gesture animation is finishing
* it is possible for a user to tap the backdrop.
* This would result in the dismiss animation
* being played again. Typically this is avoided
* by setting `presented = false` on the overlay
* component; however, we cannot do that here as
* that would prevent the element from being
* removed from the DOM.
*/
this.gestureAnimationDismissing = true;
/**
* Reset the status bar style as the dismiss animation
* starts otherwise the status bar will be the wrong
* color for the duration of the dismiss animation.
* The dismiss method does this as well, but
* in this case it's only called once the animation
* has finished.
*/
setCardStatusBarDefault(this.statusBarStyle);
this.animation!.onFinish(async () => {
await this.dismiss(undefined, GESTURE);
this.gestureAnimationDismissing = false;
});
});
/**
* Reset the status bar style as the dismiss animation
* starts otherwise the status bar will be the wrong
* color for the duration of the dismiss animation.
* The dismiss method does this as well, but
* in this case it's only called once the animation
* has finished.
*/
setCardStatusBarDefault(this.statusBarStyle);
this.animation!.onFinish(async () => {
await this.dismiss(undefined, GESTURE);
this.gestureAnimationDismissing = false;
});
},
() => this.updateSafeAreaOverrides()
);
this.gesture.enable(true);
}
@@ -755,7 +783,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.currentBreakpoint = breakpoint;
this.ionBreakpointDidChange.emit({ breakpoint });
}
}
this.updateSafeAreaOverrides();
},
() => this.updateSafeAreaOverrides()
);
this.gesture = gesture;
@@ -849,6 +879,212 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.cachedPageParent = undefined;
}
/**
* Sets initial safe-area overrides based on modal configuration before
* the modal becomes visible. This predicts whether the modal will touch
* screen edges to avoid a visual snap after animation completes.
*/
private setInitialSafeAreaOverrides(presentingElement: HTMLElement | undefined) {
const style = this.el.style;
const mode = getIonMode(this);
const isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined;
// Card modals only exist in iOS mode - in MD mode, presentingElement is ignored
const isCardModal = presentingElement !== undefined && mode === 'ios';
const isTablet = window.innerWidth >= 768;
// Sheet modals always touch bottom edge, never top/left/right
if (isSheetModal) {
style.setProperty('--ion-safe-area-top', '0px');
style.setProperty('--ion-safe-area-left', '0px');
style.setProperty('--ion-safe-area-right', '0px');
return;
}
// Card modals have rounded top corners
if (isCardModal) {
style.setProperty('--ion-safe-area-top', '0px');
if (isTablet) {
// On tablets, card modals are inset from all edges
this.zeroAllSafeAreas();
} else {
// On phones, card modals still extend to the bottom edge
style.setProperty('--ion-safe-area-left', '0px');
style.setProperty('--ion-safe-area-right', '0px');
this.applyFullscreenSafeArea();
}
return;
}
// Check if modal is fullscreen via CSS custom properties
// This applies to both phone and tablet sizes - custom modals may have
// non-fullscreen dimensions even on phones (e.g., --height: 70%)
const computedStyle = getComputedStyle(this.el);
const width = computedStyle.getPropertyValue('--width').trim();
const height = computedStyle.getPropertyValue('--height').trim();
const isFullscreen = width === '100%' && height === '100%';
if (isFullscreen) {
this.applyFullscreenSafeArea();
} else if (isTablet) {
// Centered dialog on tablet doesn't touch edges
this.zeroAllSafeAreas();
} else {
// Non-fullscreen modal on phone - use coordinate-based detection
// to determine which edges it touches (e.g., bottom-aligned custom modals)
}
}
/**
* Applies safe-area handling for fullscreen modals.
* Adds wrapper padding when no footer is present to prevent
* content from overlapping system navigation areas.
*/
private applyFullscreenSafeArea() {
this.skipSafeAreaCoordinateDetection = true;
this.updateFooterPadding();
// Watch for dynamic footer additions/removals (e.g., async data loading)
// Use subtree:true to support wrapped footers in framework components
// (e.g., <my-footer><ion-footer>...</ion-footer></my-footer>)
if (!this.footerObserver && win !== undefined && 'MutationObserver' in win) {
this.footerObserver = new MutationObserver(() => this.updateFooterPadding());
this.footerObserver.observe(this.el, { childList: true, subtree: true });
}
}
/**
* Updates wrapper and shadow padding based on footer presence.
* Called initially and when footer is dynamically added/removed.
* Both elements must be styled identically to prevent visual mismatches.
*/
private updateFooterPadding() {
if (!this.wrapperEl) return;
const hasFooter = this.el.querySelector('ion-footer') !== null;
// Apply to both wrapper and shadow to keep them in sync
const elements = [this.wrapperEl, this.shadowEl].filter(Boolean) as HTMLElement[];
if (hasFooter) {
elements.forEach((el) => {
el.style.removeProperty('padding-bottom');
el.style.removeProperty('box-sizing');
});
} else {
elements.forEach((el) => {
el.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
el.style.setProperty('box-sizing', 'border-box');
});
}
}
/**
* Sets all safe-area CSS variables to 0px for modals that
* don't touch screen edges.
*/
private zeroAllSafeAreas() {
const style = this.el.style;
style.setProperty('--ion-safe-area-top', '0px');
style.setProperty('--ion-safe-area-bottom', '0px');
style.setProperty('--ion-safe-area-left', '0px');
style.setProperty('--ion-safe-area-right', '0px');
}
/**
* Resets all safe-area related state and styles.
* Called during dismiss and disconnectedCallback to ensure clean state
* for re-presentation of inline modals.
*/
private resetSafeAreaState() {
this.skipSafeAreaCoordinateDetection = false;
this.cachedSafeAreas = undefined;
this.prevSafeAreaState = { top: false, bottom: false, left: false, right: false };
this.footerObserver?.disconnect();
this.footerObserver = undefined;
// Clear wrapper and shadow styles that may have been set for safe-area handling
[this.wrapperEl, this.shadowEl].forEach((el) => {
if (el) {
el.style.removeProperty('padding-bottom');
el.style.removeProperty('box-sizing');
}
});
// Clear safe-area CSS variable overrides
const style = this.el.style;
style.removeProperty('--ion-safe-area-top');
style.removeProperty('--ion-safe-area-bottom');
style.removeProperty('--ion-safe-area-left');
style.removeProperty('--ion-safe-area-right');
}
/**
* Gets the root safe-area values from the document element.
* Uses cached values during gestures to avoid getComputedStyle calls.
*/
private getSafeAreaValues(): { top: number; bottom: number; left: number; right: number } {
if (!this.cachedSafeAreas) {
const rootStyle = getComputedStyle(document.documentElement);
this.cachedSafeAreas = {
top: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-top')) || 0,
bottom: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-bottom')) || 0,
left: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-left')) || 0,
right: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-right')) || 0,
};
}
return this.cachedSafeAreas;
}
/**
* Updates safe-area CSS variable overrides based on whether the modal
* extends into each safe-area region. Called after animation
* and during gestures to handle dynamic position changes.
*
* Optimized to avoid redundant DOM writes by tracking previous state.
*/
private updateSafeAreaOverrides() {
if (this.skipSafeAreaCoordinateDetection) {
return;
}
const wrapper = this.wrapperEl;
if (!wrapper) {
return;
}
const rect = wrapper.getBoundingClientRect();
const safeAreas = this.getSafeAreaValues();
const extendsIntoTop = rect.top < safeAreas.top;
const extendsIntoBottom = rect.bottom > window.innerHeight - safeAreas.bottom;
const extendsIntoLeft = rect.left < safeAreas.left;
const extendsIntoRight = rect.right > window.innerWidth - safeAreas.right;
// Only update DOM when state actually changes
const prev = this.prevSafeAreaState;
const style = this.el.style;
if (extendsIntoTop !== prev.top) {
extendsIntoTop ? style.removeProperty('--ion-safe-area-top') : style.setProperty('--ion-safe-area-top', '0px');
prev.top = extendsIntoTop;
}
if (extendsIntoBottom !== prev.bottom) {
extendsIntoBottom
? style.removeProperty('--ion-safe-area-bottom')
: style.setProperty('--ion-safe-area-bottom', '0px');
prev.bottom = extendsIntoBottom;
}
if (extendsIntoLeft !== prev.left) {
extendsIntoLeft ? style.removeProperty('--ion-safe-area-left') : style.setProperty('--ion-safe-area-left', '0px');
prev.left = extendsIntoLeft;
}
if (extendsIntoRight !== prev.right) {
extendsIntoRight
? style.removeProperty('--ion-safe-area-right')
: style.setProperty('--ion-safe-area-right', '0px');
prev.right = extendsIntoRight;
}
}
private sheetOnDismiss() {
/**
* While the gesture animation is finishing
@@ -961,6 +1197,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
this.currentBreakpoint = undefined;
this.animation = undefined;
// Reset safe-area state for potential re-presentation
this.resetSafeAreaState();
unlock();
@@ -1116,6 +1354,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
private handleViewTransition() {
// Only run view transitions when the modal is presented
if (!this.presented) {
return;
}
const isPortrait = window.innerWidth < 768;
// Only transition if view state actually changed
@@ -1380,7 +1623,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
part="backdrop"
/>
{mode === 'ios' && <div class="modal-shadow"></div>}
{mode === 'ios' && <div class="modal-shadow" ref={(el) => (this.shadowEl = el)}></div>}
<div
/*

View File

@@ -0,0 +1,176 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('card modal: viewport resize'), () => {
test.beforeEach(async ({ page }) => {
// Start in portrait mode (mobile)
await page.setViewportSize({ width: 375, height: 667 });
await page.setContent(
`
<ion-app>
<div class="ion-page" id="main-page">
<ion-header>
<ion-toolbar>
<ion-title>Card Viewport Resize Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p>This page tests that viewport resize does not trigger card modal animation when modal is closed.</p>
<ion-button id="open-modal">Open Card Modal</ion-button>
<ion-modal id="card-modal">
<ion-header>
<ion-toolbar>
<ion-title>Card Modal</ion-title>
<ion-buttons slot="end">
<ion-button id="close-modal">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p>Modal content</p>
</ion-content>
</ion-modal>
</ion-content>
</div>
</ion-app>
<script>
const modal = document.querySelector('#card-modal');
const mainPage = document.querySelector('#main-page');
modal.presentingElement = mainPage;
document.querySelector('#open-modal').addEventListener('click', () => {
modal.present();
});
document.querySelector('#close-modal').addEventListener('click', () => {
modal.dismiss();
});
</script>
`,
config
);
});
test('should not animate presenting element when viewport resizes and modal is closed', async ({
page,
}, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30679',
});
const mainPage = page.locator('#main-page');
// Verify the presenting element has no transform initially
const initialTransform = await mainPage.evaluate((el) => {
return window.getComputedStyle(el).transform;
});
expect(initialTransform).toBe('none');
// Resize from portrait to landscape (crossing the 768px threshold)
await page.setViewportSize({ width: 900, height: 375 });
// Wait for the debounced resize handler (50ms) plus some buffer
await page.waitForTimeout(150);
// The presenting element should still have no transform
// If the bug exists, it would have scale(0.93) or similar applied
const afterResizeTransform = await mainPage.evaluate((el) => {
return window.getComputedStyle(el).transform;
});
expect(afterResizeTransform).toBe('none');
});
test('should not animate presenting element when resizing multiple times with modal closed', async ({ page }) => {
const mainPage = page.locator('#main-page');
// Multiple resize cycles should not trigger the animation
for (let i = 0; i < 3; i++) {
// Portrait to landscape
await page.setViewportSize({ width: 900, height: 375 });
await page.waitForTimeout(150);
let transform = await mainPage.evaluate((el) => {
return window.getComputedStyle(el).transform;
});
expect(transform).toBe('none');
// Landscape to portrait
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForTimeout(150);
transform = await mainPage.evaluate((el) => {
return window.getComputedStyle(el).transform;
});
expect(transform).toBe('none');
}
});
test('should still animate presenting element correctly when modal is open and viewport resizes', async ({
page,
}) => {
const mainPage = page.locator('#main-page');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
// Open the modal
await page.click('#open-modal');
await ionModalDidPresent.next();
// When modal is open in portrait, presenting element should be transformed
let transform = await mainPage.evaluate((el) => {
return window.getComputedStyle(el).transform;
});
// The presenting element should have a scale transform when modal is open
expect(transform).not.toBe('none');
// Resize to landscape while modal is open
await page.setViewportSize({ width: 900, height: 375 });
await page.waitForTimeout(150);
// The modal transitions correctly - in landscape mode the presenting element
// should have different (or no) transform than portrait
transform = await mainPage.evaluate((el) => {
return window.getComputedStyle(el).transform;
});
// Note: The exact transform depends on the landscape handling
// The main point is that when modal IS open, the transition should work
// This test just ensures we don't break existing functionality
});
test('presenting element should return to normal after modal is dismissed', async ({ page }) => {
const mainPage = page.locator('#main-page');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
// Open the modal
await page.click('#open-modal');
await ionModalDidPresent.next();
// Close the modal
await page.click('#close-modal');
await ionModalDidDismiss.next();
// Wait for animations to complete
await page.waitForTimeout(500);
// The presenting element should be back to normal
const transform = await mainPage.evaluate((el) => {
return window.getComputedStyle(el).transform;
});
expect(transform).toBe('none');
// Now resize the viewport - should not trigger animation
await page.setViewportSize({ width: 900, height: 375 });
await page.waitForTimeout(150);
const afterResizeTransform = await mainPage.evaluate((el) => {
return window.getComputedStyle(el).transform;
});
expect(afterResizeTransform).toBe('none');
});
});
});

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -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>

View File

@@ -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();
});
});
});
});

View File

@@ -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: () => [
<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);
});
});

View File

@@ -0,0 +1,310 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Modal - Safe Area</title>
<meta
name="viewport"
content="viewport-fit=cover, 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>
<style>
/**
* Simulate safe-area insets for testing.
* Values represent combined scenarios: top/bottom from portrait devices,
* left/right from landscape orientation or devices with side notches.
*/
:root {
--ion-safe-area-top: 44px;
--ion-safe-area-bottom: 34px;
--ion-safe-area-left: 44px;
--ion-safe-area-right: 44px;
}
.fullscreen-modal {
--width: 100%;
--height: 100%;
}
/* Visual indicators for safe areas */
.safe-area-indicator {
position: fixed;
background: rgba(255, 0, 0, 0.2);
pointer-events: none;
z-index: 99999;
}
.safe-area-top {
top: 0;
left: 0;
right: 0;
height: var(--ion-safe-area-top);
}
.safe-area-bottom {
bottom: 0;
left: 0;
right: 0;
height: var(--ion-safe-area-bottom);
}
.safe-area-left {
top: 0;
bottom: 0;
left: 0;
width: var(--ion-safe-area-left);
}
.safe-area-right {
top: 0;
bottom: 0;
right: 0;
width: var(--ion-safe-area-right);
}
</style>
</head>
<body>
<!-- Visual indicators for safe areas (red overlay) -->
<div class="safe-area-indicator safe-area-top"></div>
<div class="safe-area-indicator safe-area-bottom"></div>
<div class="safe-area-indicator safe-area-left"></div>
<div class="safe-area-indicator safe-area-right"></div>
<ion-app>
<div class="ion-page" id="main-page">
<ion-header>
<ion-toolbar>
<ion-title>Modal - Safe Area</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p>Test safe-area handling in modals. Red overlays indicate safe areas (top, bottom, left, right).</p>
<p>
<strong>Landscape simulation:</strong> Left and right safe areas are set to 44px to test devices with side
notches.
</p>
<ion-list>
<ion-item-group>
<ion-item-divider>
<ion-label>With Footer</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>
<h2>Default Modal</h2>
<p>Centered dialog on tablet - should NOT have safe-area padding</p>
</ion-label>
<ion-button slot="end" id="default-modal" onclick="presentDefaultModal()">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Fullscreen Modal</h2>
<p>Full screen - footer handles safe-area</p>
</ion-label>
<ion-button slot="end" id="fullscreen-modal" onclick="presentFullscreenModal()">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Sheet Modal (Partial)</h2>
<p>At 0.5 breakpoint - should have bottom safe-area only</p>
</ion-label>
<ion-button slot="end" id="sheet-modal-partial" onclick="presentSheetModalPartial()"
>Present</ion-button
>
</ion-item>
<ion-item>
<ion-label>
<h2>Sheet Modal (Full)</h2>
<p>At 1.0 breakpoint - should have bottom safe-area</p>
</ion-label>
<ion-button slot="end" id="sheet-modal-full" onclick="presentSheetModalFull()">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Card Modal (iOS)</h2>
<p>Card presentation with presentingElement</p>
</ion-label>
<ion-button slot="end" id="card-modal" onclick="presentCardModal()">Present</ion-button>
</ion-item>
</ion-item-group>
<ion-item-group>
<ion-item-divider>
<ion-label>Without Footer (wrapper padding)</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>
<h2>Fullscreen Modal (no footer)</h2>
<p>Wrapper padding should prevent content overlap</p>
</ion-label>
<ion-button slot="end" id="fullscreen-no-footer" onclick="presentFullscreenNoFooter()"
>Present</ion-button
>
</ion-item>
<ion-item>
<ion-label>
<h2>Card Modal (no footer)</h2>
<p>On phones, wrapper padding should prevent content overlap</p>
</ion-label>
<ion-button slot="end" id="card-modal-no-footer" onclick="presentCardModalNoFooter()"
>Present</ion-button
>
</ion-item>
<ion-item>
<ion-label>
<h2>Default Modal (no footer)</h2>
<p>On phones, wrapper padding should prevent content overlap</p>
</ion-label>
<ion-button slot="end" id="default-no-footer" onclick="presentDefaultNoFooter()">Present</ion-button>
</ion-item>
</ion-item-group>
</ion-list>
</ion-content>
</div>
</ion-app>
<script>
function createModalContent(title, includeFooter = true) {
const element = document.createElement('div');
const footerHtml = includeFooter
? `
<ion-footer>
<ion-toolbar>
<ion-title>Footer</ion-title>
</ion-toolbar>
</ion-footer>
`
: '';
// Create multiple items to ensure scrollable content
const items = Array.from(
{ length: 20 },
(_, i) => `
<ion-item>
<ion-label>Item ${i + 1}</ion-label>
</ion-item>
`
).join('');
element.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-title>${title}</ion-title>
<ion-buttons slot="end">
<ion-button class="dismiss" onclick="dismissModal()">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<h1>Modal Content</h1>
<p>This modal tests safe-area handling.</p>
<ion-list>
${items}
</ion-list>
<p class="last-item">Last item - should not overlap safe area</p>
</ion-content>
${footerHtml}
`;
return element;
}
let currentModal = null;
async function dismissModal() {
if (currentModal) {
await currentModal.dismiss();
currentModal = null;
}
}
async function presentDefaultModal() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Default Modal'),
});
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentFullscreenModal() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Fullscreen Modal'),
});
currentModal.classList.add('fullscreen-modal');
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentSheetModalPartial() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Sheet Modal (Partial)'),
initialBreakpoint: 0.5,
breakpoints: [0, 0.25, 0.5, 0.75, 1],
});
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentSheetModalFull() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Sheet Modal (Full)'),
initialBreakpoint: 1,
breakpoints: [0, 0.5, 1],
});
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentCardModal() {
const presentingElement = document.getElementById('main-page');
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Card Modal'),
presentingElement: presentingElement,
});
document.body.appendChild(currentModal);
await currentModal.present();
}
// Modals without footer - test wrapper padding
async function presentFullscreenNoFooter() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Fullscreen (No Footer)', false),
});
currentModal.classList.add('fullscreen-modal');
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentCardModalNoFooter() {
const presentingElement = document.getElementById('main-page');
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Card Modal (No Footer)', false),
presentingElement: presentingElement,
});
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentDefaultNoFooter() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Default (No Footer)', false),
});
document.body.appendChild(currentModal);
await currentModal.present();
}
</script>
</body>
</html>

View File

@@ -0,0 +1,321 @@
import { expect } from '@playwright/test';
import type { E2EPage } from '@utils/test/playwright';
import { configs, test, Viewports } from '@utils/test/playwright';
/**
* Safe-area tests verify that modals correctly handle safe-area insets
* based on modal type and screen size.
*
* These tests use simulated safe-area values set in index.html:
* - Top: 44px, Bottom: 34px, Left: 44px, Right: 44px
*
* The test HTML includes red visual indicators for all safe areas to
* verify modal content doesn't overlap unsafe regions.
*/
// Helper to get the modal wrapper's computed padding-bottom
async function getWrapperPaddingBottom(page: E2EPage): Promise<string> {
const modal = page.locator('ion-modal');
return modal.evaluate((el: HTMLIonModalElement) => {
const wrapper = el.shadowRoot?.querySelector('.modal-wrapper');
if (!wrapper) return '0px';
return getComputedStyle(wrapper).paddingBottom;
});
}
// Helper to check if modal has a footer
async function modalHasFooter(page: E2EPage): Promise<boolean> {
const modal = page.locator('ion-modal');
return modal.evaluate((el: HTMLIonModalElement) => {
return el.querySelector('ion-footer') !== null;
});
}
// Phone viewport (less than 768px width)
const PhoneViewport = { width: 390, height: 844 };
// =============================================================================
// Phone Tests - Fullscreen modals need wrapper padding when no footer
// =============================================================================
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - phone'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(PhoneViewport);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('fullscreen modal without footer should have wrapper padding', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-no-footer');
await ionModalDidPresent.next();
const hasFooter = await modalHasFooter(page);
expect(hasFooter).toBe(false);
const paddingBottom = await getWrapperPaddingBottom(page);
// Should have safe-area padding (34px as set in test HTML)
expect(paddingBottom).toBe('34px');
});
test('fullscreen modal with footer should not have wrapper padding', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-modal');
await ionModalDidPresent.next();
const hasFooter = await modalHasFooter(page);
expect(hasFooter).toBe(true);
const paddingBottom = await getWrapperPaddingBottom(page);
// Footer handles safe-area, wrapper should have no padding
expect(paddingBottom).toBe('0px');
});
test('default modal without footer should have wrapper padding on phone', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#default-no-footer');
await ionModalDidPresent.next();
// On phones, default modals are fullscreen
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('34px');
});
});
});
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - card modal on phone'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(PhoneViewport);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('card modal without footer should have wrapper padding on phone', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal-no-footer');
await ionModalDidPresent.next();
// Card modals on phones still extend to bottom edge
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('34px');
});
test('card modal with footer should not have wrapper padding', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal');
await ionModalDidPresent.next();
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
});
});
// =============================================================================
// Tablet Tests - Centered dialogs don't need safe-area, fullscreen does
// =============================================================================
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - tablet'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(Viewports.tablet.portrait);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('default modal should not have wrapper padding on tablet', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#default-modal');
await ionModalDidPresent.next();
// Centered dialog on tablet - inset from edges, no padding needed
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
test('fullscreen modal without footer should have wrapper padding on tablet', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-no-footer');
await ionModalDidPresent.next();
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('34px');
});
test('fullscreen modal with footer should not have wrapper padding', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-modal');
await ionModalDidPresent.next();
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
});
});
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - card modal on tablet'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(Viewports.tablet.portrait);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('card modal should not have wrapper padding on tablet', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal');
await ionModalDidPresent.next();
// Card modals on tablets are inset from all edges
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
});
});
// =============================================================================
// Sheet Modal Tests - Always touch bottom edge
// =============================================================================
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - sheet modal'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(Viewports.tablet.portrait);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('sheet modal should not have wrapper padding (footer handles safe-area)', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#sheet-modal-full');
await ionModalDidPresent.next();
// Sheet modals with footer - footer handles the safe area
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
});
});
// Landscape viewport simulates devices with side notches or landscape orientation
const LandscapeViewport = { width: 844, height: 390 };
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('modal: safe-area screenshots'), () => {
test('fullscreen modal should not overlap safe areas in landscape', async ({ page }) => {
await page.setViewportSize(LandscapeViewport);
await page.goto('/src/components/modal/test/safe-area', config);
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-modal');
await ionModalDidPresent.next();
// Red overlays show safe areas - modal content should not overlap them
await expect(page).toHaveScreenshot(screenshot('modal-safe-area-fullscreen-landscape'));
});
test('fullscreen modal without footer should show wrapper padding in landscape', async ({ page }) => {
await page.setViewportSize(LandscapeViewport);
await page.goto('/src/components/modal/test/safe-area', config);
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-no-footer');
await ionModalDidPresent.next();
// Without footer, wrapper padding prevents content from overlapping bottom safe area
await expect(page).toHaveScreenshot(screenshot('modal-safe-area-fullscreen-no-footer-landscape'));
});
});
});
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('modal: safe-area screenshots - tablet'), () => {
test('centered dialog should be inset from all safe areas', async ({ page }) => {
await page.setViewportSize(Viewports.tablet.portrait);
await page.goto('/src/components/modal/test/safe-area', config);
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#default-modal');
await ionModalDidPresent.next();
// Centered dialog should not touch any edges or safe areas
await expect(page).toHaveScreenshot(screenshot('modal-safe-area-centered-tablet'));
});
});
});
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('modal: safe-area screenshots - card modal'), () => {
test('card modal should handle safe areas correctly in landscape', async ({ page }) => {
await page.setViewportSize(LandscapeViewport);
await page.goto('/src/components/modal/test/safe-area', config);
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal');
await ionModalDidPresent.next();
await expect(page).toHaveScreenshot(screenshot('modal-safe-area-card-landscape'));
});
});
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -11,6 +11,12 @@ import {
} from '../utils';
const POPOVER_IOS_BODY_PADDING = 5;
/**
* Extra margin around viewport edges for safe area detection.
* When popover is within this distance of an edge, safe area
* CSS variables will be applied to prevent overlap with system UI.
*/
const POPOVER_IOS_SAFE_AREA_MARGIN = 25;
/**
* iOS Popover Enter Animation
@@ -53,7 +59,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
);
const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING;
const margin = size === 'cover' ? 0 : 25;
const margin = size === 'cover' ? 0 : POPOVER_IOS_SAFE_AREA_MARGIN;
const {
originX,
@@ -61,11 +67,14 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
top,
left,
bottom,
checkSafeAreaTop,
checkSafeAreaBottom,
checkSafeAreaLeft,
checkSafeAreaRight,
arrowTop,
arrowLeft,
addPopoverBottomClass,
isFullyConstrained,
} = calculateWindowAdjustment(
side,
results.top,
@@ -84,8 +93,37 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
arrowHeight
);
/**
* Safe area CSS variable adjustments.
* When the popover is positioned near an edge, we add the corresponding
* safe-area inset to ensure the popover doesn't overlap with system UI
* (status bars, home indicators, navigation bars on Android API 36+, etc.)
*/
const safeAreaTop = ' + var(--ion-safe-area-top, 0px)';
const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0px)';
const safeAreaLeft = ' + var(--ion-safe-area-left, 0px)';
const safeAreaRight = ' - var(--ion-safe-area-right, 0px)';
let topValue = `${top}px`;
let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
let leftValue = `${left}px`;
if (checkSafeAreaTop) {
topValue = `${top}px${safeAreaTop}`;
}
if (checkSafeAreaBottom && bottomValue !== undefined) {
bottomValue = `${bottom}px${safeAreaBottom}`;
}
if (checkSafeAreaLeft) {
leftValue = `${left}px${safeAreaLeft}`;
}
if (checkSafeAreaRight) {
leftValue = `${left}px${safeAreaRight}`;
}
const baseAnimation = createAnimation();
const backdropAnimation = createAnimation();
const arrowAnimation = createAnimation();
const contentAnimation = createAnimation();
backdropAnimation
@@ -100,11 +138,42 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
// The Chromium team stated that this behavior is expected and not a bug. The element animating opacity creates a backdrop root for the backdrop-filter.
// To get around this, instead of animating the wrapper, animate both the arrow and content.
// https://bugs.chromium.org/p/chromium/issues/detail?id=1148826
contentAnimation
.addElement(root.querySelector('.popover-arrow')!)
.addElement(root.querySelector('.popover-content')!)
.fromTo('opacity', 0.01, 1);
// TODO(FW-4376) Ensure that arrow also blurs when translucent
if (arrowEl !== null) {
arrowAnimation.addElement(arrowEl).fromTo('opacity', 0.01, 1);
}
contentAnimation
.addElement(contentEl)
.beforeAddWrite(() => {
contentEl.style.setProperty('top', `calc(${topValue} + var(--offset-y, 0px))`);
contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0px))`);
contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);
if (bottomValue !== undefined) {
contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
/**
* When both top and bottom are explicitly constrained (isFullyConstrained),
* we need to explicitly calculate the height to ensure the popover
* fits within the safe area boundaries.
*
* Using CSS calc with 100vh minus top and bottom values ensures the
* popover height respects both safe areas. We also override max-height
* to prevent it from interfering with the calculated height.
*/
if (isFullyConstrained) {
/**
* Wrap topValue and bottomValue in parentheses to ensure correct
* order of operations in the CSS calc. Without parentheses, the
* safe-area additions would have wrong signs.
*/
const heightCalc = `calc(100vh - (${topValue}) - (${bottomValue}) - var(--offset-y, 0px))`;
contentEl.style.setProperty('height', heightCalc);
contentEl.style.setProperty('max-height', heightCalc);
}
}
})
.fromTo('opacity', 0.01, 1);
return baseAnimation
.easing('ease')
@@ -118,37 +187,21 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
baseEl.classList.add('popover-bottom');
}
if (bottom !== undefined) {
contentEl.style.setProperty('bottom', `${bottom}px`);
}
const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
let leftValue = `${left}px`;
if (checkSafeAreaLeft) {
leftValue = `${left}px${safeAreaLeft}`;
}
if (checkSafeAreaRight) {
leftValue = `${left}px${safeAreaRight}`;
}
contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`);
contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`);
contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);
if (arrowEl !== null) {
const didAdjustBounds = results.top !== top || results.left !== left;
const showArrow = shouldShowArrow(side, didAdjustBounds, ev, trigger);
/**
* Hide the arrow when the popover is fully constrained to the viewport
* because it cannot accurately point to the trigger in this case.
*/
const showArrow = shouldShowArrow(side, didAdjustBounds, ev, trigger) && !isFullyConstrained;
if (showArrow) {
arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0))`);
arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0))`);
arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0px))`);
arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0px))`);
} else {
arrowEl.style.setProperty('display', 'none');
}
}
})
.addAnimation([backdropAnimation, contentAnimation]);
.addAnimation([backdropAnimation, arrowAnimation, contentAnimation]);
};

View File

@@ -5,6 +5,12 @@ import type { Animation } from '../../../interface';
import { calculateWindowAdjustment, getPopoverDimensions, getPopoverPosition } from '../utils';
const POPOVER_MD_BODY_PADDING = 12;
/**
* Extra margin around viewport edges for safe area detection.
* When popover is within this distance of an edge, safe area
* CSS variables will be applied to prevent overlap with system UI.
*/
const POPOVER_MD_SAFE_AREA_MARGIN = 25;
/**
* Md Popover Enter Animation
@@ -47,7 +53,20 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING;
const { originX, originY, top, left, bottom } = calculateWindowAdjustment(
const margin = size === 'cover' ? 0 : POPOVER_MD_SAFE_AREA_MARGIN;
const {
originX,
originY,
top,
left,
bottom,
checkSafeAreaTop,
checkSafeAreaBottom,
checkSafeAreaLeft,
checkSafeAreaRight,
isFullyConstrained,
} = calculateWindowAdjustment(
side,
results.top,
results.left,
@@ -56,12 +75,40 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
bodyHeight,
contentWidth,
contentHeight,
0,
margin,
results.originX,
results.originY,
results.referenceCoordinates
);
/**
* Safe area CSS variable adjustments.
* When the popover is positioned near an edge, we add the corresponding
* safe-area inset to ensure the popover doesn't overlap with system UI
* (status bars, home indicators, navigation bars on Android API 36+, etc.)
*/
const safeAreaTop = ' + var(--ion-safe-area-top, 0)';
const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0)';
const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
let topValue = `${top}px`;
let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
let leftValue = `${left}px`;
if (checkSafeAreaTop) {
topValue = `${top}px${safeAreaTop}`;
}
if (checkSafeAreaBottom && bottomValue !== undefined) {
bottomValue = `${bottom}px${safeAreaBottom}`;
}
if (checkSafeAreaLeft) {
leftValue = `${left}px${safeAreaLeft}`;
}
if (checkSafeAreaRight) {
leftValue = `${left}px${safeAreaRight}`;
}
const baseAnimation = createAnimation();
const backdropAnimation = createAnimation();
const wrapperAnimation = createAnimation();
@@ -81,13 +128,32 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
contentAnimation
.addElement(contentEl)
.beforeStyles({
top: `calc(${top}px + var(--offset-y, 0px))`,
left: `calc(${left}px + var(--offset-x, 0px))`,
top: `calc(${topValue} + var(--offset-y, 0px))`,
left: `calc(${leftValue} + var(--offset-x, 0px))`,
'transform-origin': `${originY} ${originX}`,
})
.beforeAddWrite(() => {
if (bottom !== undefined) {
contentEl.style.setProperty('bottom', `${bottom}px`);
if (bottomValue !== undefined) {
contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
/**
* When both top and bottom are explicitly constrained (isFullyConstrained),
* we need to explicitly calculate the height to ensure the popover
* fits within the safe area boundaries.
*
* Using CSS calc with 100vh minus top and bottom values ensures the
* popover height respects both safe areas. We also override max-height
* to prevent it from interfering with the calculated height.
*/
if (isFullyConstrained) {
/**
* Wrap topValue and bottomValue in parentheses to ensure correct
* order of operations in the CSS calc. Without parentheses, the
* safe-area additions would have wrong signs.
*/
const heightCalc = `calc(100vh - (${topValue}) - (${bottomValue}) - var(--offset-y, 0px))`;
contentEl.style.setProperty('height', heightCalc);
contentEl.style.setProperty('max-height', heightCalc);
}
}
})
.fromTo('transform', 'scale(0.8)', 'scale(1)');

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Popover - Safe Area</title>
<meta
name="viewport"
content="viewport-fit=cover, 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>
<style>
/**
* Simulate safe-area insets for testing.
* These values represent typical Android edge-to-edge safe areas.
* Left/right values simulate landscape orientation or devices with side notches.
*/
:root {
--ion-safe-area-top: 44px;
--ion-safe-area-bottom: 34px;
--ion-safe-area-left: 44px;
--ion-safe-area-right: 44px;
}
/* Visual indicator for safe areas */
.safe-area-indicator {
position: fixed;
background: rgba(255, 0, 0, 0.2);
pointer-events: none;
z-index: 99999;
}
.safe-area-top {
top: 0;
left: 0;
right: 0;
height: var(--ion-safe-area-top);
}
.safe-area-bottom {
bottom: 0;
left: 0;
right: 0;
height: var(--ion-safe-area-bottom);
}
.safe-area-left {
top: 0;
bottom: 0;
left: 0;
width: var(--ion-safe-area-left);
}
.safe-area-right {
top: 0;
bottom: 0;
right: 0;
width: var(--ion-safe-area-right);
}
/* Position triggers at different locations */
.bottom-trigger {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
}
.near-bottom-trigger {
position: fixed;
bottom: 200px;
right: 20px;
}
</style>
</head>
<body>
<ion-app>
<!-- Visual indicators for safe areas -->
<div class="safe-area-indicator safe-area-top"></div>
<div class="safe-area-indicator safe-area-bottom"></div>
<div class="safe-area-indicator safe-area-left"></div>
<div class="safe-area-indicator safe-area-right"></div>
<div class="ion-page" id="main-page">
<ion-header>
<ion-toolbar>
<ion-title>Popover - Safe Area Positioning</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p>Test that popovers are <strong>positioned away from</strong> unsafe areas (shown in red).</p>
<p>The popover should be moved up/down/left/right to avoid overlapping the safe-area zones.</p>
<p>
<strong>Landscape simulation:</strong> Left and right safe areas are set to 44px to test devices with side
notches.
</p>
<ion-list>
<ion-item>
<ion-label>
<h2>Small Popover (Center)</h2>
<p>Floating popover - positioned in center, no adjustment needed</p>
</ion-label>
<ion-button slot="end" id="small-popover-trigger">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Large Popover</h2>
<p>Tall content that may extend toward bottom safe area</p>
</ion-label>
<ion-button slot="end" id="large-popover-trigger">Present</ion-button>
</ion-item>
</ion-list>
<ion-button class="bottom-trigger" id="bottom-trigger"> Trigger Near Bottom </ion-button>
<ion-button class="near-bottom-trigger" id="near-bottom-trigger"> Near Bottom Right </ion-button>
<!-- Small popover -->
<ion-popover trigger="small-popover-trigger" trigger-action="click">
<ion-content class="ion-padding">
<ion-list>
<ion-item><ion-label>Option 1</ion-label></ion-item>
<ion-item><ion-label>Option 2</ion-label></ion-item>
<ion-item><ion-label>Option 3</ion-label></ion-item>
</ion-list>
</ion-content>
</ion-popover>
<!-- Large popover with many items -->
<ion-popover trigger="large-popover-trigger" trigger-action="click">
<ion-content>
<ion-list id="large-list"></ion-list>
</ion-content>
</ion-popover>
<!-- Popover triggered from near bottom -->
<ion-popover trigger="bottom-trigger" trigger-action="click">
<ion-content>
<ion-list id="bottom-list"></ion-list>
</ion-content>
</ion-popover>
<!-- Popover triggered from near bottom right -->
<ion-popover trigger="near-bottom-trigger" trigger-action="click">
<ion-content>
<ion-list id="near-bottom-list"></ion-list>
</ion-content>
</ion-popover>
</ion-content>
</div>
</ion-app>
<script>
// Generate list items for popovers
function generateItems(listId, count) {
const list = document.getElementById(listId);
if (!list) return;
for (let i = 1; i <= count; i++) {
const item = document.createElement('ion-item');
const label = document.createElement('ion-label');
label.textContent = `Item ${i}`;
item.appendChild(label);
list.appendChild(item);
}
}
generateItems('large-list', 15);
generateItems('bottom-list', 10);
generateItems('near-bottom-list', 8);
</script>
</body>
</html>

View File

@@ -0,0 +1,133 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* Safe-area tests verify that popovers are correctly positioned
* to avoid overlapping with safe-area zones (status bars, navigation bars, etc.)
*
* This is especially important for Android API 36+ where edge-to-edge mode
* is enforced and apps can no longer opt out.
*
* The test HTML includes safe-area values (44px top/left/right, 34px bottom)
* and red visual indicators to verify popover positioning.
*/
// Tests that apply to both iOS and MD modes
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('popover: safe-area positioning'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/popover/test/safe-area', config);
});
test('popover pinned to bottom should account for safe-area-bottom in position', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30900',
});
/**
* Use a small viewport to force the popover to be fully constrained.
* The large popover has 15 items (~700px), which will exceed the available
* space in this viewport, causing it to be constrained with both top and
* bottom edges near the safe areas.
*
* A 300px viewport ensures there's not enough space above OR below the
* trigger for the full popover content, triggering the fully constrained path.
*/
await page.setViewportSize({ width: 375, height: 300 });
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
// Click the large popover trigger which has enough content to extend toward the bottom
await page.click('#large-popover-trigger');
await ionPopoverDidPresent.next();
// Target the specific popover that was presented
const popover = page.locator('ion-popover[trigger="large-popover-trigger"]');
const popoverContent = popover.locator('.popover-content');
// Get the computed bottom style - should include safe-area calc
const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom);
// The bottom should include the safe-area-bottom CSS variable
// This ensures the popover is positioned above the unsafe area
expect(bottomStyle).toContain('var(--ion-safe-area-bottom');
});
});
});
// iOS-specific tests
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('popover: safe-area positioning - ios specific'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/popover/test/safe-area', config);
});
test('floating popover should not have safe-area adjustments', async ({ page }) => {
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
await page.click('#small-popover-trigger');
await ionPopoverDidPresent.next();
// Target the specific popover
const popover = page.locator('ion-popover[trigger="small-popover-trigger"]');
const popoverContent = popover.locator('.popover-content');
// Get the computed top and bottom styles
const topStyle = await popoverContent.evaluate((el) => el.style.top);
const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom);
// A floating popover in the middle shouldn't have safe-area adjustments
// The top should be a simple calc without safe-area
expect(topStyle).not.toContain('var(--ion-safe-area-top');
// The bottom should not be set for a floating popover
expect(bottomStyle).toBe('');
});
});
});
// Landscape viewport simulates devices with side notches or landscape orientation
const LandscapeViewport = { width: 844, height: 390 };
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('popover: safe-area screenshots'), () => {
test('popover near bottom should avoid bottom safe area', async ({ page }) => {
await page.setViewportSize(LandscapeViewport);
await page.goto('/src/components/popover/test/safe-area', config);
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
await page.click('#bottom-trigger');
await ionPopoverDidPresent.next();
// Red overlays show safe areas - popover should be positioned to avoid them
await expect(page).toHaveScreenshot(screenshot('popover-safe-area-bottom-landscape'));
});
test('popover near bottom right should avoid right safe area', async ({ page }) => {
await page.setViewportSize(LandscapeViewport);
await page.goto('/src/components/popover/test/safe-area', config);
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
await page.click('#near-bottom-trigger');
await ionPopoverDidPresent.next();
// Popover triggered from near-right edge should account for right safe area
await expect(page).toHaveScreenshot(screenshot('popover-safe-area-right-landscape'));
});
test('large popover should avoid all safe areas', async ({ page }) => {
await page.setViewportSize(LandscapeViewport);
await page.goto('/src/components/popover/test/safe-area', config);
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
await page.click('#large-popover-trigger');
await ionPopoverDidPresent.next();
// Large popover may extend toward edges - should respect safe areas
await expect(page).toHaveScreenshot(screenshot('popover-safe-area-large-landscape'));
});
});
});

Some files were not shown because too many files have changed in this diff Show More