Compare commits

...

22 Commits

Author SHA1 Message Date
ionitron
d7b4d0690b v8.7.17 2026-01-14 18:58:56 +00: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
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
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
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
61 changed files with 1479 additions and 128 deletions

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,6 +3,57 @@
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))
* **modal:** prevent card modal animation on viewport resize when modal is closed ([#30894](https://github.com/ionic-team/ionic-framework/issues/30894)) ([36f4b4d](https://github.com/ionic-team/ionic-framework/commit/36f4b4d600a8d9e53959a24ba51087a0eb587030)), closes [#30679](https://github.com/ionic-team/ionic-framework/issues/30679)
* **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)
* **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

View File

@@ -3,6 +3,53 @@
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))
* **modal:** prevent card modal animation on viewport resize when modal is closed ([#30894](https://github.com/ionic-team/ionic-framework/issues/30894)) ([36f4b4d](https://github.com/ionic-team/ionic-framework/commit/36f4b4d600a8d9e53959a24ba51087a0eb587030)), closes [#30679](https://github.com/ionic-team/ionic-framework/issues/30679)
* **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

View File

@@ -1,12 +1,12 @@
{
"name": "@ionic/core",
"version": "8.7.13",
"version": "8.7.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ionic/core",
"version": "8.7.13",
"version": "8.7.17",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
@@ -9839,4 +9839,4 @@
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@ionic/core",
"version": "8.7.13",
"version": "8.7.17",
"description": "Base components for Ionic",
"engines": {
"node": ">= 16"

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

@@ -1116,6 +1116,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

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

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

@@ -22,6 +22,7 @@ import type { TabBarChangedEventDetail } from './tab-bar-interface';
})
export class TabBar implements ComponentInterface {
private keyboardCtrl: KeyboardController | null = null;
private keyboardCtrlPromise: Promise<KeyboardController> | null = null;
private didLoad = false;
@Element() el!: HTMLElement;
@@ -88,7 +89,7 @@ export class TabBar 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 tab bar
@@ -100,11 +101,32 @@ export class TabBar 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

@@ -205,9 +205,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

@@ -252,10 +252,12 @@ html.plt-ios.plt-hybrid, html.plt-ios.plt-pwa {
@supports (padding-top: env(safe-area-inset-top)) {
html {
--ion-safe-area-top: env(safe-area-inset-top);
--ion-safe-area-bottom: env(safe-area-inset-bottom);
--ion-safe-area-left: env(safe-area-inset-left);
--ion-safe-area-right: env(safe-area-inset-right);
// `--safe-area-inset-*` are set by Capacitor
// @see https://capacitorjs.com/docs/apis/system-bars#android-note
--ion-safe-area-top: var(--safe-area-inset-top, env(safe-area-inset-top));
--ion-safe-area-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
--ion-safe-area-left: var(--safe-area-inset-left, env(safe-area-inset-left));
--ion-safe-area-right: var(--safe-area-inset-right, env(safe-area-inset-right));
}
}

View File

@@ -68,11 +68,26 @@ const addClone = (
if (disabledClonedInput) {
clonedEl.disabled = true;
}
/**
* Position the clone at the same horizontal offset as the native input
* to prevent the placeholder from overlapping start slot content (e.g., icons).
*/
const doc = componentEl.ownerDocument!;
const isRTL = doc.dir === 'rtl';
if (isRTL) {
const parentWidth = (parentEl as HTMLElement).offsetWidth;
const startOffset = parentWidth - inputEl.offsetLeft - inputEl.offsetWidth;
clonedEl.style.insetInlineStart = `${startOffset}px`;
} else {
clonedEl.style.insetInlineStart = `${inputEl.offsetLeft}px`;
}
parentEl.appendChild(clonedEl);
cloneMap.set(componentEl, clonedEl);
const doc = componentEl.ownerDocument!;
const tx = doc.dir === 'rtl' ? 9999 : -9999;
const tx = isRTL ? 9999 : -9999;
componentEl.style.pointerEvents = 'none';
inputEl.style.transform = `translate3d(${tx}px,${inputRelativeY}px,0) scale(0)`;
};

View File

@@ -291,8 +291,14 @@ const jsSetFocus = async (
// give the native text input focus
relocateInput(componentEl, inputEl, false, scrollData.inputSafeY);
// ensure this is the focused input
setManualFocus(inputEl);
/**
* If focus has moved to another element while scroll assist was running,
* don't steal focus back. This prevents focus jumping when users
* quickly switch between inputs or tap other elements.
*/
if (document.activeElement === inputEl) {
setManualFocus(inputEl);
}
/**
* When the input is about to be blurred

View File

@@ -473,7 +473,9 @@ export const getPresentedOverlay = (
id?: string
): HTMLIonOverlayElement | undefined => {
const overlays = getPresentedOverlays(doc, overlayTag);
return id === undefined ? overlays[overlays.length - 1] : overlays.find((o) => o.id === id);
// If no id is provided, return the last presented overlay
// Otherwise, return the last overlay with the given id
return (id === undefined ? overlays : overlays.filter((o: HTMLIonOverlayElement) => o.id === id)).slice(-1)[0];
};
/**

View File

@@ -3,5 +3,5 @@
"core",
"packages/*"
],
"version": "8.7.13"
"version": "8.7.17"
}

View File

@@ -3,6 +3,38 @@
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)
**Note:** Version bump only for package @ionic/angular-server
## [8.7.16](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.16) (2025-12-31)
**Note:** Version bump only for package @ionic/angular-server
## [8.7.15](https://github.com/ionic-team/ionic-framework/compare/v8.7.14...v8.7.15) (2025-12-23)
**Note:** Version bump only for package @ionic/angular-server
## [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/angular-server
## [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/angular-server

View File

@@ -1,15 +1,15 @@
{
"name": "@ionic/angular-server",
"version": "8.7.13",
"version": "8.7.17",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@ionic/angular-server",
"version": "8.7.13",
"version": "8.7.17",
"license": "MIT",
"dependencies": {
"@ionic/core": "^8.7.13"
"@ionic/core": "^8.7.17"
},
"devDependencies": {
"@angular-eslint/eslint-plugin": "^16.0.0",
@@ -1031,9 +1031,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.13",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.13.tgz",
"integrity": "sha512-72sbep6UOiGn+KYKtVSPZhKuq0o68X6mWi5sCyXYE/V1nzUknew9RGohcxbtt5iMVgjuny/m4liIUwVgvvQ5mw==",
"version": "8.7.16",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.16.tgz",
"integrity": "sha512-+vdv/o2Z/2YfoZJIDBLnoh11eJmOOZqQdfwC0zl2MemAVRSofjGuIQlUTZqiUUNht56Rnk9oo53TvmgjNCtmDA==",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
@@ -7309,9 +7309,9 @@
"dev": true
},
"@ionic/core": {
"version": "8.7.13",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.13.tgz",
"integrity": "sha512-72sbep6UOiGn+KYKtVSPZhKuq0o68X6mWi5sCyXYE/V1nzUknew9RGohcxbtt5iMVgjuny/m4liIUwVgvvQ5mw==",
"version": "8.7.16",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.16.tgz",
"integrity": "sha512-+vdv/o2Z/2YfoZJIDBLnoh11eJmOOZqQdfwC0zl2MemAVRSofjGuIQlUTZqiUUNht56Rnk9oo53TvmgjNCtmDA==",
"requires": {
"@stencil/core": "4.38.0",
"ionicons": "^8.0.13",
@@ -11289,4 +11289,4 @@
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@ionic/angular-server",
"version": "8.7.13",
"version": "8.7.17",
"description": "Angular SSR Module for Ionic",
"keywords": [
"ionic",
@@ -62,6 +62,6 @@
},
"prettier": "@ionic/prettier-config",
"dependencies": {
"@ionic/core": "^8.7.13"
"@ionic/core": "^8.7.17"
}
}

View File

@@ -3,6 +3,38 @@
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)
**Note:** Version bump only for package @ionic/angular
## [8.7.16](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.16) (2025-12-31)
**Note:** Version bump only for package @ionic/angular
## [8.7.15](https://github.com/ionic-team/ionic-framework/compare/v8.7.14...v8.7.15) (2025-12-23)
**Note:** Version bump only for package @ionic/angular
## [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/angular
## [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/angular

View File

@@ -1,15 +1,15 @@
{
"name": "@ionic/angular",
"version": "8.7.13",
"version": "8.7.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ionic/angular",
"version": "8.7.13",
"version": "8.7.17",
"license": "MIT",
"dependencies": {
"@ionic/core": "^8.7.13",
"@ionic/core": "^8.7.17",
"ionicons": "^8.0.13",
"jsonc-parser": "^3.0.0",
"tslib": "^2.3.0"
@@ -1398,9 +1398,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.13",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.13.tgz",
"integrity": "sha512-72sbep6UOiGn+KYKtVSPZhKuq0o68X6mWi5sCyXYE/V1nzUknew9RGohcxbtt5iMVgjuny/m4liIUwVgvvQ5mw==",
"version": "8.7.16",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.16.tgz",
"integrity": "sha512-+vdv/o2Z/2YfoZJIDBLnoh11eJmOOZqQdfwC0zl2MemAVRSofjGuIQlUTZqiUUNht56Rnk9oo53TvmgjNCtmDA==",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
@@ -9095,4 +9095,4 @@
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@ionic/angular",
"version": "8.7.13",
"version": "8.7.17",
"description": "Angular specific wrappers for @ionic/core",
"keywords": [
"ionic",
@@ -48,7 +48,7 @@
}
},
"dependencies": {
"@ionic/core": "^8.7.13",
"@ionic/core": "^8.7.17",
"ionicons": "^8.0.13",
"jsonc-parser": "^3.0.0",
"tslib": "^2.3.0"

View File

@@ -3,6 +3,38 @@
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)
**Note:** Version bump only for package @ionic/docs
## [8.7.16](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.16) (2025-12-31)
**Note:** Version bump only for package @ionic/docs
## [8.7.15](https://github.com/ionic-team/ionic-framework/compare/v8.7.14...v8.7.15) (2025-12-23)
**Note:** Version bump only for package @ionic/docs
## [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/docs
## [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/docs

View File

@@ -1,13 +1,13 @@
{
"name": "@ionic/docs",
"version": "8.7.13",
"version": "8.7.17",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@ionic/docs",
"version": "8.7.13",
"version": "8.7.17",
"license": "MIT"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@ionic/docs",
"version": "8.7.13",
"version": "8.7.17",
"description": "Pre-packaged API documentation for the Ionic docs.",
"main": "core.json",
"types": "core.d.ts",

View File

@@ -3,6 +3,38 @@
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)
**Note:** Version bump only for package @ionic/react-router
## [8.7.16](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.16) (2025-12-31)
**Note:** Version bump only for package @ionic/react-router
## [8.7.15](https://github.com/ionic-team/ionic-framework/compare/v8.7.14...v8.7.15) (2025-12-23)
**Note:** Version bump only for package @ionic/react-router
## [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/react-router
## [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/react-router

View File

@@ -1,15 +1,15 @@
{
"name": "@ionic/react-router",
"version": "8.7.13",
"version": "8.7.17",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@ionic/react-router",
"version": "8.7.13",
"version": "8.7.17",
"license": "MIT",
"dependencies": {
"@ionic/react": "^8.7.13",
"@ionic/react": "^8.7.17",
"tslib": "*"
},
"devDependencies": {
@@ -238,9 +238,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.13",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.13.tgz",
"integrity": "sha512-72sbep6UOiGn+KYKtVSPZhKuq0o68X6mWi5sCyXYE/V1nzUknew9RGohcxbtt5iMVgjuny/m4liIUwVgvvQ5mw==",
"version": "8.7.16",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.16.tgz",
"integrity": "sha512-+vdv/o2Z/2YfoZJIDBLnoh11eJmOOZqQdfwC0zl2MemAVRSofjGuIQlUTZqiUUNht56Rnk9oo53TvmgjNCtmDA==",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
@@ -418,12 +418,12 @@
}
},
"node_modules/@ionic/react": {
"version": "8.7.13",
"resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.7.13.tgz",
"integrity": "sha512-PCuIpaurVYxYZ/CoUN3gP56Fwdx+bx78Qy7V5Ac61nGGW7XpVlV4vM9328Kv7OPs5fkmIvKI6LoY78BnjF0PkA==",
"version": "8.7.16",
"resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.7.16.tgz",
"integrity": "sha512-36y+VmtssJ4vfrCJxUEaOo5tFQRP1m87kxVVC6Cc2ctjLQRDEMszG9v3ctzxD+8EszFLMHEmsSTvGGCelDJlvQ==",
"license": "MIT",
"dependencies": {
"@ionic/core": "8.7.13",
"@ionic/core": "8.7.16",
"ionicons": "^8.0.13",
"tslib": "*"
},
@@ -4178,9 +4178,9 @@
"dev": true
},
"@ionic/core": {
"version": "8.7.13",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.13.tgz",
"integrity": "sha512-72sbep6UOiGn+KYKtVSPZhKuq0o68X6mWi5sCyXYE/V1nzUknew9RGohcxbtt5iMVgjuny/m4liIUwVgvvQ5mw==",
"version": "8.7.16",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.16.tgz",
"integrity": "sha512-+vdv/o2Z/2YfoZJIDBLnoh11eJmOOZqQdfwC0zl2MemAVRSofjGuIQlUTZqiUUNht56Rnk9oo53TvmgjNCtmDA==",
"requires": {
"@stencil/core": "4.38.0",
"ionicons": "^8.0.13",
@@ -4284,11 +4284,11 @@
"requires": {}
},
"@ionic/react": {
"version": "8.7.13",
"resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.7.13.tgz",
"integrity": "sha512-PCuIpaurVYxYZ/CoUN3gP56Fwdx+bx78Qy7V5Ac61nGGW7XpVlV4vM9328Kv7OPs5fkmIvKI6LoY78BnjF0PkA==",
"version": "8.7.16",
"resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.7.16.tgz",
"integrity": "sha512-36y+VmtssJ4vfrCJxUEaOo5tFQRP1m87kxVVC6Cc2ctjLQRDEMszG9v3ctzxD+8EszFLMHEmsSTvGGCelDJlvQ==",
"requires": {
"@ionic/core": "8.7.13",
"@ionic/core": "8.7.16",
"ionicons": "^8.0.13",
"tslib": "*"
}
@@ -6847,4 +6847,4 @@
"dev": true
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@ionic/react-router",
"version": "8.7.13",
"version": "8.7.17",
"description": "React Router wrapper for @ionic/react",
"keywords": [
"ionic",
@@ -36,7 +36,7 @@
"dist/"
],
"dependencies": {
"@ionic/react": "^8.7.13",
"@ionic/react": "^8.7.17",
"tslib": "*"
},
"peerDependencies": {

View File

@@ -3,6 +3,41 @@
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)
**Note:** Version bump only for package @ionic/react
## [8.7.16](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.16) (2025-12-31)
**Note:** Version bump only for package @ionic/react
## [8.7.15](https://github.com/ionic-team/ionic-framework/compare/v8.7.14...v8.7.15) (2025-12-23)
**Note:** Version bump only for package @ionic/react
## [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/react

View File

@@ -1,15 +1,15 @@
{
"name": "@ionic/react",
"version": "8.7.13",
"version": "8.7.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ionic/react",
"version": "8.7.13",
"version": "8.7.17",
"license": "MIT",
"dependencies": {
"@ionic/core": "^8.7.13",
"@ionic/core": "^8.7.17",
"ionicons": "^8.0.13",
"tslib": "*"
},
@@ -736,9 +736,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.13",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.13.tgz",
"integrity": "sha512-72sbep6UOiGn+KYKtVSPZhKuq0o68X6mWi5sCyXYE/V1nzUknew9RGohcxbtt5iMVgjuny/m4liIUwVgvvQ5mw==",
"version": "8.7.16",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.16.tgz",
"integrity": "sha512-+vdv/o2Z/2YfoZJIDBLnoh11eJmOOZqQdfwC0zl2MemAVRSofjGuIQlUTZqiUUNht56Rnk9oo53TvmgjNCtmDA==",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
@@ -11916,4 +11916,4 @@
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@ionic/react",
"version": "8.7.13",
"version": "8.7.17",
"description": "React specific wrapper for @ionic/core",
"keywords": [
"ionic",
@@ -40,7 +40,7 @@
"css/"
],
"dependencies": {
"@ionic/core": "^8.7.13",
"@ionic/core": "^8.7.17",
"ionicons": "^8.0.13",
"tslib": "*"
},

View File

@@ -40,6 +40,20 @@ interface IonTabBarState {
// TODO(FW-2959): types
/**
* Checks if pathname matches the tab's href using path segment matching.
* Avoids false matches like /home2 matching /home by requiring exact match
* or a path segment boundary (/).
*/
const matchesTab = (pathname: string, href: string | undefined): boolean => {
if (href === undefined) {
return false;
}
const normalizedHref = href.endsWith('/') && href !== '/' ? href.slice(0, -1) : href;
return pathname === normalizedHref || pathname.startsWith(normalizedHref + '/');
};
class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarState> {
context!: React.ContextType<typeof NavContext>;
@@ -79,7 +93,7 @@ class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarSta
const tabKeys = Object.keys(tabs);
const activeTab = tabKeys.find((key) => {
const href = tabs[key].originalHref;
return this.props.routeInfo!.pathname.startsWith(href);
return matchesTab(this.props.routeInfo!.pathname, href);
});
if (activeTab) {
@@ -121,7 +135,7 @@ class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarSta
const tabKeys = Object.keys(state.tabs);
const activeTab = tabKeys.find((key) => {
const href = state.tabs[key].originalHref;
return props.routeInfo!.pathname.startsWith(href);
return matchesTab(props.routeInfo!.pathname, href);
});
// Check to see if the tab button href has changed, and if so, update it in the tabs state

View File

@@ -28,6 +28,7 @@ import Tabs from './pages/Tabs';
import TabsBasic from './pages/TabsBasic';
import NavComponent from './pages/navigation/NavComponent';
import TabsDirectNavigation from './pages/TabsDirectNavigation';
import TabsSimilarPrefixes from './pages/TabsSimilarPrefixes';
import IonModalConditional from './pages/overlay-components/IonModalConditional';
import IonModalConditionalSibling from './pages/overlay-components/IonModalConditionalSibling';
import IonModalDatetimeButton from './pages/overlay-components/IonModalDatetimeButton';
@@ -67,6 +68,7 @@ const App: React.FC = () => (
<Route path="/tabs" component={Tabs} />
<Route path="/tabs-basic" component={TabsBasic} />
<Route path="/tabs-direct-navigation" component={TabsDirectNavigation} />
<Route path="/tabs-similar-prefixes" component={TabsSimilarPrefixes} />
<Route path="/icons" component={Icons} />
<Route path="/inputs" component={Inputs} />
<Route path="/reorder-group" component={ReorderGroup} />

View File

@@ -46,6 +46,9 @@ const Main: React.FC<MainProps> = () => {
<IonItem routerLink="/tabs-direct-navigation">
<IonLabel>Tabs with Direct Navigation</IonLabel>
</IonItem>
<IonItem routerLink="/tabs-similar-prefixes">
<IonLabel>Tabs with Similar Route Prefixes</IonLabel>
</IonItem>
<IonItem routerLink="/icons">
<IonLabel>Icons</IonLabel>
</IonItem>

View File

@@ -0,0 +1,87 @@
import {
IonContent,
IonHeader,
IonIcon,
IonLabel,
IonPage,
IonRouterOutlet,
IonTabBar,
IonTabButton,
IonTabs,
IonTitle,
IonToolbar,
} from '@ionic/react';
import { homeOutline, radioOutline, libraryOutline } from 'ionicons/icons';
import React from 'react';
import { Route, Redirect } from 'react-router-dom';
const HomePage: React.FC = () => (
<IonPage data-testid="home-page">
<IonHeader>
<IonToolbar>
<IonTitle>Home</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="home-content">Home Content</div>
</IonContent>
</IonPage>
);
const Home2Page: React.FC = () => (
<IonPage data-testid="home2-page">
<IonHeader>
<IonToolbar>
<IonTitle>Home 2</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="home2-content">Home 2 Content</div>
</IonContent>
</IonPage>
);
const Home3Page: React.FC = () => (
<IonPage data-testid="home3-page">
<IonHeader>
<IonToolbar>
<IonTitle>Home 3</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="home3-content">Home 3 Content</div>
</IonContent>
</IonPage>
);
const TabsSimilarPrefixes: React.FC = () => {
return (
<IonTabs data-testid="tabs-similar-prefixes">
<IonRouterOutlet>
<Redirect exact path="/tabs-similar-prefixes" to="/tabs-similar-prefixes/home" />
<Route path="/tabs-similar-prefixes/home" render={() => <HomePage />} exact={true} />
<Route path="/tabs-similar-prefixes/home2" render={() => <Home2Page />} exact={true} />
<Route path="/tabs-similar-prefixes/home3" render={() => <Home3Page />} exact={true} />
</IonRouterOutlet>
<IonTabBar slot="bottom" data-testid="tab-bar">
<IonTabButton tab="home" href="/tabs-similar-prefixes/home" data-testid="home-tab">
<IonIcon icon={homeOutline}></IonIcon>
<IonLabel>Home</IonLabel>
</IonTabButton>
<IonTabButton tab="home2" href="/tabs-similar-prefixes/home2" data-testid="home2-tab">
<IonIcon icon={radioOutline}></IonIcon>
<IonLabel>Home 2</IonLabel>
</IonTabButton>
<IonTabButton tab="home3" href="/tabs-similar-prefixes/home3" data-testid="home3-tab">
<IonIcon icon={libraryOutline}></IonIcon>
<IonLabel>Home 3</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
);
};
export default TabsSimilarPrefixes;

View File

@@ -1,4 +1,45 @@
describe('IonTabs', () => {
/**
* Verifies that tabs with similar route prefixes (e.g., /home, /home2, /home3)
* correctly select the matching tab instead of the first prefix match.
*
* @see https://github.com/ionic-team/ionic-framework/issues/30448
*/
describe('Similar Route Prefixes', () => {
it('should select the correct tab when routes have similar prefixes', () => {
cy.visit('/tabs-similar-prefixes/home2');
cy.get('[data-testid="home2-content"]').should('be.visible');
cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
});
it('should select the correct tab when navigating via tab buttons', () => {
cy.visit('/tabs-similar-prefixes/home');
cy.get('[data-testid="home-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
cy.get('[data-testid="home2-tab"]').click();
cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
cy.get('[data-testid="home3-tab"]').click();
cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
});
it('should select the correct tab when directly navigating to home3', () => {
cy.visit('/tabs-similar-prefixes/home3');
cy.get('[data-testid="home3-content"]').should('be.visible');
cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
});
});
describe('With IonRouterOutlet', () => {
beforeEach(() => {
cy.visit('/tabs/tab1');

View File

@@ -3,6 +3,38 @@
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)
**Note:** Version bump only for package @ionic/vue-router
## [8.7.16](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.16) (2025-12-31)
**Note:** Version bump only for package @ionic/vue-router
## [8.7.15](https://github.com/ionic-team/ionic-framework/compare/v8.7.14...v8.7.15) (2025-12-23)
**Note:** Version bump only for package @ionic/vue-router
## [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/vue-router
## [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/vue-router

View File

@@ -1,15 +1,15 @@
{
"name": "@ionic/vue-router",
"version": "8.7.13",
"version": "8.7.17",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@ionic/vue-router",
"version": "8.7.13",
"version": "8.7.17",
"license": "MIT",
"dependencies": {
"@ionic/vue": "^8.7.13"
"@ionic/vue": "^8.7.17"
},
"devDependencies": {
"@ionic/eslint-config": "^0.3.0",
@@ -673,9 +673,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.13",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.13.tgz",
"integrity": "sha512-72sbep6UOiGn+KYKtVSPZhKuq0o68X6mWi5sCyXYE/V1nzUknew9RGohcxbtt5iMVgjuny/m4liIUwVgvvQ5mw==",
"version": "8.7.16",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.16.tgz",
"integrity": "sha512-+vdv/o2Z/2YfoZJIDBLnoh11eJmOOZqQdfwC0zl2MemAVRSofjGuIQlUTZqiUUNht56Rnk9oo53TvmgjNCtmDA==",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
@@ -868,12 +868,12 @@
}
},
"node_modules/@ionic/vue": {
"version": "8.7.13",
"resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-8.7.13.tgz",
"integrity": "sha512-hflvGaNPABYP0Qt68YgrauVaXyjKeHODOkYzJhk36kcr+VexwTWm1FGJG1/nKKgdh6fwDIsubJvlhoZaRhtVtg==",
"version": "8.7.16",
"resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-8.7.16.tgz",
"integrity": "sha512-7ZKKUj+PgzV/SiSbSPFE/anQzT3kHLrb7JGrw394QZB1E3aehljgt/hDaQzityRtgqgUaaJZx22MGrg5r9kePQ==",
"license": "MIT",
"dependencies": {
"@ionic/core": "8.7.13",
"@ionic/core": "8.7.16",
"@stencil/vue-output-target": "0.10.7",
"ionicons": "^8.0.13"
}
@@ -8044,9 +8044,9 @@
"dev": true
},
"@ionic/core": {
"version": "8.7.13",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.13.tgz",
"integrity": "sha512-72sbep6UOiGn+KYKtVSPZhKuq0o68X6mWi5sCyXYE/V1nzUknew9RGohcxbtt5iMVgjuny/m4liIUwVgvvQ5mw==",
"version": "8.7.16",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.16.tgz",
"integrity": "sha512-+vdv/o2Z/2YfoZJIDBLnoh11eJmOOZqQdfwC0zl2MemAVRSofjGuIQlUTZqiUUNht56Rnk9oo53TvmgjNCtmDA==",
"requires": {
"@stencil/core": "4.38.0",
"ionicons": "^8.0.13",
@@ -8159,11 +8159,11 @@
"requires": {}
},
"@ionic/vue": {
"version": "8.7.13",
"resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-8.7.13.tgz",
"integrity": "sha512-hflvGaNPABYP0Qt68YgrauVaXyjKeHODOkYzJhk36kcr+VexwTWm1FGJG1/nKKgdh6fwDIsubJvlhoZaRhtVtg==",
"version": "8.7.16",
"resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-8.7.16.tgz",
"integrity": "sha512-7ZKKUj+PgzV/SiSbSPFE/anQzT3kHLrb7JGrw394QZB1E3aehljgt/hDaQzityRtgqgUaaJZx22MGrg5r9kePQ==",
"requires": {
"@ionic/core": "8.7.13",
"@ionic/core": "8.7.16",
"@stencil/vue-output-target": "0.10.7",
"ionicons": "^8.0.13"
}
@@ -12994,4 +12994,4 @@
"dev": true
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@ionic/vue-router",
"version": "8.7.13",
"version": "8.7.17",
"description": "Vue Router integration for @ionic/vue",
"scripts": {
"test.spec": "jest",
@@ -44,7 +44,7 @@
},
"homepage": "https://github.com/ionic-team/ionic-framework#readme",
"dependencies": {
"@ionic/vue": "^8.7.13"
"@ionic/vue": "^8.7.17"
},
"devDependencies": {
"@ionic/eslint-config": "^0.3.0",

View File

@@ -3,6 +3,41 @@
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)
**Note:** Version bump only for package @ionic/vue
## [8.7.16](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.16) (2025-12-31)
**Note:** Version bump only for package @ionic/vue
## [8.7.15](https://github.com/ionic-team/ionic-framework/compare/v8.7.14...v8.7.15) (2025-12-23)
**Note:** Version bump only for package @ionic/vue
## [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/vue

View File

@@ -1,15 +1,15 @@
{
"name": "@ionic/vue",
"version": "8.7.13",
"version": "8.7.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ionic/vue",
"version": "8.7.13",
"version": "8.7.17",
"license": "MIT",
"dependencies": {
"@ionic/core": "^8.7.13",
"@ionic/core": "^8.7.17",
"@stencil/vue-output-target": "0.10.7",
"ionicons": "^8.0.13"
},
@@ -222,9 +222,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.13",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.13.tgz",
"integrity": "sha512-72sbep6UOiGn+KYKtVSPZhKuq0o68X6mWi5sCyXYE/V1nzUknew9RGohcxbtt5iMVgjuny/m4liIUwVgvvQ5mw==",
"version": "8.7.16",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.16.tgz",
"integrity": "sha512-+vdv/o2Z/2YfoZJIDBLnoh11eJmOOZqQdfwC0zl2MemAVRSofjGuIQlUTZqiUUNht56Rnk9oo53TvmgjNCtmDA==",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
@@ -4022,4 +4022,4 @@
"dev": true
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@ionic/vue",
"version": "8.7.13",
"version": "8.7.17",
"description": "Vue specific wrapper for @ionic/core",
"scripts": {
"eslint": "eslint src",
@@ -68,7 +68,7 @@
"vue-router": "^4.0.16"
},
"dependencies": {
"@ionic/core": "^8.7.13",
"@ionic/core": "^8.7.17",
"@stencil/vue-output-target": "0.10.7",
"ionicons": "^8.0.13"
},

View File

@@ -24,6 +24,23 @@ interface TabBarData {
const isTabButton = (child: any) => child.type?.name === "IonTabButton";
/**
* Checks if pathname matches the tab's href using path segment matching.
* Avoids false matches like /home2 matching /home by requiring exact match
* or a path segment boundary (/).
*/
const matchesTab = (pathname: string, href: string | undefined): boolean => {
if (href === undefined) {
return false;
}
const normalizedHref =
href.endsWith("/") && href !== "/" ? href.slice(0, -1) : href;
return (
pathname === normalizedHref || pathname.startsWith(normalizedHref + "/")
);
};
const getTabs = (nodes: VNode[]) => {
let tabs: VNode[] = [];
nodes.forEach((node: VNode) => {
@@ -135,7 +152,9 @@ export const IonTabBar = defineComponent({
const tabKeys = Object.keys(tabs);
let activeTab = tabKeys.find((key) => {
const href = tabs[key].originalHref;
return currentRoute?.pathname.startsWith(href);
return (
currentRoute?.pathname && matchesTab(currentRoute.pathname, href)
);
});
/**

View File

@@ -165,6 +165,28 @@ const routes: Array<RouteRecordRaw> = [
path: '/tabs-basic',
component: () => import('@/views/TabsBasic.vue')
},
{
path: '/tabs-similar-prefixes/',
component: () => import('@/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue'),
children: [
{
path: '',
redirect: '/tabs-similar-prefixes/home'
},
{
path: 'home',
component: () => import('@/views/tabs-similar-prefixes/Home.vue'),
},
{
path: 'home2',
component: () => import('@/views/tabs-similar-prefixes/Home2.vue'),
},
{
path: 'home3',
component: () => import('@/views/tabs-similar-prefixes/Home3.vue'),
}
]
},
]
const router = createRouter({

View File

@@ -50,6 +50,9 @@
<ion-item router-link="/tabs-basic" id="tab-basic">
<ion-label>Tabs with Basic Navigation</ion-label>
</ion-item>
<ion-item router-link="/tabs-similar-prefixes" id="tabs-similar-prefixes">
<ion-label>Tabs with Similar Route Prefixes</ion-label>
</ion-item>
<ion-item router-link="/lifecycle" id="lifecycle">
<ion-label>Lifecycle</ion-label>
</ion-item>

View File

@@ -0,0 +1,16 @@
<template>
<ion-page data-pageid="home">
<ion-header>
<ion-toolbar>
<ion-title>Home</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<div data-testid="home-content">Home Content</div>
</ion-content>
</ion-page>
</template>
<script setup lang="ts">
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/vue';
</script>

View File

@@ -0,0 +1,16 @@
<template>
<ion-page data-pageid="home2">
<ion-header>
<ion-toolbar>
<ion-title>Home 2</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<div data-testid="home2-content">Home 2 Content</div>
</ion-content>
</ion-page>
</template>
<script setup lang="ts">
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/vue';
</script>

View File

@@ -0,0 +1,16 @@
<template>
<ion-page data-pageid="home3">
<ion-header>
<ion-toolbar>
<ion-title>Home 3</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<div data-testid="home3-content">Home 3 Content</div>
</ion-content>
</ion-page>
</template>
<script setup lang="ts">
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/vue';
</script>

View File

@@ -0,0 +1,54 @@
<template>
<ion-page data-pageid="tabs-similar-prefixes">
<ion-content>
<ion-tabs id="tabs-similar-prefixes">
<ion-router-outlet></ion-router-outlet>
<ion-tab-bar slot="bottom" data-testid="tab-bar">
<ion-tab-button
tab="home"
href="/tabs-similar-prefixes/home"
data-testid="home-tab"
id="tab-button-home"
>
<ion-icon :icon="homeOutline" />
<ion-label>Home</ion-label>
</ion-tab-button>
<ion-tab-button
tab="home2"
href="/tabs-similar-prefixes/home2"
data-testid="home2-tab"
id="tab-button-home2"
>
<ion-icon :icon="radioOutline" />
<ion-label>Home 2</ion-label>
</ion-tab-button>
<ion-tab-button
tab="home3"
href="/tabs-similar-prefixes/home3"
data-testid="home3-tab"
id="tab-button-home3"
>
<ion-icon :icon="libraryOutline" />
<ion-label>Home 3</ion-label>
</ion-tab-button>
</ion-tab-bar>
</ion-tabs>
</ion-content>
</ion-page>
</template>
<script setup lang="ts">
import {
IonContent,
IonIcon,
IonLabel,
IonPage,
IonRouterOutlet,
IonTabBar,
IonTabButton,
IonTabs,
} from '@ionic/vue';
import { homeOutline, radioOutline, libraryOutline } from 'ionicons/icons';
</script>

View File

@@ -1,4 +1,45 @@
describe('Tabs', () => {
/**
* Verifies that tabs with similar route prefixes (e.g., /home, /home2, /home3)
* correctly select the matching tab instead of the first prefix match.
*
* @see https://github.com/ionic-team/ionic-framework/issues/30448
*/
describe('Similar Route Prefixes', () => {
it('should select the correct tab when routes have similar prefixes', () => {
cy.visit('/tabs-similar-prefixes/home2');
cy.get('[data-testid="home2-content"]').should('be.visible');
cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
});
it('should select the correct tab when navigating via tab buttons', () => {
cy.visit('/tabs-similar-prefixes/home');
cy.get('[data-testid="home-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
cy.get('[data-testid="home2-tab"]').click();
cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
cy.get('[data-testid="home3-tab"]').click();
cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
});
it('should select the correct tab when directly navigating to home3', () => {
cy.visit('/tabs-similar-prefixes/home3');
cy.get('[data-testid="home3-content"]').should('be.visible');
cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected');
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
});
});
describe('With IonRouterOutlet', () => {
it('should go back from child pages', () => {
cy.visit('/tabs');