Compare commits

..

19 Commits

Author SHA1 Message Date
ShaneK
c3e3951f31 fix(overlays): honor string focusTrap config 2025-11-16 15:56:20 -08:00
Maria Hutt
92db36489c fix(checkbox, toggle, radio-group): improve screen reader announcement timing for validation errors (#30714)
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. -->

Currently, when an error text is shown, it may not announce itself to
voice assistants. This is because the way error text currently works is
by always existing in the DOM, but being hidden when there is no error.
When the error state changes, the error text is shown, but as far as the
voice assistant can tell it's always been there and nothing has changed.

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

- Updated aria attributes
- Added observer with an observer

## 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. -->

[Checkbox
preview](https://ionic-framework-git-fw-6757-ionic1.vercel.app/src/components/checkbox/test/validation/)
[Toggle
preview](https://ionic-framework-git-fw-6757-ionic1.vercel.app/src/components/toggle/test/validation/)
[Radio Group
preview](https://ionic-framework-git-fw-6757-ionic1.vercel.app/src/components/radio-group/test/validation/)

⚠️ Flakiness ⚠️ 

The flakiness on checkbox and toggle is because when a native input is
present on the page, the browser will have the screen reader to be
really fast when it comes to checking information. This speed ends up
being too fast for `ion-checkbox` to be able to add the error text. This
leads to the error text not being announce consistently. There's no
issue when it comes to ion-input or ion-textarea because Ionic uses the
native inputs so their arias are read. There's also no issue
with ion-select because we don't have a native input. It's only an issue
with checkbox and the others is because it has a [native input that is
being
hidden](8e884bd2cb/core/src/components/checkbox/checkbox.tsx (L368-L369)).
So the browser sees that and speeds up the screen reader.

The flakiness on radio group is because when you tab out of the last
radio button, the ionBlur event is emitted by the child <ion-radio>.
This event bubbles up, but the timing is still too early for the group.
2025-11-13 18:11:32 +00:00
Gonçalo M.
c37e2a5d9e chore(npm): Update release npm action to stop using tokens (#30778)
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. -->

- Release workflows still inject a long-lived `NPM_TOKEN` via `.npmrc`,
so publishes do not use npm’s trusted OIDC flow.

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

- The shared `actions/publish-npm` composite action now configures
`setup-node` with the npm registry, upgrades npm in place, and publishes
with `--provenance` without writing `.npmrc`.
- `release-dev.yml`, `release-nightly.yml`, and `release-production.yml`
call into that trusted flow by removing the token input and (for
production) inlining the same OIDC setup before `npm run release.ci`.
- Allows npm to authenticate through trusted publishing requirements
[docs.npmjs.com/trusted-publishers](https://docs.npmjs.com/trusted-publishers).
- Step names were refreshed with emojis, but there are no other
behavioral changes.


## 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. -->

- These changes align the Ionic release automation with npm’s
trusted-publisher enforcement while keeping the existing Lerna
build/publish process intact.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-11 19:35:11 +00:00
Shane
0a02e0f8cf merge release-8.7.9 (#30767)
v8.7.9

---------

Co-authored-by: ionitron <hi@ionicframework.com>
2025-11-05 09:22:32 -08:00
ionitron
e1293ff9f6 chore(): update package lock files 2025-11-05 17:05:03 +00:00
ionitron
32df083e87 v8.7.9 2025-11-05 17:04:11 +00:00
Shane
58d563805f fix(accordion-group): skip initial animation (#30729)
Issue number: resolves #30613

---------

<!-- 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. -->
Currently, when you load an accordion group, the initially selected
accordion animates open. This is an unexpected change caused by
upgrading Stencil to >= 4.21.0, which changed the way component
lifecycles got triggered.

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

With this change, we're waiting for the accordion in the accordion group
to render and telling it if the update it's going through is the initial
update or not. This allows it to decide to animate properly.

## 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.8-dev.11761840817.1bede576
```
2025-11-03 22:03:10 +00:00
renovate[bot]
cfd8c42f07 chore(deps): update download + upload artifacts (major) (#30754)
This PR contains the following updates:

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

---

### Release Notes

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

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

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

</details>

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

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

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

</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:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNTYuMSIsInVwZGF0ZWRJblZlciI6IjQxLjE1OS40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-30 15:00:04 +00:00
renovate[bot]
0b2e766609 chore(deps): update dependency node to v24 (#30756)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [node](https://redirect.github.com/actions/node-versions) | uses-with
| major | `22.x` -> `24.x` |

---

### Release Notes

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

###
[`v24.10.0`](https://redirect.github.com/actions/node-versions/releases/tag/24.10.0-18453495281):
24.10.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/24.9.0-18024003193...24.10.0-18453495281)

Node.js 24.10.0

###
[`v24.9.0`](https://redirect.github.com/actions/node-versions/releases/tag/24.9.0-18024003193):
24.9.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/24.8.0-17630522236...24.9.0-18024003193)

Node.js 24.9.0

###
[`v24.8.0`](https://redirect.github.com/actions/node-versions/releases/tag/24.8.0-17630522236):
24.8.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/24.7.0-17283839804...24.8.0-17630522236)

Node.js 24.8.0

###
[`v24.7.0`](https://redirect.github.com/actions/node-versions/releases/tag/24.7.0-17283839804):
24.7.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/24.6.0-16980723897...24.7.0-17283839804)

Node.js 24.7.0

###
[`v24.6.0`](https://redirect.github.com/actions/node-versions/releases/tag/24.6.0-16980723897):
24.6.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/24.5.0-16666195981...24.6.0-16980723897)

Node.js 24.6.0

###
[`v24.5.0`](https://redirect.github.com/actions/node-versions/releases/tag/24.5.0-16666195981):
24.5.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/24.4.1-16309768053...24.5.0-16666195981)

Node.js 24.5.0

###
[`v24.4.1`](https://redirect.github.com/actions/node-versions/releases/tag/24.4.1-16309768053):
24.4.1

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/24.4.0-16210503505...24.4.1-16309768053)

Node.js 24.4.1

###
[`v24.4.0`](https://redirect.github.com/actions/node-versions/releases/tag/24.4.0-16210503505):
24.4.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/24.3.0-15866716565...24.4.0-16210503505)

Node.js 24.4.0

###
[`v24.3.0`](https://redirect.github.com/actions/node-versions/releases/tag/24.3.0-15866716565):
24.3.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/24.2.0-15549907769...24.3.0-15866716565)

Node.js 24.3.0

###
[`v24.2.0`](https://redirect.github.com/actions/node-versions/releases/tag/24.2.0-15549907769):
24.2.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/24.1.0-15177436545...24.2.0-15549907769)

Node.js 24.2.0

###
[`v24.1.0`](https://redirect.github.com/actions/node-versions/releases/tag/24.1.0-15177436545):
24.1.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/24.0.2-15035852679...24.1.0-15177436545)

Node.js 24.1.0

###
[`v24.0.2`](https://redirect.github.com/actions/node-versions/releases/tag/24.0.2-15035852679):
24.0.2

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/24.0.1-14928016774...24.0.2-15035852679)

Node.js 24.0.2

###
[`v24.0.1`](https://redirect.github.com/actions/node-versions/releases/tag/24.0.1-14928016774):
24.0.1

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/24.0.0-14863421234...24.0.1-14928016774)

Node.js 24.0.1

###
[`v24.0.0`](https://redirect.github.com/actions/node-versions/releases/tag/24.0.0-14863421234):
24.0.0

[Compare
Source](https://redirect.github.com/actions/node-versions/compare/22.21.0-18671149699...24.0.0-14863421234)

Node.js 24.0.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:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNTkuNCIsInVwZGF0ZWRJblZlciI6IjQxLjE1OS40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-30 14:13:19 +00:00
Brandy Smith
10f895b8b6 merge release-8.7.8 (#30761)
v8.7.8
2025-10-29 14:09:19 -04:00
ionitron
66abc05c46 chore(): update package lock files 2025-10-29 17:41:33 +00:00
ionitron
8a8eec4247 v8.7.8 2025-10-29 17:40:53 +00:00
OS-jacobbell
abd3eacadf chore(ci): send alert message when stencil-nightly workflow finishes (#30743)
Send alert to a Discord channel using a webhook.

---------

<!-- 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. -->

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

- If a step in the workflow fails, an alert message will be sent to a
Discord channel using a webhook.

## 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. -->

---------

Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
2025-10-27 16:47:56 +00:00
Maria Hutt
54a1c86d6a fix(checkbox, toggle): fire ionFocus and ionBlur (#30733)
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. -->

`ionFocus` and `ionBlur` are not being emitted for checkbox and toggle. 

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

- Moved the `onFocus` and `onBlur` to `Host`
- Added tests for `onFocus`, `onBlur`, and `onChange`.
- Added a workaround on Playwright in order to trigger `ionBlur` since
Playwright browsers aren't acting like native browsers when it comes to
tabbing.

## 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.7-dev.11761071592.1d1b804d`

---------

Co-authored-by: ionitron <hi@ionicframework.com>
Co-authored-by: Shane <shane@shanessite.net>
2025-10-23 17:09:05 +00:00
renovate[bot]
ba73988750 chore(deps): update dependency @axe-core/playwright to ^4.11.0 (#30747)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
|
[@axe-core/playwright](https://redirect.github.com/dequelabs/axe-core-npm)
| [`^4.10.2` ->
`^4.11.0`](https://renovatebot.com/diffs/npm/@axe-core%2fplaywright/4.10.2/4.11.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@axe-core%2fplaywright/4.11.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@axe-core%2fplaywright/4.10.2/4.11.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>dequelabs/axe-core-npm (@&#8203;axe-core/playwright)</summary>

###
[`v4.11.0`](https://redirect.github.com/dequelabs/axe-core-npm/blob/HEAD/CHANGELOG.md#4110-2025-10-14)

[Compare
Source](https://redirect.github.com/dequelabs/axe-core-npm/compare/v4.10.2...a7af9e96a24105a56def373bf0339cf57cda0403)

##### Bug Fixes

- Optimize AxeBuilder memory usage.
([#&#8203;1154](https://redirect.github.com/dequelabs/axe-core-npm/issues/1154))
([e53cd36](e53cd36d07)),
closes
[/github.com/bensenescu/axe-core-npm/blob/develop/packages/puppeteer/src/axePuppeteer.ts#L59](https://redirect.github.com//github.com/bensenescu/axe-core-npm/blob/develop/packages/puppeteer/src/axePuppeteer.ts/issues/L59)
[/github.com/bensenescu/axe-core-npm/blob/develop/packages/puppeteer/src/utils.ts#L34](https://redirect.github.com//github.com/bensenescu/axe-core-npm/blob/develop/packages/puppeteer/src/utils.ts/issues/L34)
- Update axe-core to v4.10.3
([#&#8203;1155](https://redirect.github.com/dequelabs/axe-core-npm/issues/1155))
([f8e3a14](f8e3a14043))
- **wdio:** resolve blank navigation issue in WDIO v9
([#&#8203;1169](https://redirect.github.com/dequelabs/axe-core-npm/issues/1169))
([6505560](6505560d64))

##### Features

- Update axe-core to v4.11.0
([#&#8203;1233](https://redirect.github.com/dequelabs/axe-core-npm/issues/1233))
([2758476](2758476481))

####
[4.10.2](https://redirect.github.com/dequelabs/axe-core-npm/compare/v4.10.1...v4.10.2)
(2025-05-12)

##### Bug Fixes

- Optimize AxeBuilder memory usage.
([#&#8203;1154](https://redirect.github.com/dequelabs/axe-core-npm/issues/1154))
([e53cd36](e53cd36d07)),
closes
[/github.com/bensenescu/axe-core-npm/blob/develop/packages/puppeteer/src/axePuppeteer.ts#L59](https://redirect.github.com//github.com/bensenescu/axe-core-npm/blob/develop/packages/puppeteer/src/axePuppeteer.ts/issues/L59)
[/github.com/bensenescu/axe-core-npm/blob/develop/packages/puppeteer/src/utils.ts#L34](https://redirect.github.com//github.com/bensenescu/axe-core-npm/blob/develop/packages/puppeteer/src/utils.ts/issues/L34)
- Update axe-core to v4.10.3
([#&#8203;1155](https://redirect.github.com/dequelabs/axe-core-npm/issues/1155))
([f8e3a14](f8e3a14043))
- **wdio:** resolve blank navigation issue in WDIO v9
([#&#8203;1169](https://redirect.github.com/dequelabs/axe-core-npm/issues/1169))
([6505560](6505560d64))

####
[4.10.1](https://redirect.github.com/dequelabs/axe-core-npm/compare/v4.10.0...v4.10.1)
(2024-10-29)

##### Bug Fixes

- Update axe-core to v4.10.1
([#&#8203;1124](https://redirect.github.com/dequelabs/axe-core-npm/issues/1124))
([099818b](099818bcef))
- Update axe-core to v4.10.2
([#&#8203;1128](https://redirect.github.com/dequelabs/axe-core-npm/issues/1128))
([aaaa34e](aaaa34e7c3))

</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:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNTYuMSIsInVwZGF0ZWRJblZlciI6IjQxLjE1Ni4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 13:58:36 +00:00
renovate[bot]
bdc80d8e82 chore(deps): update dependency @capacitor/core to v7.4.4 (#30746)
This PR contains the following updates:

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

---

### Release Notes

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

###
[`v7.4.4`](https://redirect.github.com/ionic-team/capacitor/compare/7.4.3...20fa5015b7940a19a61e005e4212af967ae8f108)

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

</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:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNTYuMSIsInVwZGF0ZWRJblZlciI6IjQxLjE1Ni4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 13:58:13 +00:00
renovate[bot]
9f013b7a51 chore(deps): update playwright (#30732)
This PR contains the following updates:

| Package | Change | Age | Confidence | Type | Update |
|---|---|---|---|---|---|
| [@playwright/test](https://playwright.dev)
([source](https://redirect.github.com/microsoft/playwright)) |
[`^1.56.0` ->
`^1.56.1`](https://renovatebot.com/diffs/npm/@playwright%2ftest/1.56.0/1.56.1)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@playwright%2ftest/1.56.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@playwright%2ftest/1.56.0/1.56.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| devDependencies | patch |
| mcr.microsoft.com/playwright | `v1.56.0` -> `v1.56.1` |
[![age](https://developer.mend.io/api/mc/badges/age/docker/mcr.microsoft.com%2fplaywright/v1.56.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/docker/mcr.microsoft.com%2fplaywright/v1.56.0/v1.56.1?slim=true)](https://docs.renovatebot.com/merge-confidence/)
| final | patch |

---

### Release Notes

<details>
<summary>microsoft/playwright (@&#8203;playwright/test)</summary>

###
[`v1.56.1`](https://redirect.github.com/microsoft/playwright/releases/tag/v1.56.1)

[Compare
Source](https://redirect.github.com/microsoft/playwright/compare/v1.56.0...v1.56.1)

#### Highlights


[#&#8203;37871](https://redirect.github.com/microsoft/playwright/issues/37871)
chore: allow local-network-access permission in chromium

[#&#8203;37891](https://redirect.github.com/microsoft/playwright/issues/37891)
fix(agents): remove workspaceFolder ref from vscode mcp

[#&#8203;37759](https://redirect.github.com/microsoft/playwright/issues/37759)
chore: rename agents to test agents

[#&#8203;37757](https://redirect.github.com/microsoft/playwright/issues/37757)
chore(mcp): fallback to cwd when resolving test config

#### Browser Versions

- Chromium 141.0.7390.37
- Mozilla Firefox 142.0.1
- WebKit 26.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.

👻 **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:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNDMuMSIsInVwZGF0ZWRJblZlciI6IjQxLjE0My4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-17 17:25:46 +00:00
Brandy Smith
e4fc33f331 merge release-8.7.7 (#30727)
v8.7.7
2025-10-15 15:27:43 -04:00
ionitron
4a49e52b6d chore(): update package lock files 2025-10-15 19:01:26 +00:00
107 changed files with 2773 additions and 415 deletions

View File

@@ -3,23 +3,23 @@ description: 'Build Ionic Angular Server'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
with:
name: ionic-core
path: ./core
filename: CoreBuild.zip
- name: Install Angular Server Dependencies
- name: 🕸️ Install Angular Server Dependencies
run: npm ci
shell: bash
working-directory: ./packages/angular-server
- name: Sync
- name: 🔄 Sync
run: npm run sync
shell: bash
working-directory: ./packages/angular-server
- name: Build
- name: 🏗️ Build
run: npm run build.prod
shell: bash
working-directory: ./packages/angular-server

View File

@@ -5,29 +5,29 @@ runs:
steps:
- uses: actions/setup-node@v6
with:
node-version: 22.x
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
with:
name: ionic-core
path: ./core
filename: CoreBuild.zip
- name: Install Angular Dependencies
- name: 🕸️ Install Angular Dependencies
run: npm ci
shell: bash
working-directory: ./packages/angular
- name: Sync
- name: 🔄 Sync
run: npm run sync
shell: bash
working-directory: ./packages/angular
- name: Lint
- name: 🖌️ Lint
run: npm run lint
shell: bash
working-directory: ./packages/angular
- name: Build
- name: 🏗️ Build
run: npm run build
shell: bash
working-directory: ./packages/angular
- name: Check Diff
- name: 🔍 Check Diff
run: git diff --exit-code
shell: bash
working-directory: ./packages/angular

View File

@@ -8,20 +8,20 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
node-version: 24.x
- name: Install Dependencies
- name: 🕸️ Install Dependencies
run: npm ci
working-directory: ./core
shell: bash
- name: Install Stencil ${{ inputs.stencil-version }}
- name: 📦 Install Stencil ${{ inputs.stencil-version }}
working-directory: ./core
run: npm i @stencil/core@${{ inputs.stencil-version }}
shell: bash
- name: Build Core
- name: 🏗️ Build Core
run: npm run build -- --ci --debug --verbose
working-directory: ./core
shell: bash

View File

@@ -8,22 +8,22 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
- name: Install Dependencies
node-version: 24.x
- name: 🕸️ Install Dependencies
run: npm install
working-directory: ./core
shell: bash
# If an Ionicons version was specified install that.
# Otherwise just use the version defined in the package.json.
- name: Install Ionicons Version
- name: 📦 Install Ionicons Version
if: inputs.ionicons-version != ''
run: npm install ionicons@${{ inputs.ionicons-version }}
working-directory: ./core
shell: bash
- name: Build Core
- name: 🏗️ Build Core
run: npm run build -- --ci
working-directory: ./core
shell: bash

View File

@@ -3,9 +3,9 @@ description: 'Build Ionic React Router'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
with:
name: ionic-core
@@ -16,19 +16,19 @@ runs:
name: ionic-react
path: ./packages/react
filename: ReactBuild.zip
- name: Install Dependencies
- name: 🕸️ Install Dependencies
run: npm ci
shell: bash
working-directory: ./packages/react-router
- name: Sync
- name: 🔄 Sync
run: npm run sync
shell: bash
working-directory: ./packages/react-router
- name: Lint
- name: 🖌️ Lint
run: npm run lint
shell: bash
working-directory: ./packages/react-router
- name: Build
- name: 🏗️ Build
run: npm run build
shell: bash
working-directory: ./packages/react-router

View File

@@ -3,35 +3,35 @@ description: 'Build Ionic React'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
with:
name: ionic-core
path: ./core
filename: CoreBuild.zip
- name: Install React Dependencies
- name: 🕸️ Install React Dependencies
run: npm ci
shell: bash
working-directory: ./packages/react
- name: Sync
- name: 🔄 Sync
run: npm run sync
shell: bash
working-directory: ./packages/react
- name: Lint
- name: 🖌️ Lint
run: npm run lint
shell: bash
working-directory: ./packages/react
- name: Build
- name: 🏗️ Build
run: npm run build
shell: bash
working-directory: ./packages/react
- name: Test Spec
- name: 🧪 Test Spec
run: npm run test.spec
shell: bash
working-directory: ./packages/react
- name: Check Diff
- name: 🔍 Check Diff
run: git diff --exit-code
shell: bash
working-directory: ./packages/react

View File

@@ -3,9 +3,9 @@ description: 'Builds Ionic Vue Router'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
with:
name: ionic-core
@@ -16,23 +16,23 @@ runs:
name: ionic-vue
path: ./packages/vue
filename: VueBuild.zip
- name: Install Vue Router Dependencies
- name: 🕸️ Install Vue Router Dependencies
run: npm ci
shell: bash
working-directory: ./packages/vue-router
- name: Sync
- name: 🔄 Sync
run: npm run sync
shell: bash
working-directory: ./packages/vue-router
- name: Lint
- name: 🖌️ Lint
run: npm run lint
shell: bash
working-directory: ./packages/vue-router
- name: Build
- name: 🏗️ Build
run: npm run build
shell: bash
working-directory: ./packages/vue-router
- name: Test Spec
- name: 🧪 Test Spec
run: npm run test.spec
shell: bash
working-directory: ./packages/vue-router

View File

@@ -3,31 +3,31 @@ description: 'Build Ionic Vue'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
with:
name: ionic-core
path: ./core
filename: CoreBuild.zip
- name: Install Vue Dependencies
- name: 🕸️ Install Vue Dependencies
run: npm ci
shell: bash
working-directory: ./packages/vue
- name: Sync
- name: 🔄 Sync
run: npm run sync
shell: bash
working-directory: ./packages/vue
- name: Lint
- name: 🖌️ Lint
run: npm run lint
shell: bash
working-directory: ./packages/vue
- name: Build
- name: 🏗️ Build
run: npm run build
shell: bash
working-directory: ./packages/vue
- name: Check Diff
- name: 🔍 Check Diff
run: git diff --exit-code
shell: bash
working-directory: ./packages/vue

View File

@@ -10,10 +10,10 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/download-artifact@v5
- uses: actions/download-artifact@v6
with:
name: ${{ inputs.name }}
path: ${{ inputs.path }}
- name: Extract Archive
- name: 🔎 Extract Archive
run: unzip -q -o ${{ inputs.path }}/${{ inputs.filename }}
shell: bash

View File

@@ -8,48 +8,51 @@ inputs:
tag:
description: 'The tag to publish to on NPM.'
preid:
description: 'The prerelease identifier used when doing a prerelease.'
description: "Prerelease identifier such as 'alpha', 'beta', 'rc', or 'next'. Leave blank to skip prerelease tagging."
working-directory:
description: 'The directory of the package.'
folder:
default: './'
description: 'A folder containing a package.json file.'
token:
description: 'The NPM authentication token required to publish.'
node-version:
description: 'Node.js version to use when publishing.'
required: false
default: '24.x'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- name: 🟢 Configure Node for Publish
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
node-version: ${{ inputs.node-version }}
registry-url: 'https://registry.npmjs.org'
# Provenance requires npm 9.5.0+
- name: Install latest npm
- name: 📦 Install latest npm
run: npm install -g npm@latest
shell: bash
# This ensures the local version of Lerna is installed
# and that we do not use the global Lerna version
- name: Install root dependencies
- name: 🕸️ Install root dependencies
run: npm ci
shell: bash
- name: Install Dependencies
- name: 📦 Install Dependencies
run: npx lerna@5 bootstrap --include-dependencies --scope ${{ inputs.scope }} --ignore-scripts -- --legacy-peer-deps
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Update Version
run: npx lerna@5 version ${{ inputs.version }} --yes --exact --no-changelog --no-push --no-git-tag-version --preid=${{ inputs.preid }}
- name: 🏷️ Set Version
run: |
if [ -z "${{ inputs.preid }}" ]; then
npx lerna@5 version ${{ inputs.version }} --yes --exact --no-changelog --no-push --no-git-tag-version
else
npx lerna@5 version ${{ inputs.version }} --yes --exact --no-changelog --no-push --no-git-tag-version --preid=${{ inputs.preid }}
fi
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Run Build
- name: 🏗️ Run Build
run: npm run build
shell: bash
working-directory: ${{ inputs.working-directory }}
- name: Prepare NPM Token
run: echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} > .npmrc
working-directory: ${{ inputs.working-directory }}
shell: bash
env:
NPM_TOKEN: ${{ inputs.token }}
- name: Publish to NPM
- name: 🚀 Publish to NPM
run: npm publish ${{ inputs.folder }} --tag ${{ inputs.tag }} --provenance
shell: bash
working-directory: ${{ inputs.working-directory }}

View File

@@ -6,9 +6,9 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
with:
name: ionic-core
@@ -24,23 +24,23 @@ runs:
name: ionic-angular-server
path: ./packages/angular-server
filename: AngularServerBuild.zip
- name: Create Test App
- name: 🧪 Create Test App
run: ./build.sh ${{ inputs.app }}
shell: bash
working-directory: ./packages/angular/test
- name: Install Dependencies
- name: 🕸️ Install Dependencies
run: npm install
shell: bash
working-directory: ./packages/angular/test/build/${{ inputs.app }}
- name: Install Playwright Browsers
- name: 📦 Install Playwright Browsers
run: npx playwright install
shell: bash
working-directory: ./packages/angular/test/build/${{ inputs.app }}
- name: Sync Built Changes
- name: 🔄 Sync Built Changes
run: npm run sync
shell: bash
working-directory: ./packages/angular/test/build/${{ inputs.app }}
- name: Run Tests
- name: 🧪 Run Tests
run: npm run test
shell: bash
working-directory: ./packages/angular/test/build/${{ inputs.app }}

View File

@@ -3,16 +3,16 @@ description: 'Test Core Clean Build'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
with:
name: ionic-core
path: ./core
filename: CoreBuild.zip
- name: Check Diff
- name: 🔍 Check Diff
run: |
git diff --exit-code || {
echo -e "\033[1;31m⚠ Error: Differences Detected ⚠️\033[0m"

View File

@@ -3,21 +3,21 @@ description: 'Test Core Lint'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
- name: Install Dependencies
node-version: 24.x
- name: 🕸️ Install Dependencies
run: npm ci
working-directory: ./core
shell: bash
- name: Lint
- name: 🖌️ Lint
run: npm run lint
shell: bash
working-directory: ./core
# Lint changes should be pushed
# to the branch before the branch
# is merge eligible.
- name: Check Lint Results
- name: 🔎 Check Lint Results
run: git diff --exit-code
shell: bash
working-directory: ./core

View File

@@ -13,19 +13,19 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
with:
name: ionic-core
path: ./core
filename: CoreBuild.zip
- name: Install Dependencies
- name: 🕸️ Install Dependencies
run: npm install
shell: bash
working-directory: ./core
- name: Test
- name: 🧪 Test
if: inputs.update != 'true'
run: npm run test.e2e.docker.ci ${{ inputs.component }} -- --shard=${{ inputs.shard }}/${{ inputs.totalShards }}
shell: bash
@@ -60,13 +60,13 @@ runs:
fi
shell: bash
working-directory: ./core
- name: Archive Updated Screenshots
- name: 📦 Archive Updated Screenshots
if: inputs.update == 'true' && steps.test-and-update.outputs.hasUpdatedScreenshots == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: updated-screenshots-${{ inputs.shard }}-${{ inputs.totalShards }}
path: UpdatedScreenshots-${{ inputs.shard }}-${{ inputs.totalShards }}.zip
- name: Archive Test Results
- name: 📦 Archive Test Results
# The always() ensures that this step
# runs even if the previous step fails.
# We want the test results to be archived

View File

@@ -6,14 +6,14 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
- name: Install Dependencies
node-version: 24.x
- name: 🕸️ Install Dependencies
run: npm ci
working-directory: ./core
shell: bash
- name: Install Stencil ${{ inputs.stencil-version }}
- name: 📦 Install Stencil ${{ inputs.stencil-version }}
run: npm install @stencil/core@${{ inputs.stencil-version }}
shell: bash
working-directory: ./core
@@ -23,7 +23,7 @@ runs:
name: ionic-core
path: ./core
filename: CoreBuild.zip
- name: Test
- name: 🧪 Test
run: npm run test.spec -- --ci
shell: bash
working-directory: ./core

View File

@@ -6,9 +6,9 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
with:
name: ionic-core
@@ -24,23 +24,23 @@ runs:
name: ionic-react-router
path: ./packages/react-router
filename: ReactRouterBuild.zip
- name: Create Test App
- name: 🧪 Create Test App
run: ./build.sh ${{ inputs.app }}
shell: bash
working-directory: ./packages/react/test
- name: Install Dependencies
- name: 🕸️ Install Dependencies
run: npm install
shell: bash
working-directory: ./packages/react/test/build/${{ inputs.app }}
- name: Sync Built Changes
- name: 🔄 Sync Built Changes
run: npm run sync
shell: bash
working-directory: ./packages/react/test/build/${{ inputs.app }}
- name: Build
- name: 🏗️ Build
run: npm run build
shell: bash
working-directory: ./packages/react/test/build/${{ inputs.app }}
- name: Run Tests
- name: 🧪 Run Tests
run: npm run e2e
shell: bash
working-directory: ./packages/react/test/build/${{ inputs.app }}

View File

@@ -6,9 +6,9 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
with:
name: ionic-core
@@ -24,23 +24,23 @@ runs:
name: ionic-react-router
path: ./packages/react-router
filename: ReactRouterBuild.zip
- name: Create Test App
- name: 🧪 Create Test App
run: ./build.sh ${{ inputs.app }}
shell: bash
working-directory: ./packages/react-router/test
- name: Install Dependencies
- name: 🕸️ Install Dependencies
run: npm install
shell: bash
working-directory: ./packages/react-router/test/build/${{ inputs.app }}
- name: Sync Built Changes
- name: 🔄 Sync Built Changes
run: npm run sync
shell: bash
working-directory: ./packages/react-router/test/build/${{ inputs.app }}
- name: Build
- name: 🏗️ Build
run: npm run build
shell: bash
working-directory: ./packages/react-router/test/build/${{ inputs.app }}
- name: Run Tests
- name: 🧪 Run Tests
run: npm run e2e
shell: bash
working-directory: ./packages/react-router/test/build/${{ inputs.app }}

View File

@@ -6,9 +6,9 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
with:
name: ionic-core
@@ -24,23 +24,23 @@ runs:
name: ionic-vue-router
path: ./packages/vue-router
filename: VueRouterBuild.zip
- name: Create Test App
- name: 🧪 Create Test App
run: ./build.sh ${{ inputs.app }}
shell: bash
working-directory: ./packages/vue/test
- name: Install Dependencies
- name: 📦 Install Dependencies
run: npm install
shell: bash
working-directory: ./packages/vue/test/build/${{ inputs.app }}
- name: Sync
- name: 🔄 Sync
run: npm run sync
shell: bash
working-directory: ./packages/vue/test/build/${{ inputs.app }}
- name: Run Spec Tests
- name: 🧪 Run Spec Tests
run: npm run test:unit
shell: bash
working-directory: ./packages/vue/test/build/${{ inputs.app }}
- name: Run E2E Tests
- name: 🧪 Run E2E Tests
run: npm run test:e2e
shell: bash
working-directory: ./packages/vue/test/build/${{ inputs.app }}

View File

@@ -7,13 +7,13 @@ on:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22.x
- uses: actions/download-artifact@v5
node-version: 24.x
- uses: actions/download-artifact@v6
with:
path: ./artifacts
- name: Extract Archives
- name: 🔎 Extract Archives
# This finds all .zip files in the ./artifacts
# directory, including nested directories.
# It then unzips every .zip to the root directory
@@ -21,7 +21,7 @@ runs:
find . -type f -name 'UpdatedScreenshots-*.zip' -exec unzip -q -o -d ../ {} \;
shell: bash
working-directory: ./artifacts
- name: Push Screenshots
- name: 📸 Push Screenshots
# Configure user as Ionitron
# and push only the changed .png snapshots
# to the remote branch.

View File

@@ -10,10 +10,10 @@ inputs:
runs:
using: 'composite'
steps:
- name: Create Archive
- name: 🗄️ Create Archive
run: zip -q -r ${{ inputs.output }} ${{ inputs.paths }}
shell: bash
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v5
with:
name: ${{ inputs.name }}
path: ${{ inputs.output }}

View File

@@ -22,7 +22,7 @@ jobs:
build-core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: ./.github/workflows/actions/test-core-clean-build
test-core-lint:
needs: [build-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: ./.github/workflows/actions/test-core-lint
test-core-spec:
needs: [build-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: ./.github/workflows/actions/build-vue
build-vue-router:
needs: [build-vue]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: ./.github/workflows/actions/build-angular
build-angular-server:
needs: [build-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: ./.github/workflows/actions/build-react
build-react-router:
needs: [build-react]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: github/codeql-action/init@v4
with:
languages: javascript

View File

@@ -9,7 +9,7 @@ jobs:
outputs:
dev-hash: ${{ steps.create-dev-hash.outputs.DEV_HASH }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
# 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
@@ -30,8 +30,6 @@ jobs:
with:
tag: dev
version: ${{ needs.create-dev-hash.outputs.dev-hash }}
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
get-build:
name: Get your dev build!

View File

@@ -12,7 +12,7 @@ jobs:
outputs:
nightly-hash: ${{ steps.create-nightly-hash.outputs.NIGHTLY_HASH }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
# 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
@@ -35,5 +35,3 @@ jobs:
with:
tag: nightly
version: ${{ needs.create-nightly-hash.outputs.nightly-hash }}
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -14,15 +14,16 @@ on:
preid:
description: 'The prerelease identifier used when doing a prerelease.'
type: string
secrets:
NPM_TOKEN:
required: true
permissions:
contents: read
id-token: write
jobs:
release-core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: ./.github/workflows/actions/publish-npm
with:
scope: '@ionic/core'
@@ -30,7 +31,6 @@ jobs:
version: ${{ inputs.version }}
preid: ${{ inputs.preid }}
working-directory: 'core'
token: ${{ secrets.NPM_TOKEN }}
- name: Cache Built @ionic/core
uses: ./.github/workflows/actions/upload-archive
with:
@@ -48,7 +48,7 @@ jobs:
needs: [release-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Restore @ionic/docs built cache
uses: ./.github/workflows/actions/download-archive
with:
@@ -62,13 +62,12 @@ jobs:
version: ${{ inputs.version }}
preid: ${{ inputs.preid }}
working-directory: 'packages/docs'
token: ${{ secrets.NPM_TOKEN }}
release-angular:
needs: [release-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Restore @ionic/core built cache
uses: ./.github/workflows/actions/download-archive
with:
@@ -83,7 +82,6 @@ jobs:
preid: ${{ inputs.preid }}
working-directory: 'packages/angular'
folder: './dist'
token: ${{ secrets.NPM_TOKEN }}
- name: Cache Built @ionic/angular
uses: ./.github/workflows/actions/upload-archive
with:
@@ -95,7 +93,7 @@ jobs:
needs: [release-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Restore @ionic/core built cache
uses: ./.github/workflows/actions/download-archive
with:
@@ -109,7 +107,6 @@ jobs:
version: ${{ inputs.version }}
preid: ${{ inputs.preid }}
working-directory: 'packages/react'
token: ${{ secrets.NPM_TOKEN }}
- name: Cache Built @ionic/react
uses: ./.github/workflows/actions/upload-archive
with:
@@ -121,7 +118,7 @@ jobs:
needs: [release-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Restore @ionic/core built cache
uses: ./.github/workflows/actions/download-archive
with:
@@ -135,7 +132,6 @@ jobs:
version: ${{ inputs.version }}
preid: ${{ inputs.preid }}
working-directory: 'packages/vue'
token: ${{ secrets.NPM_TOKEN }}
- name: Cache Built @ionic/vue
uses: ./.github/workflows/actions/upload-archive
with:
@@ -147,7 +143,7 @@ jobs:
needs: [release-core]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Restore @ionic/core built cache
uses: ./.github/workflows/actions/download-archive
with:
@@ -162,13 +158,12 @@ jobs:
preid: ${{ inputs.preid }}
working-directory: 'packages/angular-server'
folder: './dist'
token: ${{ secrets.NPM_TOKEN }}
release-react-router:
needs: [release-react]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Restore @ionic/core built cache
uses: ./.github/workflows/actions/download-archive
with:
@@ -188,13 +183,12 @@ jobs:
version: ${{ inputs.version }}
preid: ${{ inputs.preid }}
working-directory: 'packages/react-router'
token: ${{ secrets.NPM_TOKEN }}
release-vue-router:
needs: [release-vue]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Restore @ionic/core built cache
uses: ./.github/workflows/actions/download-archive
with:
@@ -214,4 +208,3 @@ jobs:
version: ${{ inputs.version }}
preid: ${{ inputs.preid }}
working-directory: 'packages/vue-router'
token: ${{ secrets.NPM_TOKEN }}

View File

@@ -25,6 +25,7 @@ on:
preid:
type: choice
description: Which prerelease identifier should be used? This is only needed when version is "prepatch", "preminor", "premajor", or "prerelease".
default: ''
options:
- ''
- alpha
@@ -35,20 +36,19 @@ on:
jobs:
release-ionic:
permissions:
contents: read
id-token: write
uses: ./.github/workflows/release-ionic.yml
with:
tag: ${{ inputs.tag }}
version: ${{ inputs.version }}
preid: ${{ inputs.preid }}
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
finalize-release:
needs: [release-ionic]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
token: ${{ secrets.IONITRON_TOKEN }}
fetch-depth: 0
@@ -76,7 +76,7 @@ jobs:
needs: [finalize-release]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
# 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: ./.github/workflows/actions/test-core-lint
test-core-spec:
needs: [build-core-with-stencil-nightly]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: ./.github/workflows/actions/build-vue
build-vue-router:
needs: [build-vue]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: ./.github/workflows/actions/build-angular
build-angular-server:
needs: [build-core-with-stencil-nightly]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: ./.github/workflows/actions/build-react
build-react-router:
needs: [build-react]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: ./.github/workflows/actions/test-react-e2e
with:
app: ${{ matrix.apps }}
@@ -225,3 +225,35 @@ jobs:
- name: Check build matrix status
if: ${{ needs.test-react-e2e.result != 'success' }}
run: exit 1
send-success-messages:
needs: [test-core-clean-build, test-core-lint, test-core-spec, verify-screenshots, verify-test-vue-e2e, verify-test-angular-e2e, verify-test-react-router-e2e, verify-test-react-e2e]
runs-on: ubuntu-latest
if: ${{ !cancelled() && contains(needs.*.result, 'success') }}
steps:
- name: Notify success on Discord
run: |
curl -H "Content-Type:application/json" \
-d '{"embeds": [{"title": "✅ Workflow ${{github.workflow}} #${{github.run_number}} finished successfully", "color": 65280, "url": "${{github.server_url}}/${{github.repository}}/actions/runs/${{github.run_id}}"}]}' \
${{secrets.DISCORD_NOTIFY_WEBHOOK}}
- name: Notify success on Slack
run: |
curl -H "Content-Type:application/json" \
-d '{"title": "✅ Workflow ${{github.workflow}} #${{github.run_number}} finished successfully", "url": "${{github.server_url}}/${{github.repository}}/actions/runs/${{github.run_id}}"}' \
${{secrets.SLACK_NOTIFY_SUCCESS_WEBHOOK}}
send-failure-messages:
needs: [test-core-clean-build, test-core-lint, test-core-spec, verify-screenshots, verify-test-vue-e2e, verify-test-angular-e2e, verify-test-react-router-e2e, verify-test-react-e2e]
runs-on: ubuntu-latest
if: ${{ !cancelled() && contains(needs.*.result, 'failure') }}
steps:
- name: Notify failure on Discord
run: |
curl -H "Content-Type:application/json" \
-d '{"content": "Alerting <@&1347593178580254761>!", "embeds": [{"title": "❌ Workflow ${{github.workflow}} #${{github.run_number}} failed", "color": 16711680, "url": "${{github.server_url}}/${{github.repository}}/actions/runs/${{github.run_id}}"}]}' \
${{secrets.DISCORD_NOTIFY_WEBHOOK}}
- name: Notify failure on Slack
run: |
curl -H "Content-Type:application/json" \
-d '{"title": "❌ Workflow ${{github.workflow}} #${{github.run_number}} failed", "url": "${{github.server_url}}/${{github.repository}}/actions/runs/${{github.run_id}}"}' \
${{secrets.SLACK_NOTIFY_FAILURE_WEBHOOK}}

View File

@@ -26,7 +26,7 @@ jobs:
build-core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@v5
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
# Normally, we could just push with the
# default GITHUB_TOKEN, but that will
# not cause the build workflow

View File

@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [8.7.9](https://github.com/ionic-team/ionic-framework/compare/v8.7.8...v8.7.9) (2025-11-05)
### Bug Fixes
* **accordion-group:** skip initial animation ([#30729](https://github.com/ionic-team/ionic-framework/issues/30729)) ([58d5638](https://github.com/ionic-team/ionic-framework/commit/58d563805fca1db88caeeb40a8f710ac30416d93)), closes [#30613](https://github.com/ionic-team/ionic-framework/issues/30613)
## [8.7.8](https://github.com/ionic-team/ionic-framework/compare/v8.7.7...v8.7.8) (2025-10-29)
### Bug Fixes
* **checkbox, toggle:** fire ionFocus and ionBlur ([#30733](https://github.com/ionic-team/ionic-framework/issues/30733)) ([54a1c86](https://github.com/ionic-team/ionic-framework/commit/54a1c86d6a5d533b0c8c2d18edc62454a7c17bab))
## [8.7.7](https://github.com/ionic-team/ionic-framework/compare/v8.7.6...v8.7.7) (2025-10-15)

View File

@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [8.7.9](https://github.com/ionic-team/ionic-framework/compare/v8.7.8...v8.7.9) (2025-11-05)
### Bug Fixes
* **accordion-group:** skip initial animation ([#30729](https://github.com/ionic-team/ionic-framework/issues/30729)) ([58d5638](https://github.com/ionic-team/ionic-framework/commit/58d563805fca1db88caeeb40a8f710ac30416d93)), closes [#30613](https://github.com/ionic-team/ionic-framework/issues/30613)
## [8.7.8](https://github.com/ionic-team/ionic-framework/compare/v8.7.7...v8.7.8) (2025-10-29)
### Bug Fixes
* **checkbox, toggle:** fire ionFocus and ionBlur ([#30733](https://github.com/ionic-team/ionic-framework/issues/30733)) ([54a1c86](https://github.com/ionic-team/ionic-framework/commit/54a1c86d6a5d533b0c8c2d18edc62454a7c17bab))
## [8.7.7](https://github.com/ionic-team/ionic-framework/compare/v8.7.6...v8.7.7) (2025-10-15)

View File

@@ -1,5 +1,5 @@
# Get Playwright
FROM mcr.microsoft.com/playwright:v1.56.0
FROM mcr.microsoft.com/playwright:v1.56.1
# Set the working directory
WORKDIR /ionic

94
core/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@ionic/core",
"version": "8.7.7",
"version": "8.7.9",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@ionic/core",
"version": "8.7.7",
"version": "8.7.9",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
@@ -14,7 +14,7 @@
"tslib": "^2.1.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.2",
"@axe-core/playwright": "^4.11.0",
"@capacitor/core": "^7.0.0",
"@capacitor/haptics": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
@@ -22,7 +22,7 @@
"@clack/prompts": "^0.11.0",
"@ionic/eslint-config": "^0.3.0",
"@ionic/prettier-config": "^2.0.0",
"@playwright/test": "^1.56.0",
"@playwright/test": "^1.56.1",
"@rollup/plugin-node-resolve": "^8.4.0",
"@rollup/plugin-virtual": "^2.0.3",
"@stencil/angular-output-target": "^0.10.0",
@@ -57,12 +57,12 @@
"dev": true
},
"node_modules/@axe-core/playwright": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.10.2.tgz",
"integrity": "sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==",
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.0.tgz",
"integrity": "sha512-70vBT/Ylqpm65RQz2iCG2o0JJCEG/WCNyefTr2xcOcr1CoSee60gNQYUMZZ7YukoKkFLv26I/jjlsvwwp532oQ==",
"dev": true,
"dependencies": {
"axe-core": "~4.10.3"
"axe-core": "~4.11.0"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
@@ -663,9 +663,9 @@
"dev": true
},
"node_modules/@capacitor/core": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.3.tgz",
"integrity": "sha512-wCWr8fQ9Wxn0466vPg7nMn0tivbNVjNy1yL4GvDSIZuZx7UpU2HeVGNe9QjN/quEd+YLRFeKEBLBw619VqUiNg==",
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.4.tgz",
"integrity": "sha512-xzjxpr+d2zwTpCaN0k+C6wKSZzWFAb9OVEUtmO72ihjr/NEDoLvsGl4WLfjWPcCO2zOy0b2X52tfRWjECFUjtw==",
"dev": true,
"dependencies": {
"tslib": "^2.1.0"
@@ -1715,12 +1715,12 @@
}
},
"node_modules/@playwright/test": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz",
"integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==",
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
"dev": true,
"dependencies": {
"playwright": "1.56.0"
"playwright": "1.56.1"
},
"bin": {
"playwright": "cli.js"
@@ -3033,9 +3033,9 @@
}
},
"node_modules/axe-core": {
"version": "4.10.3",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
"integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==",
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz",
"integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==",
"dev": true,
"engines": {
"node": ">=4"
@@ -8592,12 +8592,12 @@
}
},
"node_modules/playwright": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz",
"integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"dev": true,
"dependencies": {
"playwright-core": "1.56.0"
"playwright-core": "1.56.1"
},
"bin": {
"playwright": "cli.js"
@@ -8610,9 +8610,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz",
"integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==",
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
@@ -10652,12 +10652,12 @@
},
"dependencies": {
"@axe-core/playwright": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.10.2.tgz",
"integrity": "sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==",
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.0.tgz",
"integrity": "sha512-70vBT/Ylqpm65RQz2iCG2o0JJCEG/WCNyefTr2xcOcr1CoSee60gNQYUMZZ7YukoKkFLv26I/jjlsvwwp532oQ==",
"dev": true,
"requires": {
"axe-core": "~4.10.3"
"axe-core": "~4.11.0"
}
},
"@babel/code-frame": {
@@ -11101,9 +11101,9 @@
"dev": true
},
"@capacitor/core": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.3.tgz",
"integrity": "sha512-wCWr8fQ9Wxn0466vPg7nMn0tivbNVjNy1yL4GvDSIZuZx7UpU2HeVGNe9QjN/quEd+YLRFeKEBLBw619VqUiNg==",
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.4.tgz",
"integrity": "sha512-xzjxpr+d2zwTpCaN0k+C6wKSZzWFAb9OVEUtmO72ihjr/NEDoLvsGl4WLfjWPcCO2zOy0b2X52tfRWjECFUjtw==",
"dev": true,
"requires": {
"tslib": "^2.1.0"
@@ -11862,12 +11862,12 @@
}
},
"@playwright/test": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz",
"integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==",
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
"dev": true,
"requires": {
"playwright": "1.56.0"
"playwright": "1.56.1"
}
},
"@rollup/plugin-node-resolve": {
@@ -12770,9 +12770,9 @@
}
},
"axe-core": {
"version": "4.10.3",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
"integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==",
"version": "4.11.0",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz",
"integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==",
"dev": true
},
"babel-jest": {
@@ -16811,19 +16811,19 @@
}
},
"playwright": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz",
"integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"dev": true,
"requires": {
"fsevents": "2.3.2",
"playwright-core": "1.56.0"
"playwright-core": "1.56.1"
}
},
"playwright-core": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz",
"integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==",
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true
},
"postcss": {
@@ -18337,4 +18337,4 @@
"dev": true
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@ionic/core",
"version": "8.7.7",
"version": "8.7.9",
"description": "Base components for Ionic",
"keywords": [
"ionic",
@@ -36,7 +36,7 @@
"tslib": "^2.1.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.2",
"@axe-core/playwright": "^4.11.0",
"@capacitor/core": "^7.0.0",
"@capacitor/haptics": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
@@ -44,7 +44,7 @@
"@clack/prompts": "^0.11.0",
"@ionic/eslint-config": "^0.3.0",
"@ionic/prettier-config": "^2.0.0",
"@playwright/test": "^1.56.0",
"@playwright/test": "^1.56.1",
"@rollup/plugin-node-resolve": "^8.4.0",
"@rollup/plugin-virtual": "^2.0.3",
"@stencil/angular-output-target": "^0.10.0",

View File

@@ -38,7 +38,40 @@ const enum AccordionState {
})
export class Accordion implements ComponentInterface {
private accordionGroupEl?: HTMLIonAccordionGroupElement | null;
private updateListener = () => this.updateState(false);
private accordionGroupUpdateHandler = () => {
/**
* Determine if this update will cause an actual state change.
* We only want to mark as "interacted" if the state is changing.
*/
const accordionGroup = this.accordionGroupEl;
if (accordionGroup) {
const value = accordionGroup.value;
const accordionValue = this.value;
const shouldExpand = Array.isArray(value) ? value.includes(accordionValue) : value === accordionValue;
const isExpanded = this.state === AccordionState.Expanded || this.state === AccordionState.Expanding;
const stateWillChange = shouldExpand !== isExpanded;
/**
* Only mark as interacted if:
* 1. This is not the first update we've received with a defined value
* 2. The state is actually changing (prevents redundant updates from enabling animations)
*/
if (this.hasReceivedFirstUpdate && stateWillChange) {
this.hasInteracted = true;
}
/**
* Only count this as the first update if the group value is defined.
* This prevents the initial undefined value from the group's componentDidLoad
* from being treated as the first real update.
*/
if (value !== undefined) {
this.hasReceivedFirstUpdate = true;
}
}
this.updateState();
};
private contentEl: HTMLDivElement | undefined;
private contentElWrapper: HTMLDivElement | undefined;
private headerEl: HTMLDivElement | undefined;
@@ -50,6 +83,25 @@ export class Accordion implements ComponentInterface {
@State() state: AccordionState = AccordionState.Collapsed;
@State() isNext = false;
@State() isPrevious = false;
/**
* Tracks whether a user-initiated interaction has occurred.
* Animations are disabled until the first interaction happens.
* This prevents the accordion from animating when it's programmatically
* set to an expanded or collapsed state on initial load.
*/
@State() hasInteracted = false;
/**
* Tracks if this accordion has ever been expanded.
* Used to prevent the first expansion from animating.
*/
private hasEverBeenExpanded = false;
/**
* Tracks if this accordion has received its first update from the group.
* Used to distinguish initial programmatic sets from user interactions.
*/
private hasReceivedFirstUpdate = false;
/**
* The value of the accordion. Defaults to an autogenerated
@@ -88,15 +140,15 @@ export class Accordion implements ComponentInterface {
connectedCallback() {
const accordionGroupEl = (this.accordionGroupEl = this.el?.closest('ion-accordion-group'));
if (accordionGroupEl) {
this.updateState(true);
addEventListener(accordionGroupEl, 'ionValueChange', this.updateListener);
this.updateState();
addEventListener(accordionGroupEl, 'ionValueChange', this.accordionGroupUpdateHandler);
}
}
disconnectedCallback() {
const accordionGroupEl = this.accordionGroupEl;
if (accordionGroupEl) {
removeEventListener(accordionGroupEl, 'ionValueChange', this.updateListener);
removeEventListener(accordionGroupEl, 'ionValueChange', this.accordionGroupUpdateHandler);
}
}
@@ -212,10 +264,16 @@ export class Accordion implements ComponentInterface {
ionItem.appendChild(iconEl);
};
private expandAccordion = (initialUpdate = false) => {
private expandAccordion = () => {
const { contentEl, contentElWrapper } = this;
if (initialUpdate || contentEl === undefined || contentElWrapper === undefined) {
/**
* If the content elements aren't available yet, just set the state.
* This happens on initial render before the DOM is ready.
*/
if (contentEl === undefined || contentElWrapper === undefined) {
this.state = AccordionState.Expanded;
this.hasEverBeenExpanded = true;
return;
}
@@ -227,6 +285,12 @@ export class Accordion implements ComponentInterface {
cancelAnimationFrame(this.currentRaf);
}
/**
* Mark that this accordion has been expanded at least once.
* This allows subsequent expansions to animate.
*/
this.hasEverBeenExpanded = true;
if (this.shouldAnimate()) {
raf(() => {
this.state = AccordionState.Expanding;
@@ -247,9 +311,14 @@ export class Accordion implements ComponentInterface {
}
};
private collapseAccordion = (initialUpdate = false) => {
private collapseAccordion = () => {
const { contentEl } = this;
if (initialUpdate || contentEl === undefined) {
/**
* If the content element isn't available yet, just set the state.
* This happens on initial render before the DOM is ready.
*/
if (contentEl === undefined) {
this.state = AccordionState.Collapsed;
return;
}
@@ -291,6 +360,19 @@ export class Accordion implements ComponentInterface {
* of what is set in the config.
*/
private shouldAnimate = () => {
/**
* Don't animate until after the first user interaction.
* This prevents animations on initial load when accordions
* start in an expanded or collapsed state programmatically.
*
* Additionally, don't animate the very first expansion even if
* hasInteracted is true. This handles edge cases like React StrictMode
* where effects run twice and might incorrectly mark as interacted.
*/
if (!this.hasInteracted || !this.hasEverBeenExpanded) {
return false;
}
if (typeof (window as any) === 'undefined') {
return false;
}
@@ -312,7 +394,7 @@ export class Accordion implements ComponentInterface {
return true;
};
private updateState = async (initialUpdate = false) => {
private updateState = async () => {
const accordionGroup = this.accordionGroupEl;
const accordionValue = this.value;
@@ -325,10 +407,10 @@ export class Accordion implements ComponentInterface {
const shouldExpand = Array.isArray(value) ? value.includes(accordionValue) : value === accordionValue;
if (shouldExpand) {
this.expandAccordion(initialUpdate);
this.expandAccordion();
this.isNext = this.isPrevious = false;
} else {
this.collapseAccordion(initialUpdate);
this.collapseAccordion();
/**
* When using popout or inset,
@@ -386,6 +468,12 @@ export class Accordion implements ComponentInterface {
if (disabled || readonly) return;
/**
* Mark that the user has interacted with the accordion.
* This enables animations for all future state changes.
*/
this.hasInteracted = true;
if (accordionGroupEl) {
/**
* Because the accordion group may or may

View File

@@ -200,6 +200,87 @@ it('should set default values if not provided', async () => {
expect(accordion.classList.contains('accordion-collapsed')).toEqual(false);
});
it('should not animate when initial value is set before load', async () => {
const page = await newSpecPage({
components: [Item, Accordion, AccordionGroup],
});
const accordionGroup = page.doc.createElement('ion-accordion-group');
accordionGroup.innerHTML = `
<ion-accordion value="first">
<ion-item slot="header">Label</ion-item>
<div slot="content">Content</div>
</ion-accordion>
<ion-accordion value="second">
<ion-item slot="header">Label</ion-item>
<div slot="content">Content</div>
</ion-accordion>
`;
accordionGroup.value = 'first';
page.body.appendChild(accordionGroup);
await page.waitForChanges();
const firstAccordion = accordionGroup.querySelector('ion-accordion[value="first"]')!;
expect(firstAccordion.classList.contains('accordion-expanded')).toEqual(true);
expect(firstAccordion.classList.contains('accordion-expanding')).toEqual(false);
});
it('should not animate when initial value is set after load', async () => {
const page = await newSpecPage({
components: [Item, Accordion, AccordionGroup],
});
const accordionGroup = page.doc.createElement('ion-accordion-group');
accordionGroup.innerHTML = `
<ion-accordion value="first">
<ion-item slot="header">Label</ion-item>
<div slot="content">Content</div>
</ion-accordion>
<ion-accordion value="second">
<ion-item slot="header">Label</ion-item>
<div slot="content">Content</div>
</ion-accordion>
`;
page.body.appendChild(accordionGroup);
await page.waitForChanges();
accordionGroup.value = 'first';
await page.waitForChanges();
const firstAccordion = accordionGroup.querySelector('ion-accordion[value="first"]')!;
expect(firstAccordion.classList.contains('accordion-expanded')).toEqual(true);
expect(firstAccordion.classList.contains('accordion-expanding')).toEqual(false);
});
it('should not have animated class on first expansion', async () => {
const page = await newSpecPage({
components: [Item, Accordion, AccordionGroup],
html: `
<ion-accordion-group>
<ion-accordion value="first">
<ion-item slot="header">Label</ion-item>
<div slot="content">Content</div>
</ion-accordion>
</ion-accordion-group>
`,
});
const accordionGroup = page.body.querySelector('ion-accordion-group')!;
const firstAccordion = page.body.querySelector('ion-accordion[value="first"]')!;
// First expansion should not have the animated class
accordionGroup.value = 'first';
await page.waitForChanges();
expect(firstAccordion.classList.contains('accordion-animated')).toEqual(false);
expect(firstAccordion.classList.contains('accordion-expanded')).toEqual(true);
});
// Verifies fix for https://github.com/ionic-team/ionic-framework/issues/27047
it('should not have animated class when animated="false"', async () => {
const page = await newSpecPage({

View File

@@ -1,5 +1,6 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, h } from '@stencil/core';
import { Build, Component, Element, Event, Host, Method, Prop, State, h } from '@stencil/core';
import { checkInvalidState } from '@utils/forms';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, renderHiddenInput } from '@utils/helpers';
import { createColorClasses, hostContext } from '@utils/theme';
@@ -34,8 +35,8 @@ export class Checkbox implements ComponentInterface {
private inputLabelId = `${this.inputId}-lbl`;
private helperTextId = `${this.inputId}-helper-text`;
private errorTextId = `${this.inputId}-error-text`;
private focusEl?: HTMLElement;
private inheritedAttributes: Attributes = {};
private validationObserver?: MutationObserver;
@Element() el!: HTMLIonCheckboxElement;
@@ -121,6 +122,13 @@ export class Checkbox implements ComponentInterface {
*/
@Prop() required = false;
/**
* Track validation state for proper aria-live announcements.
*/
@State() isInvalid = false;
@State() private hintTextId?: string;
/**
* Emitted when the checked property has changed as a result of a user action such as a click.
*
@@ -138,18 +146,69 @@ export class Checkbox implements ComponentInterface {
*/
@Event() ionBlur!: EventEmitter<void>;
connectedCallback() {
const { el } = this;
// Watch for class changes to update validation state.
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
this.validationObserver = new MutationObserver(() => {
const newIsInvalid = checkInvalidState(el);
if (this.isInvalid !== newIsInvalid) {
this.isInvalid = newIsInvalid;
/**
* Screen readers tend to announce changes
* to `aria-describedby` when the attribute
* is changed during a blur event for a
* native form control.
* However, the announcement can be spotty
* when using a non-native form control
* and `forceUpdate()`.
* This is due to `forceUpdate()` internally
* rescheduling the DOM update to a lower
* priority queue regardless if it's called
* inside a Promise or not, thus causing
* the screen reader to potentially miss the
* change.
* By using a State variable inside a Promise,
* it guarantees a re-render immediately at
* a higher priority.
*/
Promise.resolve().then(() => {
this.hintTextId = this.getHintTextId();
});
}
});
this.validationObserver.observe(el, {
attributes: true,
attributeFilter: ['class'],
});
}
// Always set initial state
this.isInvalid = checkInvalidState(el);
}
componentWillLoad() {
this.inheritedAttributes = {
...inheritAriaAttributes(this.el),
};
this.hintTextId = this.getHintTextId();
}
disconnectedCallback() {
// Clean up validation observer to prevent memory leaks.
if (this.validationObserver) {
this.validationObserver.disconnect();
this.validationObserver = undefined;
}
}
/** @internal */
@Method()
async setFocus() {
if (this.focusEl) {
this.focusEl.focus();
}
this.el.focus();
}
/**
@@ -169,7 +228,6 @@ export class Checkbox implements ComponentInterface {
private toggleChecked = (ev: Event) => {
ev.preventDefault();
this.setFocus();
this.setChecked(!this.checked);
this.indeterminate = false;
};
@@ -207,10 +265,10 @@ export class Checkbox implements ComponentInterface {
ev.stopPropagation();
};
private getHintTextID(): string | undefined {
const { el, helperText, errorText, helperTextId, errorTextId } = this;
private getHintTextId(): string | undefined {
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
if (isInvalid && errorText) {
return errorTextId;
}
@@ -226,7 +284,7 @@ export class Checkbox implements ComponentInterface {
* This element should only be rendered if hint text is set.
*/
private renderHintText() {
const { helperText, errorText, helperTextId, errorTextId } = this;
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
/**
* undefined and empty string values should
@@ -239,11 +297,11 @@ export class Checkbox implements ComponentInterface {
return (
<div class="checkbox-bottom">
<div id={helperTextId} class="helper-text" part="supporting-text helper-text">
{helperText}
<div id={helperTextId} class="helper-text" part="supporting-text helper-text" aria-live="polite">
{!isInvalid ? helperText : null}
</div>
<div id={errorTextId} class="error-text" part="supporting-text error-text">
{errorText}
<div id={errorTextId} class="error-text" part="supporting-text error-text" role="alert">
{isInvalid ? errorText : null}
</div>
</div>
);
@@ -278,13 +336,17 @@ export class Checkbox implements ComponentInterface {
<Host
role="checkbox"
aria-checked={indeterminate ? 'mixed' : `${checked}`}
aria-describedby={this.getHintTextID()}
aria-invalid={this.getHintTextID() === this.errorTextId}
aria-describedby={this.hintTextId}
aria-invalid={this.isInvalid ? 'true' : undefined}
aria-labelledby={hasLabelContent ? this.inputLabelId : null}
aria-label={inheritedAttributes['aria-label'] || null}
aria-disabled={disabled ? 'true' : null}
aria-required={required ? 'true' : undefined}
tabindex={disabled ? undefined : 0}
onKeyDown={this.onKeyDown}
onFocus={this.onFocus}
onBlur={this.onBlur}
onClick={this.onClick}
class={createColorClasses(color, {
[mode]: true,
'in-item': hostContext('ion-item', el),
@@ -296,7 +358,6 @@ export class Checkbox implements ComponentInterface {
[`checkbox-alignment-${alignment}`]: alignment !== undefined,
[`checkbox-label-placement-${labelPlacement}`]: true,
})}
onClick={this.onClick}
>
<label class="checkbox-wrapper" htmlFor={inputId}>
{/*
@@ -309,9 +370,6 @@ export class Checkbox implements ComponentInterface {
disabled={disabled}
id={inputId}
onChange={this.toggleChecked}
onFocus={() => this.onFocus()}
onBlur={() => this.onBlur()}
ref={(focusEl) => (this.focusEl = focusEl)}
required={required}
{...inheritedAttributes}
/>

View File

@@ -44,7 +44,10 @@ configs().forEach(({ title, screenshot, config }) => {
});
});
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
/**
* This behavior does not vary across modes/directions
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('checkbox: ionChange'), () => {
test('should fire ionChange when interacting with checkbox', async ({ page }) => {
await page.setContent(
@@ -133,4 +136,195 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
expect(clickCount).toBe(1);
});
});
test.describe(title('checkbox: ionFocus'), () => {
test('should not have visual regressions', async ({ page, pageUtils }) => {
await page.setContent(
`
<style>
#container {
display: inline-block;
padding: 10px;
}
</style>
<div id="container">
<ion-checkbox>Unchecked</ion-checkbox>
</div>
`,
config
);
await pageUtils.pressKeys('Tab');
const container = page.locator('#container');
await expect(container).toHaveScreenshot(screenshot(`checkbox-focus`));
});
test('should not have visual regressions when interacting with checkbox in item', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-item class="ion-focused">
<ion-checkbox>Unchecked</ion-checkbox>
</ion-item>
`,
config
);
// Test focus with keyboard navigation
await pageUtils.pressKeys('Tab');
const item = page.locator('ion-item');
await expect(item).toHaveScreenshot(screenshot(`checkbox-in-item-focus`));
});
test('should fire ionFocus when checkbox is focused', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
`,
config
);
const ionFocus = await page.spyOnEvent('ionFocus');
// Test focus with keyboard navigation
await pageUtils.pressKeys('Tab');
expect(ionFocus).toHaveReceivedEventTimes(1);
// Reset focus
const checkbox = page.locator('ion-checkbox');
const checkboxBoundingBox = (await checkbox.boundingBox())!;
await page.mouse.click(0, checkboxBoundingBox.height + 1);
// Test focus with click
await checkbox.click();
expect(ionFocus).toHaveReceivedEventTimes(2);
});
test('should fire ionFocus when interacting with checkbox in item', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-item>
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
</ion-item>
`,
config
);
const ionFocus = await page.spyOnEvent('ionFocus');
// Test focus with keyboard navigation
await pageUtils.pressKeys('Tab');
expect(ionFocus).toHaveReceivedEventTimes(1);
// Verify that the event target is the checkbox and not the item
const eventByKeyboard = ionFocus.events[0];
expect((eventByKeyboard.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
// Reset focus
const checkbox = page.locator('ion-checkbox');
const checkboxBoundingBox = (await checkbox.boundingBox())!;
await page.mouse.click(0, checkboxBoundingBox.height + 1);
// Test focus with click
const item = page.locator('ion-item');
await item.click();
expect(ionFocus).toHaveReceivedEventTimes(2);
// Verify that the event target is the checkbox and not the item
const eventByClick = ionFocus.events[0];
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
});
test('should not fire when programmatically setting a value', async ({ page }) => {
await page.setContent(
`
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
`,
config
);
const ionFocus = await page.spyOnEvent('ionFocus');
const checkbox = page.locator('ion-checkbox');
await checkbox.evaluate((el: HTMLIonCheckboxElement) => (el.checked = true));
expect(ionFocus).not.toHaveReceivedEvent();
});
});
test.describe(title('checkbox: ionBlur'), () => {
test('should fire ionBlur when checkbox is blurred', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
`,
config
);
const ionBlur = await page.spyOnEvent('ionBlur');
// Test blur with keyboard navigation
// Focus the checkbox
await pageUtils.pressKeys('Tab');
// Blur the checkbox
await pageUtils.pressKeys('Tab');
expect(ionBlur).toHaveReceivedEventTimes(1);
// Test blur with click
const checkbox = page.locator('ion-checkbox');
// Focus the checkbox
await checkbox.click();
// Blur the checkbox by clicking outside of it
const checkboxBoundingBox = (await checkbox.boundingBox())!;
await page.mouse.click(0, checkboxBoundingBox.height + 1);
expect(ionBlur).toHaveReceivedEventTimes(2);
});
test('should fire ionBlur after interacting with checkbox in item', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-item>
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
</ion-item>
`,
config
);
const ionBlur = await page.spyOnEvent('ionBlur');
// Test blur with keyboard navigation
// Focus the checkbox
await pageUtils.pressKeys('Tab');
// Blur the checkbox
await pageUtils.pressKeys('Tab');
expect(ionBlur).toHaveReceivedEventTimes(1);
// Verify that the event target is the checkbox and not the item
const event = ionBlur.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
// Test blur with click
const item = page.locator('ion-item');
await item.click();
// Blur the checkbox by clicking outside of it
const itemBoundingBox = (await item.boundingBox())!;
await page.mouse.click(0, itemBoundingBox.height + 1);
expect(ionBlur).toHaveReceivedEventTimes(2);
// Verify that the event target is the checkbox and not the item
const eventByClick = ionBlur.events[0];
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
});
});
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -50,6 +50,20 @@
<ion-checkbox checked style="width: 200px">Specified width</ion-checkbox><br />
<ion-checkbox checked style="width: 100%">Full-width</ion-checkbox><br />
</ion-content>
<script>
document.addEventListener('ionBlur', (ev) => {
console.log('ionBlur', ev);
});
document.addEventListener('ionChange', (ev) => {
console.log('ionChange', ev);
});
document.addEventListener('ionFocus', (ev) => {
console.log('ionFocus', ev);
});
</script>
</ion-app>
</body>
</html>

View File

@@ -246,6 +246,20 @@
</div>
</div>
</ion-content>
<script>
document.addEventListener('ionBlur', (ev) => {
console.log('ionBlur', ev);
});
document.addEventListener('ionChange', (ev) => {
console.log('ionChange', ev);
});
document.addEventListener('ionFocus', (ev) => {
console.log('ionFocus', ev);
});
</script>
</ion-app>
</body>
</html>

View File

@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Checkbox - Validation</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>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-row-gap: 30px;
grid-column-gap: 30px;
}
h2 {
font-size: 12px;
font-weight: normal;
color: var(--ion-color-step-600);
margin-top: 10px;
margin-bottom: 5px;
}
.validation-info {
margin: 20px;
padding: 10px;
background: var(--ion-color-light);
border-radius: 4px;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Checkbox - Validation Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="validation-info">
<h2>Screen Reader Testing Instructions:</h2>
<ol>
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
<li>Tab through the form fields</li>
<li>When you tab away from an empty required field, the error should be announced immediately</li>
<li>The error text should be announced BEFORE the next field is announced</li>
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
</ol>
</div>
<div class="grid">
<div>
<h2>Required Field</h2>
<ion-checkbox
id="terms-checkbox"
helper-text="You must agree to continue"
error-text="Please accept the terms and conditions"
required
>I agree to the terms and conditions</ion-checkbox
>
</div>
<div>
<h2>Optional Field (No Validation)</h2>
<ion-checkbox id="optional-checkbox" helper-text="You can skip this field">Optional Checkbox</ion-checkbox>
</div>
</div>
<div class="ion-padding">
<ion-button id="submit-btn" expand="block" disabled>Submit Form</ion-button>
<ion-button id="reset-btn" expand="block" fill="outline">Reset Form</ion-button>
</div>
</ion-content>
</ion-app>
<script>
// Simple validation logic
const checkboxes = document.querySelectorAll('ion-checkbox');
const submitBtn = document.getElementById('submit-btn');
const resetBtn = document.getElementById('reset-btn');
// Track which fields have been touched
const touchedFields = new Set();
// Validation functions
const validators = {
'terms-checkbox': (checked) => {
return checked === true;
},
'optional-checkbox': () => true, // Always valid
};
function validateField(checkbox) {
const checkboxId = checkbox.id;
const checked = checkbox.checked;
const isValid = validators[checkboxId] ? validators[checkboxId](checked) : true;
// Only show validation state if field has been touched
if (touchedFields.has(checkboxId)) {
if (isValid) {
checkbox.classList.remove('ion-invalid');
checkbox.classList.add('ion-valid');
} else {
checkbox.classList.remove('ion-valid');
checkbox.classList.add('ion-invalid');
}
checkbox.classList.add('ion-touched');
}
return isValid;
}
function validateForm() {
let allValid = true;
checkboxes.forEach((checkbox) => {
if (checkbox.id !== 'optional-checkbox') {
const isValid = validateField(checkbox);
if (!isValid) {
allValid = false;
}
}
});
submitBtn.disabled = !allValid;
return allValid;
}
// Add event listeners
checkboxes.forEach((checkbox) => {
// Mark as touched on blur
checkbox.addEventListener('ionBlur', (e) => {
console.log('Blur event on:', checkbox.id);
touchedFields.add(checkbox.id);
validateField(checkbox);
validateForm();
const isInvalid = checkbox.classList.contains('ion-invalid');
if (isInvalid) {
console.log('Field marked invalid:', checkbox.innerText, checkbox.errorText);
}
});
// Validate on change
checkbox.addEventListener('ionChange', (e) => {
console.log('Change event on:', checkbox.id);
if (touchedFields.has(checkbox.id)) {
validateField(checkbox);
validateForm();
}
});
});
// Reset button
resetBtn.addEventListener('click', () => {
checkboxes.forEach((checkbox) => {
checkbox.checked = false;
checkbox.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
});
touchedFields.clear();
submitBtn.disabled = true;
});
// Submit button
submitBtn.addEventListener('click', () => {
if (validateForm()) {
alert('Form submitted successfully!');
}
});
// Initial setup
validateForm();
</script>
</body>
</html>

View File

@@ -98,7 +98,11 @@ export const createSheetGesture = (
// Respect explicit opt-out of focus trapping/backdrop interactions
// If focusTrap is false or showBackdrop is false, do not enable the backdrop or re-enable focus trap
const el = baseEl as HTMLIonModalElement & { focusTrap?: boolean; showBackdrop?: boolean };
if (el.focusTrap === false || el.showBackdrop === false) {
const focusTrapAttr = el.getAttribute?.('focus-trap');
const showBackdropAttr = el.getAttribute?.('show-backdrop');
const focusTrapDisabled = el.focusTrap === false || focusTrapAttr === 'false';
const backdropDisabled = el.showBackdrop === false || showBackdropAttr === 'false';
if (focusTrapDisabled || backdropDisabled) {
return;
}
baseEl.style.setProperty('pointer-events', 'auto');
@@ -241,10 +245,12 @@ export const createSheetGesture = (
* ion-backdrop and .modal-wrapper always have pointer-events: auto
* applied, so the modal content can still be interacted with.
*/
const shouldEnableBackdrop =
currentBreakpoint > backdropBreakpoint &&
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false &&
(baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false;
const modalEl = baseEl as HTMLIonModalElement & { focusTrap?: boolean; showBackdrop?: boolean };
const focusTrapAttr = modalEl.getAttribute?.('focus-trap');
const showBackdropAttr = modalEl.getAttribute?.('show-backdrop');
const focusTrapDisabled = modalEl.focusTrap === false || focusTrapAttr === 'false';
const backdropDisabled = modalEl.showBackdrop === false || showBackdropAttr === 'false';
const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint && !focusTrapDisabled && !backdropDisabled;
if (shouldEnableBackdrop) {
enableBackdrop();
} else {
@@ -591,10 +597,16 @@ export const createSheetGesture = (
* Backdrop should become enabled
* after the backdropBreakpoint value
*/
const modalEl = baseEl as HTMLIonModalElement & {
focusTrap?: boolean;
showBackdrop?: boolean;
};
const focusTrapAttr = modalEl.getAttribute?.('focus-trap');
const showBackdropAttr = modalEl.getAttribute?.('show-backdrop');
const focusTrapDisabled = modalEl.focusTrap === false || focusTrapAttr === 'false';
const backdropDisabled = modalEl.showBackdrop === false || showBackdropAttr === 'false';
const shouldEnableBackdrop =
currentBreakpoint > backdropBreakpoint &&
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false &&
(baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false;
currentBreakpoint > backdropBreakpoint && !focusTrapDisabled && !backdropDisabled;
if (shouldEnableBackdrop) {
enableBackdrop();
} else {

View File

@@ -1237,6 +1237,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
const isHandleCycle = handleBehavior === 'cycle';
const isSheetModalWithHandle = isSheetModal && showHandle;
const focusTrapAttr = this.el.getAttribute('focus-trap');
return (
<Host
no-router
@@ -1253,7 +1255,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
[`modal-sheet`]: isSheetModal,
[`modal-no-expand-scroll`]: isSheetModal && !expandToScroll,
'overlay-hidden': true,
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false,
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false || focusTrapAttr === 'false',
...getClassMap(this.cssClass),
}}
onIonBackdropTap={this.onBackdropTap}

View File

@@ -28,6 +28,24 @@ describe('modal: focus trap', () => {
expect(modal.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
});
it('should set the focus trap class when disabled via string prop', async () => {
const page = await newSpecPage({
components: [Modal],
template: () => <ion-modal overlayIndex={1}></ion-modal>,
});
const modal = page.body.querySelector('ion-modal')! as any;
// Simulate frameworks passing the boolean as a string prop, e.g. focusTrap="false" in Angular
modal.focusTrap = 'false';
await page.waitForChanges();
// This currently fails because the host class only inspects focusTrap === false
// and treats the string "false" as truthy.
expect(modal.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
});
it('should not set the focus trap class by default', async () => {
const page = await newSpecPage({
components: [Modal],

View File

@@ -687,6 +687,7 @@ export class Popover implements ComponentInterface, PopoverInterface {
const { onLifecycle, parentPopover, dismissOnSelect, side, arrow, htmlAttributes, focusTrap } = this;
const desktop = isPlatform('desktop');
const enableArrow = arrow && !parentPopover;
const focusTrapAttr = this.el.getAttribute('focus-trap');
return (
<Host
@@ -704,7 +705,7 @@ export class Popover implements ComponentInterface, PopoverInterface {
'overlay-hidden': true,
'popover-desktop': desktop,
[`popover-side-${side}`]: true,
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false,
[FOCUS_TRAP_DISABLE_CLASS]: focusTrap === false || focusTrapAttr === 'false',
'popover-nested': !!parentPopover,
}}
onIonPopoverDidPresent={onLifecycle}

View File

@@ -29,6 +29,24 @@ describe('popover: focus trap', () => {
expect(popover.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
});
it('should set the focus trap class when disabled via string prop', async () => {
const page = await newSpecPage({
components: [Popover],
template: () => <ion-popover overlayIndex={1}></ion-popover>,
});
const popover = page.body.querySelector('ion-popover')! as any;
// Simulate frameworks passing the boolean as a string prop, e.g. focusTrap="false" in Angular
popover.focusTrap = 'false';
await page.waitForChanges();
// This currently fails because the host class only inspects focusTrap === false
// and treats the string "false" as truthy.
expect(popover.classList.contains(FOCUS_TRAP_DISABLE_CLASS)).toBe(true);
});
it('should not set the focus trap class by default', async () => {
const page = await newSpecPage({
components: [Popover],

View File

@@ -1,5 +1,6 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Listen, Method, Prop, Watch, h } from '@stencil/core';
import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core';
import { checkInvalidState } from '@utils/forms';
import { renderHiddenInput } from '@utils/helpers';
import { getIonMode } from '../../global/ionic-global';
@@ -19,9 +20,17 @@ export class RadioGroup implements ComponentInterface {
private errorTextId = `${this.inputId}-error-text`;
private labelId = `${this.inputId}-lbl`;
private label?: HTMLIonLabelElement | null;
private validationObserver?: MutationObserver;
@Element() el!: HTMLElement;
/**
* Track validation state for proper aria-live announcements.
*/
@State() isInvalid = false;
@State() private hintTextId?: string;
/**
* If `true`, the radios can be deselected.
*/
@@ -121,6 +130,57 @@ export class RadioGroup implements ComponentInterface {
this.labelId = label.id = this.name + '-lbl';
}
}
// Watch for class changes to update validation state.
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
this.validationObserver = new MutationObserver(() => {
const newIsInvalid = checkInvalidState(this.el);
if (this.isInvalid !== newIsInvalid) {
this.isInvalid = newIsInvalid;
/**
* Screen readers tend to announce changes
* to `aria-describedby` when the attribute
* is changed during a blur event for a
* native form control.
* However, the announcement can be spotty
* when using a non-native form control
* and `forceUpdate()`.
* This is due to `forceUpdate()` internally
* rescheduling the DOM update to a lower
* priority queue regardless if it's called
* inside a Promise or not, thus causing
* the screen reader to potentially miss the
* change.
* By using a State variable inside a Promise,
* it guarantees a re-render immediately at
* a higher priority.
*/
Promise.resolve().then(() => {
this.hintTextId = this.getHintTextId();
});
}
});
this.validationObserver.observe(this.el, {
attributes: true,
attributeFilter: ['class'],
});
}
// Always set initial state
this.isInvalid = checkInvalidState(this.el);
}
componentWillLoad() {
this.hintTextId = this.getHintTextId();
}
disconnectedCallback() {
// Clean up validation observer to prevent memory leaks.
if (this.validationObserver) {
this.validationObserver.disconnect();
this.validationObserver = undefined;
}
}
private getRadios(): HTMLIonRadioElement[] {
@@ -244,7 +304,7 @@ export class RadioGroup implements ComponentInterface {
* Renders the helper text or error text values
*/
private renderHintText() {
const { helperText, errorText, helperTextId, errorTextId } = this;
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
const hasHintText = !!helperText || !!errorText;
if (!hasHintText) {
@@ -253,20 +313,20 @@ export class RadioGroup implements ComponentInterface {
return (
<div class="radio-group-top">
<div id={helperTextId} class="helper-text">
{helperText}
<div id={helperTextId} class="helper-text" aria-live="polite">
{!isInvalid ? helperText : null}
</div>
<div id={errorTextId} class="error-text">
{errorText}
<div id={errorTextId} class="error-text" role="alert">
{isInvalid ? errorText : null}
</div>
</div>
);
}
private getHintTextID(): string | undefined {
const { el, helperText, errorText, helperTextId, errorTextId } = this;
private getHintTextId(): string | undefined {
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
if (isInvalid && errorText) {
return errorTextId;
}
@@ -287,8 +347,8 @@ export class RadioGroup implements ComponentInterface {
<Host
role="radiogroup"
aria-labelledby={label ? labelId : null}
aria-describedby={this.getHintTextID()}
aria-invalid={this.getHintTextID() === this.errorTextId}
aria-describedby={this.hintTextId}
aria-invalid={this.isInvalid ? 'true' : undefined}
onClick={this.onClick}
class={mode}
>

View File

@@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Radio Group - Validation</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>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-row-gap: 30px;
grid-column-gap: 30px;
}
h2 {
font-size: 12px;
font-weight: normal;
color: var(--ion-color-step-600);
margin-top: 10px;
margin-bottom: 5px;
}
.validation-info {
margin: 20px;
padding: 10px;
background: var(--ion-color-light);
border-radius: 4px;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Radio Group - Validation Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="validation-info">
<h2>Screen Reader Testing Instructions:</h2>
<ol>
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
<li>Tab through the form fields</li>
<li>When you tab away from an empty required field, the error should be announced immediately</li>
<li>The error text should be announced BEFORE the next field is announced</li>
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
</ol>
</div>
<div class="grid">
<div>
<h2>Required Field</h2>
<ion-radio-group
id="fruits-radio-group"
helper-text="You must select one to continue"
error-text="Please select a fruit"
allow-empty-selection="true"
required
>
<ion-radio value="grapes">Grapes</ion-radio><br />
<ion-radio value="strawberries">Strawberries</ion-radio>
</ion-radio-group>
</div>
<div>
<h2>Optional Field (No Validation)</h2>
<ion-radio-group
id="optional-radio-group"
helper-text="You can skip this field"
allow-empty-selection="true"
>
<ion-radio value="cucumbers">Cucumbers</ion-radio><br />
<ion-radio value="tomatoes">Tomatoes</ion-radio>
</ion-radio-group>
</div>
</div>
<div class="ion-padding">
<ion-button id="submit-btn" expand="block" disabled>Submit Form</ion-button>
<ion-button id="reset-btn" expand="block" fill="outline">Reset Form</ion-button>
</div>
</ion-content>
</ion-app>
<script>
// Simple validation logic
const radioGroups = document.querySelectorAll('ion-radio-group');
const submitBtn = document.getElementById('submit-btn');
const resetBtn = document.getElementById('reset-btn');
// Track which fields have been touched
const touchedFields = new Set();
// Validation functions
const validators = {
'fruits-radio-group': (value) => {
return value !== '' && value !== undefined;
},
'optional-radio-group': () => true, // Always valid
};
function validateField(radioGroup) {
const radioGroupId = radioGroup.id;
const value = radioGroup.value;
const isValid = validators[radioGroupId] ? validators[radioGroupId](value) : true;
// Only show validation state if field has been touched
if (touchedFields.has(radioGroupId)) {
if (isValid) {
radioGroup.classList.remove('ion-invalid');
radioGroup.classList.add('ion-valid');
} else {
radioGroup.classList.remove('ion-valid');
radioGroup.classList.add('ion-invalid');
}
radioGroup.classList.add('ion-touched');
}
return isValid;
}
function validateForm() {
let allValid = true;
radioGroups.forEach((radioGroup) => {
if (radioGroup.id !== 'optional-radio-group') {
const isValid = validateField(radioGroup);
if (!isValid) {
allValid = false;
}
}
});
submitBtn.disabled = !allValid;
return allValid;
}
// Add event listeners
radioGroups.forEach((radioGroup) => {
// Mark as touched on blur
radioGroup.addEventListener('ionBlur', (e) => {
console.log('Blur event on:', radioGroup.id);
touchedFields.add(radioGroup.id);
validateField(radioGroup);
validateForm();
const isInvalid = radioGroup.classList.contains('ion-invalid');
if (isInvalid) {
console.log('Field marked invalid:', radioGroup.id, radioGroup.errorText);
}
});
// Validate on change
radioGroup.addEventListener('ionChange', (e) => {
console.log('Change event on:', radioGroup.id);
if (touchedFields.has(radioGroup.id)) {
validateField(radioGroup);
validateForm();
}
});
});
// Reset button
resetBtn.addEventListener('click', () => {
radioGroups.forEach((radioGroup) => {
radioGroup.value = '';
radioGroup.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
});
touchedFields.clear();
submitBtn.disabled = true;
});
// Submit button
submitBtn.addEventListener('click', () => {
if (validateForm()) {
alert('Form submitted successfully!');
}
});
// Initial setup
validateForm();
</script>
</body>
</html>

View File

@@ -87,7 +87,7 @@ export class Select implements ComponentInterface {
*/
@State() isInvalid = false;
@State() private hintTextID?: string;
@State() private hintTextId?: string;
/**
* The text to display on the cancel button.
@@ -332,7 +332,7 @@ export class Select implements ComponentInterface {
* a higher priority.
*/
Promise.resolve().then(() => {
this.hintTextID = this.getHintTextID();
this.hintTextId = this.getHintTextId();
});
}
});
@@ -350,7 +350,7 @@ export class Select implements ComponentInterface {
componentWillLoad() {
this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
this.hintTextID = this.getHintTextID();
this.hintTextId = this.getHintTextId();
}
componentDidLoad() {
@@ -1111,7 +1111,7 @@ export class Select implements ComponentInterface {
aria-label={this.ariaLabel}
aria-haspopup="dialog"
aria-expanded={`${isExpanded}`}
aria-describedby={this.hintTextID}
aria-describedby={this.hintTextId}
aria-invalid={this.isInvalid ? 'true' : undefined}
aria-required={`${required}`}
onFocus={this.onFocus}
@@ -1121,7 +1121,7 @@ export class Select implements ComponentInterface {
);
}
private getHintTextID(): string | undefined {
private getHintTextId(): string | undefined {
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
if (isInvalid && errorText) {

View File

@@ -45,6 +45,20 @@
<ion-toggle style="width: 100%"> Full-width </ion-toggle><br />
<ion-toggle> Long Label Long Label Long Label Long Label Long Label Long Label </ion-toggle><br />
</ion-content>
<script>
document.addEventListener('ionBlur', (ev) => {
console.log('ionBlur', ev);
});
document.addEventListener('ionChange', (ev) => {
console.log('ionChange', ev);
});
document.addEventListener('ionFocus', (ev) => {
console.log('ionFocus', ev);
});
</script>
</ion-app>
</body>
</html>

View File

@@ -1,7 +1,65 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
/**
* This behavior does not vary across modes/directions
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('toggle: ionChange'), () => {
test('should fire ionChange when interacting with toggle', async ({ page }) => {
await page.setContent(
`
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
`,
config
);
const ionChange = await page.spyOnEvent('ionChange');
const toggle = page.locator('ion-toggle');
await toggle.click();
expect(ionChange).toHaveReceivedEventDetail({ value: 'my-toggle', checked: true });
await toggle.click();
expect(ionChange).toHaveReceivedEventDetail({ value: 'my-toggle', checked: false });
});
test('should fire ionChange when interacting with toggle in item', async ({ page }) => {
await page.setContent(
`
<ion-item>
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
</ion-item>
`,
config
);
const ionChange = await page.spyOnEvent('ionChange');
const item = page.locator('ion-item');
await item.click();
expect(ionChange).toHaveReceivedEventDetail({ value: 'my-toggle', checked: true });
await item.click();
expect(ionChange).toHaveReceivedEventDetail({ value: 'my-toggle', checked: false });
});
test('should not fire when programmatically setting a value', async ({ page }) => {
await page.setContent(
`
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
`,
config
);
const ionChange = await page.spyOnEvent('ionChange');
const toggle = page.locator('ion-toggle');
await toggle.evaluate((el: HTMLIonToggleElement) => (el.checked = true));
expect(ionChange).not.toHaveReceivedEvent();
});
});
test.describe(title('toggle: click'), () => {
test('should trigger onclick only once when clicking the label', async ({ page }, testInfo) => {
testInfo.annotations.push({
@@ -35,4 +93,195 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
expect(clickCount).toBe(1);
});
});
test.describe(title('toggle: ionFocus'), () => {
test('should not have visual regressions', async ({ page, pageUtils }) => {
await page.setContent(
`
<style>
#container {
display: inline-block;
padding: 10px;
}
</style>
<div id="container">
<ion-toggle>Unchecked</ion-toggle>
</div>
`,
config
);
await pageUtils.pressKeys('Tab');
const container = page.locator('#container');
await expect(container).toHaveScreenshot(screenshot(`toggle-focus`));
});
test('should not have visual regressions when interacting with toggle in item', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-item class="ion-focused">
<ion-toggle>Unchecked</ion-toggle>
</ion-item>
`,
config
);
// Test focus with keyboard navigation
await pageUtils.pressKeys('Tab');
const item = page.locator('ion-item');
await expect(item).toHaveScreenshot(screenshot(`toggle-in-item-focus`));
});
test('should fire ionFocus when toggle is focused', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
`,
config
);
const ionFocus = await page.spyOnEvent('ionFocus');
// Test focus with keyboard navigation
await pageUtils.pressKeys('Tab');
expect(ionFocus).toHaveReceivedEventTimes(1);
// Reset focus
const toggle = page.locator('ion-toggle');
const toggleBoundingBox = (await toggle.boundingBox())!;
await page.mouse.click(0, toggleBoundingBox.height + 1);
// Test focus with click
await toggle.click();
expect(ionFocus).toHaveReceivedEventTimes(2);
});
test('should fire ionFocus when interacting with toggle in item', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-item>
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
</ion-item>
`,
config
);
const ionFocus = await page.spyOnEvent('ionFocus');
// Test focus with keyboard navigation
await pageUtils.pressKeys('Tab');
expect(ionFocus).toHaveReceivedEventTimes(1);
// Verify that the event target is the toggle and not the item
const eventByKeyboard = ionFocus.events[0];
expect((eventByKeyboard.target as HTMLElement).tagName.toLowerCase()).toBe('ion-toggle');
// Reset focus
const toggle = page.locator('ion-toggle');
const toggleBoundingBox = (await toggle.boundingBox())!;
await page.mouse.click(0, toggleBoundingBox.height + 1);
// Test focus with click
const item = page.locator('ion-item');
await item.click();
expect(ionFocus).toHaveReceivedEventTimes(2);
// Verify that the event target is the toggle and not the item
const eventByClick = ionFocus.events[0];
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-toggle');
});
test('should not fire when programmatically setting a value', async ({ page }) => {
await page.setContent(
`
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
`,
config
);
const ionFocus = await page.spyOnEvent('ionFocus');
const toggle = page.locator('ion-toggle');
await toggle.evaluate((el: HTMLIonToggleElement) => (el.checked = true));
expect(ionFocus).not.toHaveReceivedEvent();
});
});
test.describe(title('toggle: ionBlur'), () => {
test('should fire ionBlur when toggle is blurred', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
`,
config
);
const ionBlur = await page.spyOnEvent('ionBlur');
// Test blur with keyboard navigation
// Focus the toggle
await pageUtils.pressKeys('Tab');
// Blur the toggle
await pageUtils.pressKeys('Tab');
expect(ionBlur).toHaveReceivedEventTimes(1);
// Test blur with click
const toggle = page.locator('ion-toggle');
// Focus the toggle
await toggle.click();
// Blur the toggle by clicking outside of it
const toggleBoundingBox = (await toggle.boundingBox())!;
await page.mouse.click(0, toggleBoundingBox.height + 1);
expect(ionBlur).toHaveReceivedEventTimes(2);
});
test('should fire ionBlur after interacting with toggle in item', async ({ page, pageUtils }) => {
await page.setContent(
`
<ion-item>
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
</ion-item>
`,
config
);
const ionBlur = await page.spyOnEvent('ionBlur');
// Test blur with keyboard navigation
// Focus the toggle
await pageUtils.pressKeys('Tab');
// Blur the toggle
await pageUtils.pressKeys('Tab');
expect(ionBlur).toHaveReceivedEventTimes(1);
// Verify that the event target is the toggle and not the item
const event = ionBlur.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-toggle');
// Test blur with click
const item = page.locator('ion-item');
await item.click();
// Blur the toggle by clicking outside of it
const itemBoundingBox = (await item.boundingBox())!;
await page.mouse.click(0, itemBoundingBox.height + 1);
expect(ionBlur).toHaveReceivedEventTimes(2);
// Verify that the event target is the toggle and not the item
const eventByClick = ionBlur.events[0];
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-toggle');
});
});
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -223,6 +223,20 @@
</div>
</div>
</ion-content>
<script>
document.addEventListener('ionBlur', (ev) => {
console.log('ionBlur', ev);
});
document.addEventListener('ionChange', (ev) => {
console.log('ionChange', ev);
});
document.addEventListener('ionFocus', (ev) => {
console.log('ionFocus', ev);
});
</script>
</ion-app>
</body>
</html>

View File

@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Toggle - Validation</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>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-row-gap: 30px;
grid-column-gap: 30px;
}
h2 {
font-size: 12px;
font-weight: normal;
color: var(--ion-color-step-600);
margin-top: 10px;
margin-bottom: 5px;
}
.validation-info {
margin: 20px;
padding: 10px;
background: var(--ion-color-light);
border-radius: 4px;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Toggle - Validation Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="validation-info">
<h2>Screen Reader Testing Instructions:</h2>
<ol>
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
<li>Tab through the form fields</li>
<li>When you tab away from an empty required field, the error should be announced immediately</li>
<li>The error text should be announced BEFORE the next field is announced</li>
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
</ol>
</div>
<div class="grid">
<div>
<h2>Required Field</h2>
<ion-toggle
id="terms-toggle"
helper-text="You must turn on to continue"
error-text="Please turn on this toggle"
required
>Tap to turn on</ion-toggle
>
</div>
<div>
<h2>Optional Field (No Validation)</h2>
<ion-toggle id="optional-toggle" helper-text="You can skip this field">Optional Toggle</ion-toggle>
</div>
</div>
<div class="ion-padding">
<ion-button id="submit-btn" expand="block" disabled>Submit Form</ion-button>
<ion-button id="reset-btn" expand="block" fill="outline">Reset Form</ion-button>
</div>
</ion-content>
</ion-app>
<script>
// Simple validation logic
const toggles = document.querySelectorAll('ion-toggle');
const submitBtn = document.getElementById('submit-btn');
const resetBtn = document.getElementById('reset-btn');
// Track which fields have been touched
const touchedFields = new Set();
// Validation functions
const validators = {
'terms-toggle': (checked) => {
return checked === true;
},
'optional-toggle': () => true, // Always valid
};
function validateField(toggle) {
const toggleId = toggle.id;
const checked = toggle.checked;
const isValid = validators[toggleId] ? validators[toggleId](checked) : true;
// Only show validation state if field has been touched
if (touchedFields.has(toggleId)) {
if (isValid) {
toggle.classList.remove('ion-invalid');
toggle.classList.add('ion-valid');
} else {
toggle.classList.remove('ion-valid');
toggle.classList.add('ion-invalid');
}
toggle.classList.add('ion-touched');
}
return isValid;
}
function validateForm() {
let allValid = true;
toggles.forEach((toggle) => {
if (toggle.id !== 'optional-toggle') {
const isValid = validateField(toggle);
if (!isValid) {
allValid = false;
}
}
});
submitBtn.disabled = !allValid;
return allValid;
}
// Add event listeners
toggles.forEach((toggle) => {
// Mark as touched on blur
toggle.addEventListener('ionBlur', (e) => {
console.log('Blur event on:', toggle.id);
touchedFields.add(toggle.id);
validateField(toggle);
validateForm();
const isInvalid = toggle.classList.contains('ion-invalid');
if (isInvalid) {
console.log('Field marked invalid:', toggle.innerText, toggle.errorText);
}
});
// Validate on change
toggle.addEventListener('ionChange', (e) => {
console.log('Change event on:', toggle.id);
if (touchedFields.has(toggle.id)) {
validateField(toggle);
validateForm();
}
});
});
// Reset button
resetBtn.addEventListener('click', () => {
toggles.forEach((toggle) => {
toggle.checked = false;
toggle.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
});
touchedFields.clear();
submitBtn.disabled = true;
});
// Submit button
submitBtn.addEventListener('click', () => {
if (validateForm()) {
alert('Form submitted successfully!');
}
});
// Initial setup
validateForm();
</script>
</body>
</html>

View File

@@ -1,5 +1,6 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
import { Build, Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
import { checkInvalidState } from '@utils/forms';
import { renderHiddenInput, inheritAriaAttributes } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { hapticSelection } from '@utils/native/haptic';
@@ -40,16 +41,23 @@ export class Toggle implements ComponentInterface {
private helperTextId = `${this.inputId}-helper-text`;
private errorTextId = `${this.inputId}-error-text`;
private gesture?: Gesture;
private focusEl?: HTMLElement;
private lastDrag = 0;
private inheritedAttributes: Attributes = {};
private toggleTrack?: HTMLElement;
private didLoad = false;
private validationObserver?: MutationObserver;
@Element() el!: HTMLIonToggleElement;
@State() activated = false;
/**
* Track validation state for proper aria-live announcements.
*/
@State() isInvalid = false;
@State() private hintTextId?: string;
/**
* The color to use from your application's color palette.
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
@@ -162,7 +170,6 @@ export class Toggle implements ComponentInterface {
const isNowChecked = !checked;
this.checked = isNowChecked;
this.setFocus();
this.ionChange.emit({
checked: isNowChecked,
value,
@@ -170,15 +177,56 @@ export class Toggle implements ComponentInterface {
}
async connectedCallback() {
const { didLoad, el } = this;
/**
* If we have not yet rendered
* ion-toggle, then toggleTrack is not defined.
* But if we are moving ion-toggle via appendChild,
* then toggleTrack will be defined.
*/
if (this.didLoad) {
if (didLoad) {
this.setupGesture();
}
// Watch for class changes to update validation state.
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
this.validationObserver = new MutationObserver(() => {
const newIsInvalid = checkInvalidState(el);
if (this.isInvalid !== newIsInvalid) {
this.isInvalid = newIsInvalid;
/**
* Screen readers tend to announce changes
* to `aria-describedby` when the attribute
* is changed during a blur event for a
* native form control.
* However, the announcement can be spotty
* when using a non-native form control
* and `forceUpdate()`.
* This is due to `forceUpdate()` internally
* rescheduling the DOM update to a lower
* priority queue regardless if it's called
* inside a Promise or not, thus causing
* the screen reader to potentially miss the
* change.
* By using a State variable inside a Promise,
* it guarantees a re-render immediately at
* a higher priority.
*/
Promise.resolve().then(() => {
this.hintTextId = this.getHintTextId();
});
}
});
this.validationObserver.observe(el, {
attributes: true,
attributeFilter: ['class'],
});
}
// Always set initial state
this.isInvalid = checkInvalidState(el);
}
componentDidLoad() {
@@ -209,12 +257,20 @@ export class Toggle implements ComponentInterface {
this.gesture.destroy();
this.gesture = undefined;
}
// Clean up validation observer to prevent memory leaks.
if (this.validationObserver) {
this.validationObserver.disconnect();
this.validationObserver = undefined;
}
}
componentWillLoad() {
this.inheritedAttributes = {
...inheritAriaAttributes(this.el),
};
this.hintTextId = this.getHintTextId();
}
private onStart() {
@@ -243,9 +299,7 @@ export class Toggle implements ComponentInterface {
}
private setFocus() {
if (this.focusEl) {
this.focusEl.focus();
}
this.el.focus();
}
private onKeyDown = (ev: KeyboardEvent) => {
@@ -339,10 +393,10 @@ export class Toggle implements ComponentInterface {
return this.el.textContent !== '';
}
private getHintTextID(): string | undefined {
const { el, helperText, errorText, helperTextId, errorTextId } = this;
private getHintTextId(): string | undefined {
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
if (isInvalid && errorText) {
return errorTextId;
}
@@ -358,7 +412,7 @@ export class Toggle implements ComponentInterface {
* This element should only be rendered if hint text is set.
*/
private renderHintText() {
const { helperText, errorText, helperTextId, errorTextId } = this;
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
/**
* undefined and empty string values should
@@ -371,11 +425,11 @@ export class Toggle implements ComponentInterface {
return (
<div class="toggle-bottom">
<div id={helperTextId} class="helper-text" part="supporting-text helper-text">
{helperText}
<div id={helperTextId} class="helper-text" part="supporting-text helper-text" aria-live="polite">
{!isInvalid ? helperText : null}
</div>
<div id={errorTextId} class="error-text" part="supporting-text error-text">
{errorText}
<div id={errorTextId} class="error-text" part="supporting-text error-text" role="alert">
{isInvalid ? errorText : null}
</div>
</div>
);
@@ -389,7 +443,6 @@ export class Toggle implements ComponentInterface {
color,
disabled,
el,
errorTextId,
hasLabel,
inheritedAttributes,
inputId,
@@ -409,14 +462,17 @@ export class Toggle implements ComponentInterface {
<Host
role="switch"
aria-checked={`${checked}`}
aria-describedby={this.getHintTextID()}
aria-invalid={this.getHintTextID() === errorTextId}
aria-describedby={this.hintTextId}
aria-invalid={this.isInvalid ? 'true' : undefined}
onClick={this.onClick}
aria-labelledby={hasLabel ? inputLabelId : null}
aria-label={inheritedAttributes['aria-label'] || null}
aria-disabled={disabled ? 'true' : null}
aria-required={required ? 'true' : undefined}
tabindex={disabled ? undefined : 0}
onKeyDown={this.onKeyDown}
onFocus={this.onFocus}
onBlur={this.onBlur}
class={createColorClasses(color, {
[mode]: true,
'in-item': hostContext('ion-item', el),
@@ -441,9 +497,6 @@ export class Toggle implements ComponentInterface {
checked={checked}
disabled={disabled}
id={inputId}
onFocus={() => this.onFocus()}
onBlur={() => this.onBlur()}
ref={(focusEl) => (this.focusEl = focusEl)}
required={required}
{...inheritedAttributes}
/>

View File

@@ -1,4 +1,10 @@
type FormElement = HTMLIonInputElement | HTMLIonTextareaElement | HTMLIonSelectElement;
type FormElement =
| HTMLIonInputElement
| HTMLIonTextareaElement
| HTMLIonSelectElement
| HTMLIonCheckboxElement
| HTMLIonToggleElement
| HTMLElement;
/**
* Checks if the form element is in an invalid state based on

View File

@@ -539,11 +539,18 @@ export const present = async <OverlayPresentOptions>(
* view container subtree, skip adding aria-hidden/inert there
* to avoid disabling the overlay.
*/
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false;
const overlayEl = overlay.el as HTMLIonOverlayElement & {
focusTrap?: boolean;
showBackdrop?: boolean;
};
const focusTrapAttr = overlayEl.getAttribute?.('focus-trap');
const showBackdropAttr = overlayEl.getAttribute?.('show-backdrop');
const focusTrapDisabled = overlayEl.focusTrap === false || focusTrapAttr === 'false';
const backdropDisabled = overlayEl.showBackdrop === false || showBackdropAttr === 'false';
const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && !focusTrapDisabled;
// Only lock out root content when backdrop is active. Developers relying on showBackdrop=false
// expect background interaction to remain enabled.
const shouldLockRoot = shouldTrapFocus && overlayEl.showBackdrop !== false;
const shouldLockRoot = shouldTrapFocus && !backdropDisabled;
overlay.presented = true;
overlay.willPresent.emit();
@@ -681,11 +688,21 @@ export const dismiss = async <OverlayDismissOptions>(
*/
const overlaysLockingRoot = presentedOverlays.filter((o) => {
const el = o as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && el.showBackdrop !== false;
const focusTrapAttr = el.getAttribute?.('focus-trap');
const showBackdropAttr = el.getAttribute?.('show-backdrop');
const focusTrapDisabled = el.focusTrap === false || focusTrapAttr === 'false';
const backdropDisabled = el.showBackdrop === false || showBackdropAttr === 'false';
return el.tagName !== 'ION-TOAST' && !focusTrapDisabled && !backdropDisabled;
});
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
const locksRoot =
overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && overlayEl.showBackdrop !== false;
const overlayEl = overlay.el as HTMLIonOverlayElement & {
focusTrap?: boolean;
showBackdrop?: boolean;
};
const focusTrapAttr = overlayEl.getAttribute?.('focus-trap');
const showBackdropAttr = overlayEl.getAttribute?.('show-backdrop');
const focusTrapDisabled = overlayEl.focusTrap === false || focusTrapAttr === 'false';
const backdropDisabled = overlayEl.showBackdrop === false || showBackdropAttr === 'false';
const locksRoot = overlayEl.tagName !== 'ION-TOAST' && !focusTrapDisabled && !backdropDisabled;
/**
* If this is the last visible overlay that is trapping focus

View File

@@ -57,6 +57,58 @@ describe('overlays: scroll blocking', () => {
expect(body).not.toHaveClass('backdrop-no-scroll');
});
it('should not block scroll when focus trapping is disabled via string prop', async () => {
const page = await newSpecPage({
components: [Modal],
html: `
<ion-modal></ion-modal>
`,
});
const modal = page.body.querySelector('ion-modal')! as any;
const body = page.doc.querySelector('body')!;
// Simulate frameworks passing the boolean as a string prop, e.g. focusTrap="false" in Angular
modal.focusTrap = 'false';
await page.waitForChanges();
await modal.present();
// With focus trapping disabled, scroll should not be locked.
// This currently fails because overlays.ts only checks for focusTrap !== false
// and treats the string "false" as truthy.
expect(body).not.toHaveClass('backdrop-no-scroll');
await modal.dismiss();
expect(body).not.toHaveClass('backdrop-no-scroll');
});
it('should not block scroll when backdrop is disabled via string prop', async () => {
const page = await newSpecPage({
components: [Modal],
html: `
<ion-modal></ion-modal>
`,
});
const modal = page.body.querySelector('ion-modal')! as any;
const body = page.doc.querySelector('body')!;
// Simulate frameworks passing showBackdrop="false" as a string
modal.showBackdrop = 'false';
await page.waitForChanges();
await modal.present();
// With backdrop disabled, scroll should not be locked.
expect(body).not.toHaveClass('backdrop-no-scroll');
await modal.dismiss();
expect(body).not.toHaveClass('backdrop-no-scroll');
});
it('should not enable scroll until last overlay is dismissed', async () => {
const page = await newSpecPage({
components: [Modal],

View File

@@ -2,6 +2,40 @@ import type { E2EPage } from '../../playwright-declarations';
import { addE2EListener, EventSpy } from '../event-spy';
export const spyOnEvent = async (page: E2EPage, eventName: string): Promise<EventSpy> => {
/**
* Tabbing out of the page boundary can lead to unreliable `ionBlur events,
* particularly in Firefox.
*
* This occurs because Playwright may incorrectly maintain focus state on the
* last element, even after a Tab press attempts to shift focus outside the
* viewport. To reliably trigger the necessary blur event, add a visually
* hidden, focusable element at the end of the page to receive focus instead of
* the browser.
*
* Playwright issue reference:
* https://github.com/microsoft/playwright/issues/32269
*/
if (eventName === 'ionBlur') {
const hiddenInput = await page.$('#hidden-input-for-ion-blur');
if (!hiddenInput) {
await page.evaluate(() => {
const input = document.createElement('input');
input.id = 'hidden-input-for-ion-blur';
input.style.position = 'absolute';
input.style.opacity = '0';
input.style.height = '0';
input.style.width = '0';
input.style.pointerEvents = 'none';
document.body.appendChild(input);
// Clean up the element when the page is unloaded.
window.addEventListener('unload', () => {
input.remove();
});
});
}
}
const spy = new EventSpy(eventName);
const handle = await page.evaluateHandle(() => window);

View File

@@ -3,5 +3,5 @@
"core",
"packages/*"
],
"version": "8.7.7"
"version": "8.7.9"
}

View File

@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [8.7.9](https://github.com/ionic-team/ionic-framework/compare/v8.7.8...v8.7.9) (2025-11-05)
**Note:** Version bump only for package @ionic/angular-server
## [8.7.8](https://github.com/ionic-team/ionic-framework/compare/v8.7.7...v8.7.8) (2025-10-29)
**Note:** Version bump only for package @ionic/angular-server
## [8.7.7](https://github.com/ionic-team/ionic-framework/compare/v8.7.6...v8.7.7) (2025-10-15)
**Note:** Version bump only for package @ionic/angular-server

View File

@@ -1,15 +1,15 @@
{
"name": "@ionic/angular-server",
"version": "8.7.7",
"version": "8.7.9",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@ionic/angular-server",
"version": "8.7.7",
"version": "8.7.9",
"license": "MIT",
"dependencies": {
"@ionic/core": "^8.7.7"
"@ionic/core": "^8.7.9"
},
"devDependencies": {
"@angular-eslint/eslint-plugin": "^16.0.0",
@@ -1031,9 +1031,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.6",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.6.tgz",
"integrity": "sha512-ufV64Pl0BYSoNla+DaTRXTS3hX6MQZZJPhAR3fJQ4N5Fg/vwMcHADQffstKZeoPqk6mbJoLqoTBjcWvaLRdO0g==",
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.9.tgz",
"integrity": "sha512-6cYp3LHjCGX32MiYR09l/O+8klkJ27WpKzllvnLRe5bxX3nOuqJvJJIshzT85qTdZNTFu7zJJrjWkgEOXGarzg==",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
@@ -7306,9 +7306,9 @@
"dev": true
},
"@ionic/core": {
"version": "8.7.6",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.6.tgz",
"integrity": "sha512-ufV64Pl0BYSoNla+DaTRXTS3hX6MQZZJPhAR3fJQ4N5Fg/vwMcHADQffstKZeoPqk6mbJoLqoTBjcWvaLRdO0g==",
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.9.tgz",
"integrity": "sha512-6cYp3LHjCGX32MiYR09l/O+8klkJ27WpKzllvnLRe5bxX3nOuqJvJJIshzT85qTdZNTFu7zJJrjWkgEOXGarzg==",
"requires": {
"@stencil/core": "4.38.0",
"ionicons": "^8.0.13",
@@ -11286,4 +11286,4 @@
}
}
}
}
}

View File

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

View File

@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [8.7.9](https://github.com/ionic-team/ionic-framework/compare/v8.7.8...v8.7.9) (2025-11-05)
**Note:** Version bump only for package @ionic/angular
## [8.7.8](https://github.com/ionic-team/ionic-framework/compare/v8.7.7...v8.7.8) (2025-10-29)
**Note:** Version bump only for package @ionic/angular
## [8.7.7](https://github.com/ionic-team/ionic-framework/compare/v8.7.6...v8.7.7) (2025-10-15)

View File

@@ -1,15 +1,15 @@
{
"name": "@ionic/angular",
"version": "8.7.7",
"version": "8.7.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ionic/angular",
"version": "8.7.7",
"version": "8.7.9",
"license": "MIT",
"dependencies": {
"@ionic/core": "^8.7.7",
"@ionic/core": "^8.7.9",
"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.6",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.6.tgz",
"integrity": "sha512-ufV64Pl0BYSoNla+DaTRXTS3hX6MQZZJPhAR3fJQ4N5Fg/vwMcHADQffstKZeoPqk6mbJoLqoTBjcWvaLRdO0g==",
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.9.tgz",
"integrity": "sha512-6cYp3LHjCGX32MiYR09l/O+8klkJ27WpKzllvnLRe5bxX3nOuqJvJJIshzT85qTdZNTFu7zJJrjWkgEOXGarzg==",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
@@ -9079,4 +9079,4 @@
}
}
}
}
}

View File

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

View File

@@ -93,7 +93,7 @@
<ion-select-option value="option2">Option 2</ion-select-option>
</ion-select>
</ion-item>
<!-- Display validation state for debugging -->
<ion-item>
<ion-label>
@@ -102,6 +102,80 @@
<p>Select Errors: <span id="select-errors">{{selectField.errors | json}}</span></p>
</ion-label>
</ion-item>
<!-- Test ion-checkbox with required validation -->
<ion-item>
<ion-checkbox
[(ngModel)]="checkboxValue"
name="checkboxField"
required
#checkboxField="ngModel"
id="template-checkbox-test"
helper-text="You must agree to continue"
error-text="Please accept the terms and conditions"
>
I agree to the terms and conditions
</ion-checkbox>
</ion-item>
<!-- Display validation state for debugging -->
<ion-item>
<ion-label>
<p>Checkbox Touched: <span id="checkbox-touched">{{checkboxField.touched}}</span></p>
<p>Checkbox Invalid: <span id="checkbox-invalid">{{checkboxField.invalid}}</span></p>
<p>Checkbox Errors: <span id="checkbox-errors">{{checkboxField.errors | json}}</span></p>
</ion-label>
</ion-item>
<!-- Test ion-toggle with required validation -->
<ion-item>
<ion-toggle
[(ngModel)]="toggleValue"
name="toggleField"
required
#toggleField="ngModel"
id="template-toggle-test"
helper-text="You must turn on to continue"
error-text="Please turn on this toggle"
>
Tap to turn on
</ion-toggle>
</ion-item>
<!-- Display validation state for debugging -->
<ion-item>
<ion-label>
<p>Toggle Touched: <span id="toggle-touched">{{toggleField.touched}}</span></p>
<p>Toggle Invalid: <span id="toggle-invalid">{{toggleField.invalid}}</span></p>
<p>Toggle Errors: <span id="toggle-errors">{{toggleField.errors | json}}</span></p>
</ion-label>
</ion-item>
<!-- Test ion-radio-group with required validation -->
<ion-item>
<ion-radio-group
[(ngModel)]="radioGroupValue"
name="radioGroupField"
required
#radioGroupField="ngModel"
id="template-radio-group-test"
helper-text="You must select one to continue"
error-text="Please select a fruit"
allow-empty-selection="true"
>
<ion-radio value="grapes">Grapes</ion-radio><br />
<ion-radio value="strawberries">Strawberries</ion-radio>
</ion-radio-group>
</ion-item>
<!-- Display validation state for debugging -->
<ion-item>
<ion-label>
<p>Radio Group Touched: <span id="radio-group-touched">{{radioGroupField.touched}}</span></p>
<p>Radio Group Invalid: <span id="radio-group-invalid">{{radioGroupField.invalid}}</span></p>
<p>Radio Group Errors: <span id="radio-group-errors">{{radioGroupField.errors | json}}</span></p>
</ion-label>
</ion-item>
</ion-list>
<div class="ion-padding">

View File

@@ -10,6 +10,9 @@ export class TemplateFormComponent {
textareaValue = '';
minLengthValue = '';
selectValue = '';
checkboxValue = false;
toggleValue = false;
radioGroupValue = '';
// Track if form has been submitted
submitted = false;

View File

@@ -48,6 +48,9 @@ export const routes: Routes = [
{ path: 'input-validation', loadComponent: () => import('../validation/input-validation/input-validation.component').then(c => c.InputValidationComponent) },
{ path: 'textarea-validation', loadComponent: () => import('../validation/textarea-validation/textarea-validation.component').then(c => c.TextareaValidationComponent) },
{ path: 'select-validation', loadComponent: () => import('../validation/select-validation/select-validation.component').then(c => c.SelectValidationComponent) },
{ path: 'checkbox-validation', loadComponent: () => import('../validation/checkbox-validation/checkbox-validation.component').then(c => c.CheckboxValidationComponent) },
{ path: 'toggle-validation', loadComponent: () => import('../validation/toggle-validation/toggle-validation.component').then(c => c.ToggleValidationComponent) },
{ path: 'radio-group-validation', loadComponent: () => import('../validation/radio-group-validation/radio-group-validation.component').then(c => c.RadioGroupValidationComponent) },
{ path: '**', redirectTo: 'input-validation' }
]
},

View File

@@ -121,19 +121,34 @@
<ion-list-header>
<ion-label>Validation Tests</ion-label>
</ion-list-header>
<ion-item routerLink="/standalone/validation/checkbox-validation">
<ion-label>
Checkbox Validation Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/validation/input-validation">
<ion-label>
Input Validation Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/validation/radio-group-validation">
<ion-label>
Radio Group Validation Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/validation/select-validation">
<ion-label>
Select Validation Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/validation/textarea-validation">
<ion-label>
Textarea Validation Test
</ion-label>
</ion-item>
<ion-item routerLink="/standalone/validation/select-validation">
<ion-item routerLink="/standalone/validation/toggle-validation">
<ion-label>
Select Validation Test
Toggle Validation Test
</ion-label>
</ion-item>
</ion-list>

View File

@@ -0,0 +1,53 @@
<ion-header>
<ion-toolbar>
<ion-title>Checkbox - Validation Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="validation-info">
<h2>Screen Reader Testing Instructions:</h2>
<ol>
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
<li>Tab through the form fields</li>
<li>When you tab away from an empty required field, the error should be announced immediately</li>
<li>The error text should be announced BEFORE the next field is announced</li>
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
</ol>
</div>
<form [formGroup]="form">
<div class="grid">
<div>
<h2>Required Field</h2>
<ion-checkbox
#termsCheckbox
id="terms-checkbox"
[helperText]="fieldMetadata.terms.helperText"
[errorText]="fieldMetadata.terms.errorText"
formControlName="terms"
required
>
{{ fieldMetadata.terms.label }}
</ion-checkbox>
</div>
<div>
<h2>Optional Field (No Validation)</h2>
<ion-checkbox
#optionalCheckbox
id="optional-checkbox"
[helperText]="fieldMetadata.optional.helperText"
formControlName="optional"
>
{{ fieldMetadata.optional.label }}
</ion-checkbox>
</div>
</div>
</form>
<div class="ion-padding">
<ion-button id="submit-btn" expand="block" [disabled]="form.invalid" (click)="onSubmit()">Submit Form</ion-button>
<ion-button id="reset-btn" expand="block" fill="outline" (click)="form.reset()">Reset Form</ion-button>
</div>
</ion-content>

View File

@@ -0,0 +1,36 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-row-gap: 50px;
grid-column-gap: 50px;
}
h2 {
font-size: 12px;
font-weight: normal;
color: var(--ion-color-step-600);
margin-top: 10px;
margin-bottom: 5px;
}
.validation-info {
margin: 20px;
padding: 10px;
background: var(--ion-color-light);
border-radius: 4px;
}
.validation-info h2 {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
}
.validation-info ol {
margin: 0;
padding-left: 20px;
}
.validation-info li {
margin-bottom: 5px;
}

View File

@@ -0,0 +1,61 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import {
FormBuilder,
ReactiveFormsModule,
Validators
} from '@angular/forms';
import {
IonButton,
IonContent,
IonHeader,
IonCheckbox,
IonTitle,
IonToolbar
} from '@ionic/angular/standalone';
@Component({
selector: 'app-checkbox-validation',
templateUrl: './checkbox-validation.component.html',
styleUrls: ['./checkbox-validation.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
IonCheckbox,
IonButton,
IonHeader,
IonToolbar,
IonTitle,
IonContent
]
})
export class CheckboxValidationComponent {
// Field metadata for labels and error messages
fieldMetadata = {
terms: {
label: 'I agree to the terms and conditions',
helperText: "You must agree to continue",
errorText: 'This field is required'
},
optional: {
label: 'Optional Checkbox',
helperText: 'You can skip this field',
errorText: ''
}
};
form = this.fb.group({
terms: [false, Validators.requiredTrue],
optional: [false]
});
constructor(private fb: FormBuilder) {}
// Submit form
onSubmit(): void {
if (this.form.valid) {
alert('Form submitted successfully!');
}
}
}

View File

@@ -0,0 +1,57 @@
<ion-header>
<ion-toolbar>
<ion-title>Radio Group - Validation Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="validation-info">
<h2>Screen Reader Testing Instructions:</h2>
<ol>
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
<li>Tab through the form fields</li>
<li>When you tab away from an empty required field, the error should be announced immediately</li>
<li>The error text should be announced BEFORE the next field is announced</li>
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
</ol>
</div>
<form [formGroup]="form">
<div class="grid">
<div>
<h2>Required Field</h2>
<ion-radio-group
#fruitsRadioGroup
id="fruits-radio-group"
[helperText]="fieldMetadata.fruits.helperText"
[errorText]="fieldMetadata.fruits.errorText"
formControlName="fruits"
allow-empty-selection="true"
required
>
<ion-radio [value]="fieldMetadata.fruits.firstRadio">{{ fieldMetadata.fruits.firstRadio }}</ion-radio><br />
<ion-radio [value]="fieldMetadata.fruits.secondRadio">{{ fieldMetadata.fruits.secondRadio }}</ion-radio>
</ion-radio-group>
</div>
<div>
<h2>Optional Field (No Validation)</h2>
<ion-radio-group
#optionalRadioGroup
id="optional-radio-group"
[helperText]="fieldMetadata.optional.helperText"
formControlName="optional"
allow-empty-selection="true"
>
<ion-radio [value]="fieldMetadata.optional.firstRadio">{{ fieldMetadata.optional.firstRadio }}</ion-radio><br />
<ion-radio [value]="fieldMetadata.optional.secondRadio">{{ fieldMetadata.optional.secondRadio }}</ion-radio>
</ion-radio-group>
</div>
</div>
</form>
<div class="ion-padding">
<ion-button id="submit-btn" expand="block" [disabled]="form.invalid" (click)="onSubmit()">Submit Form</ion-button>
<ion-button id="reset-btn" expand="block" fill="outline" (click)="form.reset()">Reset Form</ion-button>
</div>
</ion-content>

View File

@@ -0,0 +1,36 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-row-gap: 20px;
grid-column-gap: 20px;
}
h2 {
font-size: 12px;
font-weight: normal;
color: var(--ion-color-step-600);
margin-top: 10px;
margin-bottom: 5px;
}
.validation-info {
margin: 20px;
padding: 10px;
background: var(--ion-color-light);
border-radius: 4px;
}
.validation-info h2 {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
}
.validation-info ol {
margin: 0;
padding-left: 20px;
}
.validation-info li {
margin-bottom: 5px;
}

View File

@@ -0,0 +1,66 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import {
FormBuilder,
ReactiveFormsModule,
Validators
} from '@angular/forms';
import {
IonButton,
IonContent,
IonHeader,
IonRadioGroup,
IonRadio,
IonTitle,
IonToolbar
} from '@ionic/angular/standalone';
@Component({
selector: 'app-radio-group-validation',
templateUrl: './radio-group-validation.component.html',
styleUrls: ['./radio-group-validation.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
IonRadioGroup,
IonRadio,
IonButton,
IonHeader,
IonToolbar,
IonTitle,
IonContent
]
})
export class RadioGroupValidationComponent {
// Field metadata for labels and error messages
fieldMetadata = {
fruits: {
helperText: "You must select one to continue",
errorText: 'This field is required',
firstRadio: "Grapes",
secondRadio: "Strawberries"
},
optional: {
label: 'Optional Radio',
helperText: 'You can skip this field',
errorText: '',
firstRadio: "Option A",
secondRadio: "Option B"
}
};
form = this.fb.group({
fruits: ['', Validators.required],
optional: ['']
});
constructor(private fb: FormBuilder) {}
// Submit form
onSubmit(): void {
if (this.form.valid) {
alert('Form submitted successfully!');
}
}
}

View File

@@ -0,0 +1,54 @@
<ion-header>
<ion-toolbar>
<ion-title>Toggle - Validation Test</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="validation-info">
<h2>Screen Reader Testing Instructions:</h2>
<ol>
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
<li>Tab through the form fields</li>
<li>When you tab away from an empty required field, the error should be announced immediately</li>
<li>The error text should be announced BEFORE the next field is announced</li>
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
</ol>
</div>
<form [formGroup]="form">
<div class="grid">
<div>
<h2>Required Field</h2>
<ion-toggle
#onToggle
id="on-toggle"
[helperText]="fieldMetadata.on.helperText"
[errorText]="fieldMetadata.on.errorText"
formControlName="on"
required
>
{{ fieldMetadata.on.label }}
</ion-toggle>
</div>
<div>
<h2>Optional Field (No Validation)</h2>
<ion-toggle
#optionalToggle
id="optional-toggle"
[helperText]="fieldMetadata.optional.helperText"
formControlName="optional"
>
{{ fieldMetadata.optional.label }}
</ion-toggle>
</div>
</div>
</form>
<div class="ion-padding">
<ion-button id="submit-btn" expand="block" [disabled]="form.invalid" (click)="onSubmit()">Submit Form</ion-button>
<ion-button id="reset-btn" expand="block" fill="outline" (click)="form.reset()">Reset Form</ion-button>
</div>
</ion-content>

View File

@@ -0,0 +1,36 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-row-gap: 20px;
grid-column-gap: 20px;
}
h2 {
font-size: 12px;
font-weight: normal;
color: var(--ion-color-step-600);
margin-top: 10px;
margin-bottom: 5px;
}
.validation-info {
margin: 20px;
padding: 10px;
background: var(--ion-color-light);
border-radius: 4px;
}
.validation-info h2 {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
}
.validation-info ol {
margin: 0;
padding-left: 20px;
}
.validation-info li {
margin-bottom: 5px;
}

View File

@@ -0,0 +1,61 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import {
FormBuilder,
ReactiveFormsModule,
Validators
} from '@angular/forms';
import {
IonButton,
IonContent,
IonHeader,
IonToggle,
IonTitle,
IonToolbar
} from '@ionic/angular/standalone';
@Component({
selector: 'app-toggle-validation',
templateUrl: './toggle-validation.component.html',
styleUrls: ['./toggle-validation.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
IonToggle,
IonButton,
IonHeader,
IonToolbar,
IonTitle,
IonContent
]
})
export class ToggleValidationComponent {
// Field metadata for labels and error messages
fieldMetadata = {
on: {
label: 'Tap to turn on',
helperText: "You must turn on to continue",
errorText: 'This field is required'
},
optional: {
label: 'Optional Toggle',
helperText: 'You can skip this field',
errorText: ''
}
};
form = this.fb.group({
on: [false, Validators.requiredTrue],
optional: [false]
});
constructor(private fb: FormBuilder) {}
// Submit form
onSubmit(): void {
if (this.form.valid) {
alert('Form submitted successfully!');
}
}
}

View File

@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [8.7.9](https://github.com/ionic-team/ionic-framework/compare/v8.7.8...v8.7.9) (2025-11-05)
**Note:** Version bump only for package @ionic/docs
## [8.7.8](https://github.com/ionic-team/ionic-framework/compare/v8.7.7...v8.7.8) (2025-10-29)
**Note:** Version bump only for package @ionic/docs
## [8.7.7](https://github.com/ionic-team/ionic-framework/compare/v8.7.6...v8.7.7) (2025-10-15)
**Note:** Version bump only for package @ionic/docs

View File

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

View File

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

View File

@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [8.7.9](https://github.com/ionic-team/ionic-framework/compare/v8.7.8...v8.7.9) (2025-11-05)
**Note:** Version bump only for package @ionic/react-router
## [8.7.8](https://github.com/ionic-team/ionic-framework/compare/v8.7.7...v8.7.8) (2025-10-29)
**Note:** Version bump only for package @ionic/react-router
## [8.7.7](https://github.com/ionic-team/ionic-framework/compare/v8.7.6...v8.7.7) (2025-10-15)
**Note:** Version bump only for package @ionic/react-router

View File

@@ -1,15 +1,15 @@
{
"name": "@ionic/react-router",
"version": "8.7.7",
"version": "8.7.9",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@ionic/react-router",
"version": "8.7.7",
"version": "8.7.9",
"license": "MIT",
"dependencies": {
"@ionic/react": "^8.7.7",
"@ionic/react": "^8.7.9",
"tslib": "*"
},
"devDependencies": {
@@ -238,9 +238,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.6",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.6.tgz",
"integrity": "sha512-ufV64Pl0BYSoNla+DaTRXTS3hX6MQZZJPhAR3fJQ4N5Fg/vwMcHADQffstKZeoPqk6mbJoLqoTBjcWvaLRdO0g==",
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.9.tgz",
"integrity": "sha512-6cYp3LHjCGX32MiYR09l/O+8klkJ27WpKzllvnLRe5bxX3nOuqJvJJIshzT85qTdZNTFu7zJJrjWkgEOXGarzg==",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
@@ -415,12 +415,12 @@
}
},
"node_modules/@ionic/react": {
"version": "8.7.6",
"resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.7.6.tgz",
"integrity": "sha512-7uoqcd5AOovtN7MJd5v9xeQFKF4og8W++bvDgka6TQcMTE/8mmzpFJUpOSvhFeOITMaqBHtkCNMvQSCa7vsmfw==",
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.7.9.tgz",
"integrity": "sha512-mG+0oGUOXt54QIgY6XTLWdf0u3dxHWVcuCXFH1fh4t2F7QPP392LJx6wIZieTc+Cp1r94uuNKWBZmdVPuvRxSA==",
"license": "MIT",
"dependencies": {
"@ionic/core": "8.7.6",
"@ionic/core": "8.7.9",
"ionicons": "^8.0.13",
"tslib": "*"
},
@@ -4175,9 +4175,9 @@
"dev": true
},
"@ionic/core": {
"version": "8.7.6",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.6.tgz",
"integrity": "sha512-ufV64Pl0BYSoNla+DaTRXTS3hX6MQZZJPhAR3fJQ4N5Fg/vwMcHADQffstKZeoPqk6mbJoLqoTBjcWvaLRdO0g==",
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.9.tgz",
"integrity": "sha512-6cYp3LHjCGX32MiYR09l/O+8klkJ27WpKzllvnLRe5bxX3nOuqJvJJIshzT85qTdZNTFu7zJJrjWkgEOXGarzg==",
"requires": {
"@stencil/core": "4.38.0",
"ionicons": "^8.0.13",
@@ -4281,11 +4281,11 @@
"requires": {}
},
"@ionic/react": {
"version": "8.7.6",
"resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.7.6.tgz",
"integrity": "sha512-7uoqcd5AOovtN7MJd5v9xeQFKF4og8W++bvDgka6TQcMTE/8mmzpFJUpOSvhFeOITMaqBHtkCNMvQSCa7vsmfw==",
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.7.9.tgz",
"integrity": "sha512-mG+0oGUOXt54QIgY6XTLWdf0u3dxHWVcuCXFH1fh4t2F7QPP392LJx6wIZieTc+Cp1r94uuNKWBZmdVPuvRxSA==",
"requires": {
"@ionic/core": "8.7.6",
"@ionic/core": "8.7.9",
"ionicons": "^8.0.13",
"tslib": "*"
}
@@ -6844,4 +6844,4 @@
"dev": true
}
}
}
}

View File

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

View File

@@ -3,6 +3,25 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [8.7.9](https://github.com/ionic-team/ionic-framework/compare/v8.7.8...v8.7.9) (2025-11-05)
### Bug Fixes
* **accordion-group:** skip initial animation ([#30729](https://github.com/ionic-team/ionic-framework/issues/30729)) ([58d5638](https://github.com/ionic-team/ionic-framework/commit/58d563805fca1db88caeeb40a8f710ac30416d93)), closes [#30613](https://github.com/ionic-team/ionic-framework/issues/30613)
## [8.7.8](https://github.com/ionic-team/ionic-framework/compare/v8.7.7...v8.7.8) (2025-10-29)
**Note:** Version bump only for package @ionic/react
## [8.7.7](https://github.com/ionic-team/ionic-framework/compare/v8.7.6...v8.7.7) (2025-10-15)
**Note:** Version bump only for package @ionic/react

View File

@@ -1,15 +1,15 @@
{
"name": "@ionic/react",
"version": "8.7.7",
"version": "8.7.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ionic/react",
"version": "8.7.7",
"version": "8.7.9",
"license": "MIT",
"dependencies": {
"@ionic/core": "^8.7.7",
"@ionic/core": "^8.7.9",
"ionicons": "^8.0.13",
"tslib": "*"
},
@@ -736,9 +736,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.6",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.6.tgz",
"integrity": "sha512-ufV64Pl0BYSoNla+DaTRXTS3hX6MQZZJPhAR3fJQ4N5Fg/vwMcHADQffstKZeoPqk6mbJoLqoTBjcWvaLRdO0g==",
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.9.tgz",
"integrity": "sha512-6cYp3LHjCGX32MiYR09l/O+8klkJ27WpKzllvnLRe5bxX3nOuqJvJJIshzT85qTdZNTFu7zJJrjWkgEOXGarzg==",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.38.0",
@@ -11913,4 +11913,4 @@
}
}
}
}
}

View File

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

View File

@@ -37,6 +37,7 @@ import KeepContentsMounted from './pages/overlay-components/KeepContentsMounted'
import OverlayComponents from './pages/overlay-components/OverlayComponents';
import OverlayHooks from './pages/overlay-hooks/OverlayHooks';
import ReorderGroup from './pages/ReorderGroup';
import AccordionGroup from './pages/AccordionGroup';
setupIonicReact();
@@ -69,6 +70,7 @@ const App: React.FC = () => (
<Route path="/icons" component={Icons} />
<Route path="/inputs" component={Inputs} />
<Route path="/reorder-group" component={ReorderGroup} />
<Route path="/accordion-group" component={AccordionGroup} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>

View File

@@ -0,0 +1,54 @@
import { IonHeader, IonTitle, IonToolbar, IonPage, IonContent, IonAccordionGroup, IonAccordion, IonItem, IonLabel } from '@ionic/react';
import { useEffect, useRef } from 'react';
const AccordionGroup: React.FC = () => {
const accordionGroup = useRef<null | HTMLIonAccordionGroupElement>(null);
useEffect(() => {
if (!accordionGroup.current) {
return;
}
accordionGroup.current.value = ['first', 'third'];
}, []);
return (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>Accordion Group</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<IonAccordionGroup ref={accordionGroup} multiple={true}>
<IonAccordion value="first">
<IonItem slot="header" color="light">
<IonLabel>First Accordion</IonLabel>
</IonItem>
<div className="ion-padding" slot="content">
First Content
</div>
</IonAccordion>
<IonAccordion value="second">
<IonItem slot="header" color="light">
<IonLabel>Second Accordion</IonLabel>
</IonItem>
<div className="ion-padding" slot="content">
Second Content
</div>
</IonAccordion>
<IonAccordion value="third">
<IonItem slot="header" color="light">
<IonLabel>Third Accordion</IonLabel>
</IonItem>
<div className="ion-padding" slot="content">
Third Content
</div>
</IonAccordion>
</IonAccordionGroup>
</IonContent>
</IonPage>
);
};
export default AccordionGroup;

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