Compare commits

..

5 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
64 changed files with 1696 additions and 280 deletions

View File

@@ -3,7 +3,7 @@ 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: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -11,15 +11,15 @@ runs:
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

@@ -11,23 +11,23 @@ runs:
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: 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: 24.x
- name: Install Dependencies
- 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,7 +3,7 @@ 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: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -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,7 +3,7 @@ description: 'Build Ionic React'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -11,27 +11,27 @@ runs:
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,7 +3,7 @@ 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: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -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,7 +3,7 @@ description: 'Build Ionic Vue'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -11,23 +11,23 @@ runs:
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

@@ -14,6 +14,6 @@ runs:
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: 24.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,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -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,7 +3,7 @@ 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: 24.x
@@ -12,7 +12,7 @@ runs:
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: 24.x
- name: Install Dependencies
- 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,7 +13,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -21,11 +21,11 @@ runs:
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@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: 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 }}
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,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -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,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -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,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
@@ -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: 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,7 +10,7 @@ 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@v5

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

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

@@ -18337,4 +18337,4 @@
"dev": true
}
}
}
}

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';
@@ -35,6 +36,7 @@ export class Checkbox implements ComponentInterface {
private helperTextId = `${this.inputId}-helper-text`;
private errorTextId = `${this.inputId}-error-text`;
private inheritedAttributes: Attributes = {};
private validationObserver?: MutationObserver;
@Element() el!: HTMLIonCheckboxElement;
@@ -120,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.
*
@@ -137,10 +146,63 @@ 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 */
@@ -203,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;
}
@@ -222,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
@@ -235,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>
);
@@ -274,11 +336,12 @@ 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}

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

@@ -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';
@@ -44,11 +45,19 @@ export class Toggle implements ComponentInterface {
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"`.
@@ -168,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() {
@@ -207,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() {
@@ -335,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;
}
@@ -354,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
@@ -367,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>
);
@@ -385,7 +443,6 @@ export class Toggle implements ComponentInterface {
color,
disabled,
el,
errorTextId,
hasLabel,
inheritedAttributes,
inputId,
@@ -405,12 +462,13 @@ 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}

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

@@ -1031,9 +1031,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.8",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.8.tgz",
"integrity": "sha512-GLWb/lz3kocpzTZTeQQ5xxoWz4CKHD6zpnbwJknTKsncebohAaw2KTe7uOw5toKQEDdohTseFuSGoDDBoRQ1Ug==",
"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.8",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.8.tgz",
"integrity": "sha512-GLWb/lz3kocpzTZTeQQ5xxoWz4CKHD6zpnbwJknTKsncebohAaw2KTe7uOw5toKQEDdohTseFuSGoDDBoRQ1Ug==",
"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

@@ -1398,9 +1398,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.8",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.8.tgz",
"integrity": "sha512-GLWb/lz3kocpzTZTeQQ5xxoWz4CKHD6zpnbwJknTKsncebohAaw2KTe7uOw5toKQEDdohTseFuSGoDDBoRQ1Ug==",
"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

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

@@ -10,4 +10,4 @@
"license": "MIT"
}
}
}
}

View File

@@ -238,9 +238,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.8",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.8.tgz",
"integrity": "sha512-GLWb/lz3kocpzTZTeQQ5xxoWz4CKHD6zpnbwJknTKsncebohAaw2KTe7uOw5toKQEDdohTseFuSGoDDBoRQ1Ug==",
"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.8",
"resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.7.8.tgz",
"integrity": "sha512-QRxGXcSkfmwVIFxdHI776bqiHpqT1FwwVNASBRPCD8RNCIT9NTZIvgNdJ2FokBZjHRfgk4QuYOcQntrbPmK0Hg==",
"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.8",
"@ionic/core": "8.7.9",
"ionicons": "^8.0.13",
"tslib": "*"
},
@@ -4175,9 +4175,9 @@
"dev": true
},
"@ionic/core": {
"version": "8.7.8",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.8.tgz",
"integrity": "sha512-GLWb/lz3kocpzTZTeQQ5xxoWz4CKHD6zpnbwJknTKsncebohAaw2KTe7uOw5toKQEDdohTseFuSGoDDBoRQ1Ug==",
"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.8",
"resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.7.8.tgz",
"integrity": "sha512-QRxGXcSkfmwVIFxdHI776bqiHpqT1FwwVNASBRPCD8RNCIT9NTZIvgNdJ2FokBZjHRfgk4QuYOcQntrbPmK0Hg==",
"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.8",
"@ionic/core": "8.7.9",
"ionicons": "^8.0.13",
"tslib": "*"
}
@@ -6844,4 +6844,4 @@
"dev": true
}
}
}
}

View File

@@ -736,9 +736,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.8",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.8.tgz",
"integrity": "sha512-GLWb/lz3kocpzTZTeQQ5xxoWz4CKHD6zpnbwJknTKsncebohAaw2KTe7uOw5toKQEDdohTseFuSGoDDBoRQ1Ug==",
"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

@@ -673,9 +673,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.8",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.8.tgz",
"integrity": "sha512-GLWb/lz3kocpzTZTeQQ5xxoWz4CKHD6zpnbwJknTKsncebohAaw2KTe7uOw5toKQEDdohTseFuSGoDDBoRQ1Ug==",
"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",
@@ -865,12 +865,12 @@
}
},
"node_modules/@ionic/vue": {
"version": "8.7.8",
"resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-8.7.8.tgz",
"integrity": "sha512-vB8JBfIduHDD8+MnhGX8w3zXB/abNqjqwOjv+0SOrMx1sp9TvpPi2vYGjtSrFlg1PvOv8EGNzVYTbURmwgvcHA==",
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-8.7.9.tgz",
"integrity": "sha512-chpnclhnL8dC+Wi2/Fd/muI4FPzTqyVvFgIKfMDaz00tF8xmhHI9AXwaRvtt7AJh8CWGAw1QCp/FMboFSQ7D7A==",
"license": "MIT",
"dependencies": {
"@ionic/core": "8.7.8",
"@ionic/core": "8.7.9",
"@stencil/vue-output-target": "0.10.7",
"ionicons": "^8.0.13"
}
@@ -8041,9 +8041,9 @@
"dev": true
},
"@ionic/core": {
"version": "8.7.8",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.8.tgz",
"integrity": "sha512-GLWb/lz3kocpzTZTeQQ5xxoWz4CKHD6zpnbwJknTKsncebohAaw2KTe7uOw5toKQEDdohTseFuSGoDDBoRQ1Ug==",
"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",
@@ -8156,11 +8156,11 @@
"requires": {}
},
"@ionic/vue": {
"version": "8.7.8",
"resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-8.7.8.tgz",
"integrity": "sha512-vB8JBfIduHDD8+MnhGX8w3zXB/abNqjqwOjv+0SOrMx1sp9TvpPi2vYGjtSrFlg1PvOv8EGNzVYTbURmwgvcHA==",
"version": "8.7.9",
"resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-8.7.9.tgz",
"integrity": "sha512-chpnclhnL8dC+Wi2/Fd/muI4FPzTqyVvFgIKfMDaz00tF8xmhHI9AXwaRvtt7AJh8CWGAw1QCp/FMboFSQ7D7A==",
"requires": {
"@ionic/core": "8.7.8",
"@ionic/core": "8.7.9",
"@stencil/vue-output-target": "0.10.7",
"ionicons": "^8.0.13"
}
@@ -12991,4 +12991,4 @@
"dev": true
}
}
}
}

View File

@@ -222,9 +222,9 @@
"dev": true
},
"node_modules/@ionic/core": {
"version": "8.7.8",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.8.tgz",
"integrity": "sha512-GLWb/lz3kocpzTZTeQQ5xxoWz4CKHD6zpnbwJknTKsncebohAaw2KTe7uOw5toKQEDdohTseFuSGoDDBoRQ1Ug==",
"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",
@@ -4019,4 +4019,4 @@
"dev": true
}
}
}
}