Compare commits
59 Commits
v8.7.7
...
fix/dateti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8772e8bd9 | ||
|
|
12ef9345d6 | ||
|
|
79d5716baf | ||
|
|
14419288c0 | ||
|
|
2ee52d77c8 | ||
|
|
0e110de5e3 | ||
|
|
f50994a6ef | ||
|
|
5bf6f6e825 | ||
|
|
afa15d23d2 | ||
|
|
b1645168a7 | ||
|
|
b9e3cf0f5a | ||
|
|
99dcf3810a | ||
|
|
6643f6a115 | ||
|
|
1c89cf06ac | ||
|
|
3129565e4e | ||
|
|
39a0be848c | ||
|
|
57687623aa | ||
|
|
3709bba41e | ||
|
|
76e4901189 | ||
|
|
22288319ed | ||
|
|
c65b76e727 | ||
|
|
b4e540decc | ||
|
|
87e50ad9da | ||
|
|
595643fd14 | ||
|
|
3249e1dce8 | ||
|
|
e9bd3f819d | ||
|
|
bf0f1e36e4 | ||
|
|
9d781db662 | ||
|
|
9ae41efddb | ||
|
|
7379d34f38 | ||
|
|
53f3bea244 | ||
|
|
5c86b87fe3 | ||
|
|
d8e6756ac3 | ||
|
|
627416b9d7 | ||
|
|
eeb15c3c5c | ||
|
|
2bebbd7a3e | ||
|
|
b3826febe0 | ||
|
|
1ff26b796b | ||
|
|
99bfdee4cd | ||
|
|
d03e88179b | ||
|
|
ecc291138e | ||
|
|
92db36489c | ||
|
|
c37e2a5d9e | ||
|
|
0a02e0f8cf | ||
|
|
e1293ff9f6 | ||
|
|
32df083e87 | ||
|
|
58d563805f | ||
|
|
cfd8c42f07 | ||
|
|
0b2e766609 | ||
|
|
10f895b8b6 | ||
|
|
66abc05c46 | ||
|
|
8a8eec4247 | ||
|
|
abd3eacadf | ||
|
|
54a1c86d6a | ||
|
|
ba73988750 | ||
|
|
bdc80d8e82 | ||
|
|
9f013b7a51 | ||
|
|
e4fc33f331 | ||
|
|
4a49e52b6d |
@@ -8,48 +8,53 @@ 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@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: ${{ inputs.node-version }}
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
scope: '@ionic'
|
||||
# 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 }}
|
||||
|
||||
@@ -3,23 +3,23 @@ description: 'Build Ionic Angular Server'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
path: ./core
|
||||
filename: CoreBuild.zip
|
||||
- name: Install Angular Server Dependencies
|
||||
- name: 🕸️ Install Angular Server Dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
working-directory: ./packages/angular-server
|
||||
- name: Sync
|
||||
- name: 🔄 Sync
|
||||
run: npm run sync
|
||||
shell: bash
|
||||
working-directory: ./packages/angular-server
|
||||
- name: Build
|
||||
- name: 🏗️ Build
|
||||
run: npm run build.prod
|
||||
shell: bash
|
||||
working-directory: ./packages/angular-server
|
||||
|
||||
@@ -5,29 +5,29 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
path: ./core
|
||||
filename: CoreBuild.zip
|
||||
- name: Install Angular Dependencies
|
||||
- name: 🕸️ Install Angular Dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
working-directory: ./packages/angular
|
||||
- name: Sync
|
||||
- name: 🔄 Sync
|
||||
run: npm run sync
|
||||
shell: bash
|
||||
working-directory: ./packages/angular
|
||||
- name: Lint
|
||||
- name: 🖌️ Lint
|
||||
run: npm run lint
|
||||
shell: bash
|
||||
working-directory: ./packages/angular
|
||||
- name: Build
|
||||
- name: 🏗️ Build
|
||||
run: npm run build
|
||||
shell: bash
|
||||
working-directory: ./packages/angular
|
||||
- name: Check Diff
|
||||
- name: 🔍 Check Diff
|
||||
run: git diff --exit-code
|
||||
shell: bash
|
||||
working-directory: ./packages/angular
|
||||
|
||||
@@ -8,20 +8,20 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
|
||||
- name: Install Dependencies
|
||||
- name: 🕸️ Install Dependencies
|
||||
run: npm ci
|
||||
working-directory: ./core
|
||||
shell: bash
|
||||
- name: Install Stencil ${{ inputs.stencil-version }}
|
||||
- name: 📦 Install Stencil ${{ inputs.stencil-version }}
|
||||
working-directory: ./core
|
||||
run: npm i @stencil/core@${{ inputs.stencil-version }}
|
||||
shell: bash
|
||||
- name: Build Core
|
||||
- name: 🏗️ Build Core
|
||||
run: npm run build -- --ci --debug --verbose
|
||||
working-directory: ./core
|
||||
shell: bash
|
||||
|
||||
12
.github/workflows/actions/build-core/action.yml
vendored
@@ -8,22 +8,22 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
- name: Install Dependencies
|
||||
node-version: 24.x
|
||||
- name: 🕸️ Install Dependencies
|
||||
run: npm install
|
||||
working-directory: ./core
|
||||
shell: bash
|
||||
# If an Ionicons version was specified install that.
|
||||
# Otherwise just use the version defined in the package.json.
|
||||
- name: Install Ionicons Version
|
||||
- name: 📦 Install Ionicons Version
|
||||
if: inputs.ionicons-version != ''
|
||||
run: npm install ionicons@${{ inputs.ionicons-version }}
|
||||
working-directory: ./core
|
||||
shell: bash
|
||||
- name: Build Core
|
||||
- name: 🏗️ Build Core
|
||||
run: npm run build -- --ci
|
||||
working-directory: ./core
|
||||
shell: bash
|
||||
|
||||
@@ -3,9 +3,9 @@ description: 'Build Ionic React Router'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
@@ -16,19 +16,19 @@ runs:
|
||||
name: ionic-react
|
||||
path: ./packages/react
|
||||
filename: ReactBuild.zip
|
||||
- name: Install Dependencies
|
||||
- name: 🕸️ Install Dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
working-directory: ./packages/react-router
|
||||
- name: Sync
|
||||
- name: 🔄 Sync
|
||||
run: npm run sync
|
||||
shell: bash
|
||||
working-directory: ./packages/react-router
|
||||
- name: Lint
|
||||
- name: 🖌️ Lint
|
||||
run: npm run lint
|
||||
shell: bash
|
||||
working-directory: ./packages/react-router
|
||||
- name: Build
|
||||
- name: 🏗️ Build
|
||||
run: npm run build
|
||||
shell: bash
|
||||
working-directory: ./packages/react-router
|
||||
|
||||
16
.github/workflows/actions/build-react/action.yml
vendored
@@ -3,35 +3,35 @@ description: 'Build Ionic React'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
path: ./core
|
||||
filename: CoreBuild.zip
|
||||
- name: Install React Dependencies
|
||||
- name: 🕸️ Install React Dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
working-directory: ./packages/react
|
||||
- name: Sync
|
||||
- name: 🔄 Sync
|
||||
run: npm run sync
|
||||
shell: bash
|
||||
working-directory: ./packages/react
|
||||
- name: Lint
|
||||
- name: 🖌️ Lint
|
||||
run: npm run lint
|
||||
shell: bash
|
||||
working-directory: ./packages/react
|
||||
- name: Build
|
||||
- name: 🏗️ Build
|
||||
run: npm run build
|
||||
shell: bash
|
||||
working-directory: ./packages/react
|
||||
- name: Test Spec
|
||||
- name: 🧪 Test Spec
|
||||
run: npm run test.spec
|
||||
shell: bash
|
||||
working-directory: ./packages/react
|
||||
- name: Check Diff
|
||||
- name: 🔍 Check Diff
|
||||
run: git diff --exit-code
|
||||
shell: bash
|
||||
working-directory: ./packages/react
|
||||
|
||||
@@ -3,9 +3,9 @@ description: 'Builds Ionic Vue Router'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
@@ -16,23 +16,23 @@ runs:
|
||||
name: ionic-vue
|
||||
path: ./packages/vue
|
||||
filename: VueBuild.zip
|
||||
- name: Install Vue Router Dependencies
|
||||
- name: 🕸️ Install Vue Router Dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
working-directory: ./packages/vue-router
|
||||
- name: Sync
|
||||
- name: 🔄 Sync
|
||||
run: npm run sync
|
||||
shell: bash
|
||||
working-directory: ./packages/vue-router
|
||||
- name: Lint
|
||||
- name: 🖌️ Lint
|
||||
run: npm run lint
|
||||
shell: bash
|
||||
working-directory: ./packages/vue-router
|
||||
- name: Build
|
||||
- name: 🏗️ Build
|
||||
run: npm run build
|
||||
shell: bash
|
||||
working-directory: ./packages/vue-router
|
||||
- name: Test Spec
|
||||
- name: 🧪 Test Spec
|
||||
run: npm run test.spec
|
||||
shell: bash
|
||||
working-directory: ./packages/vue-router
|
||||
|
||||
14
.github/workflows/actions/build-vue/action.yml
vendored
@@ -3,31 +3,31 @@ description: 'Build Ionic Vue'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
path: ./core
|
||||
filename: CoreBuild.zip
|
||||
- name: Install Vue Dependencies
|
||||
- name: 🕸️ Install Vue Dependencies
|
||||
run: npm ci
|
||||
shell: bash
|
||||
working-directory: ./packages/vue
|
||||
- name: Sync
|
||||
- name: 🔄 Sync
|
||||
run: npm run sync
|
||||
shell: bash
|
||||
working-directory: ./packages/vue
|
||||
- name: Lint
|
||||
- name: 🖌️ Lint
|
||||
run: npm run lint
|
||||
shell: bash
|
||||
working-directory: ./packages/vue
|
||||
- name: Build
|
||||
- name: 🏗️ Build
|
||||
run: npm run build
|
||||
shell: bash
|
||||
working-directory: ./packages/vue
|
||||
- name: Check Diff
|
||||
- name: 🔍 Check Diff
|
||||
run: git diff --exit-code
|
||||
shell: bash
|
||||
working-directory: ./packages/vue
|
||||
|
||||
@@ -10,10 +10,10 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/download-artifact@v5
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: ${{ inputs.name }}
|
||||
path: ${{ inputs.path }}
|
||||
- name: Extract Archive
|
||||
- name: 🔎 Extract Archive
|
||||
run: unzip -q -o ${{ inputs.path }}/${{ inputs.filename }}
|
||||
shell: bash
|
||||
|
||||
@@ -6,9 +6,9 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
@@ -24,23 +24,23 @@ runs:
|
||||
name: ionic-angular-server
|
||||
path: ./packages/angular-server
|
||||
filename: AngularServerBuild.zip
|
||||
- name: Create Test App
|
||||
- name: 🧪 Create Test App
|
||||
run: ./build.sh ${{ inputs.app }}
|
||||
shell: bash
|
||||
working-directory: ./packages/angular/test
|
||||
- name: Install Dependencies
|
||||
- name: 🕸️ Install Dependencies
|
||||
run: npm install
|
||||
shell: bash
|
||||
working-directory: ./packages/angular/test/build/${{ inputs.app }}
|
||||
- name: Install Playwright Browsers
|
||||
- name: 📦 Install Playwright Browsers
|
||||
run: npx playwright install
|
||||
shell: bash
|
||||
working-directory: ./packages/angular/test/build/${{ inputs.app }}
|
||||
- name: Sync Built Changes
|
||||
- name: 🔄 Sync Built Changes
|
||||
run: npm run sync
|
||||
shell: bash
|
||||
working-directory: ./packages/angular/test/build/${{ inputs.app }}
|
||||
- name: Run Tests
|
||||
- name: 🧪 Run Tests
|
||||
run: npm run test
|
||||
shell: bash
|
||||
working-directory: ./packages/angular/test/build/${{ inputs.app }}
|
||||
|
||||
@@ -3,16 +3,16 @@ description: 'Test Core Clean Build'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
path: ./core
|
||||
filename: CoreBuild.zip
|
||||
- name: Check Diff
|
||||
- name: 🔍 Check Diff
|
||||
run: |
|
||||
git diff --exit-code || {
|
||||
echo -e "\033[1;31m⚠️ Error: Differences Detected ⚠️\033[0m"
|
||||
|
||||
@@ -3,21 +3,21 @@ description: 'Test Core Lint'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
- name: Install Dependencies
|
||||
node-version: 24.x
|
||||
- name: 🕸️ Install Dependencies
|
||||
run: npm ci
|
||||
working-directory: ./core
|
||||
shell: bash
|
||||
- name: Lint
|
||||
- name: 🖌️ Lint
|
||||
run: npm run lint
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
# Lint changes should be pushed
|
||||
# to the branch before the branch
|
||||
# is merge eligible.
|
||||
- name: Check Lint Results
|
||||
- name: 🔎 Check Lint Results
|
||||
run: git diff --exit-code
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
|
||||
@@ -13,19 +13,19 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
path: ./core
|
||||
filename: CoreBuild.zip
|
||||
- name: Install Dependencies
|
||||
- name: 🕸️ Install Dependencies
|
||||
run: npm install
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
- name: Test
|
||||
- name: 🧪 Test
|
||||
if: inputs.update != 'true'
|
||||
run: npm run test.e2e.docker.ci ${{ inputs.component }} -- --shard=${{ inputs.shard }}/${{ inputs.totalShards }}
|
||||
shell: bash
|
||||
@@ -60,13 +60,13 @@ runs:
|
||||
fi
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
- name: Archive Updated Screenshots
|
||||
- name: 📦 Archive Updated Screenshots
|
||||
if: inputs.update == 'true' && steps.test-and-update.outputs.hasUpdatedScreenshots == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: updated-screenshots-${{ inputs.shard }}-${{ inputs.totalShards }}
|
||||
path: UpdatedScreenshots-${{ inputs.shard }}-${{ inputs.totalShards }}.zip
|
||||
- name: Archive Test Results
|
||||
- name: 📦 Archive Test Results
|
||||
# The always() ensures that this step
|
||||
# runs even if the previous step fails.
|
||||
# We want the test results to be archived
|
||||
|
||||
@@ -6,14 +6,14 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
- name: Install Dependencies
|
||||
node-version: 24.x
|
||||
- name: 🕸️ Install Dependencies
|
||||
run: npm ci
|
||||
working-directory: ./core
|
||||
shell: bash
|
||||
- name: Install Stencil ${{ inputs.stencil-version }}
|
||||
- name: 📦 Install Stencil ${{ inputs.stencil-version }}
|
||||
run: npm install @stencil/core@${{ inputs.stencil-version }}
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
@@ -23,7 +23,7 @@ runs:
|
||||
name: ionic-core
|
||||
path: ./core
|
||||
filename: CoreBuild.zip
|
||||
- name: Test
|
||||
- name: 🧪 Test
|
||||
run: npm run test.spec -- --ci
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
|
||||
@@ -6,9 +6,9 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
@@ -24,23 +24,23 @@ runs:
|
||||
name: ionic-react-router
|
||||
path: ./packages/react-router
|
||||
filename: ReactRouterBuild.zip
|
||||
- name: Create Test App
|
||||
- name: 🧪 Create Test App
|
||||
run: ./build.sh ${{ inputs.app }}
|
||||
shell: bash
|
||||
working-directory: ./packages/react/test
|
||||
- name: Install Dependencies
|
||||
- name: 🕸️ Install Dependencies
|
||||
run: npm install
|
||||
shell: bash
|
||||
working-directory: ./packages/react/test/build/${{ inputs.app }}
|
||||
- name: Sync Built Changes
|
||||
- name: 🔄 Sync Built Changes
|
||||
run: npm run sync
|
||||
shell: bash
|
||||
working-directory: ./packages/react/test/build/${{ inputs.app }}
|
||||
- name: Build
|
||||
- name: 🏗️ Build
|
||||
run: npm run build
|
||||
shell: bash
|
||||
working-directory: ./packages/react/test/build/${{ inputs.app }}
|
||||
- name: Run Tests
|
||||
- name: 🧪 Run Tests
|
||||
run: npm run e2e
|
||||
shell: bash
|
||||
working-directory: ./packages/react/test/build/${{ inputs.app }}
|
||||
|
||||
@@ -6,9 +6,9 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
@@ -24,23 +24,23 @@ runs:
|
||||
name: ionic-react-router
|
||||
path: ./packages/react-router
|
||||
filename: ReactRouterBuild.zip
|
||||
- name: Create Test App
|
||||
- name: 🧪 Create Test App
|
||||
run: ./build.sh ${{ inputs.app }}
|
||||
shell: bash
|
||||
working-directory: ./packages/react-router/test
|
||||
- name: Install Dependencies
|
||||
- name: 🕸️ Install Dependencies
|
||||
run: npm install
|
||||
shell: bash
|
||||
working-directory: ./packages/react-router/test/build/${{ inputs.app }}
|
||||
- name: Sync Built Changes
|
||||
- name: 🔄 Sync Built Changes
|
||||
run: npm run sync
|
||||
shell: bash
|
||||
working-directory: ./packages/react-router/test/build/${{ inputs.app }}
|
||||
- name: Build
|
||||
- name: 🏗️ Build
|
||||
run: npm run build
|
||||
shell: bash
|
||||
working-directory: ./packages/react-router/test/build/${{ inputs.app }}
|
||||
- name: Run Tests
|
||||
- name: 🧪 Run Tests
|
||||
run: npm run e2e
|
||||
shell: bash
|
||||
working-directory: ./packages/react-router/test/build/${{ inputs.app }}
|
||||
|
||||
@@ -6,9 +6,9 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
@@ -24,23 +24,23 @@ runs:
|
||||
name: ionic-vue-router
|
||||
path: ./packages/vue-router
|
||||
filename: VueRouterBuild.zip
|
||||
- name: Create Test App
|
||||
- name: 🧪 Create Test App
|
||||
run: ./build.sh ${{ inputs.app }}
|
||||
shell: bash
|
||||
working-directory: ./packages/vue/test
|
||||
- name: Install Dependencies
|
||||
- name: 📦 Install Dependencies
|
||||
run: npm install
|
||||
shell: bash
|
||||
working-directory: ./packages/vue/test/build/${{ inputs.app }}
|
||||
- name: Sync
|
||||
- name: 🔄 Sync
|
||||
run: npm run sync
|
||||
shell: bash
|
||||
working-directory: ./packages/vue/test/build/${{ inputs.app }}
|
||||
- name: Run Spec Tests
|
||||
- name: 🧪 Run Spec Tests
|
||||
run: npm run test:unit
|
||||
shell: bash
|
||||
working-directory: ./packages/vue/test/build/${{ inputs.app }}
|
||||
- name: Run E2E Tests
|
||||
- name: 🧪 Run E2E Tests
|
||||
run: npm run test:e2e
|
||||
shell: bash
|
||||
working-directory: ./packages/vue/test/build/${{ inputs.app }}
|
||||
|
||||
@@ -7,13 +7,13 @@ on:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
- uses: actions/download-artifact@v5
|
||||
node-version: 24.x
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: ./artifacts
|
||||
- name: Extract Archives
|
||||
- name: 🔎 Extract Archives
|
||||
# This finds all .zip files in the ./artifacts
|
||||
# directory, including nested directories.
|
||||
# It then unzips every .zip to the root directory
|
||||
@@ -21,7 +21,7 @@ runs:
|
||||
find . -type f -name 'UpdatedScreenshots-*.zip' -exec unzip -q -o -d ../ {} \;
|
||||
shell: bash
|
||||
working-directory: ./artifacts
|
||||
- name: Push Screenshots
|
||||
- name: 📸 Push Screenshots
|
||||
# Configure user as Ionitron
|
||||
# and push only the changed .png snapshots
|
||||
# to the remote branch.
|
||||
|
||||
@@ -10,10 +10,10 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Create Archive
|
||||
- name: 🗄️ Create Archive
|
||||
run: zip -q -r ${{ inputs.output }} ${{ inputs.paths }}
|
||||
shell: bash
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: ${{ inputs.name }}
|
||||
path: ${{ inputs.output }}
|
||||
|
||||
30
.github/workflows/build.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
build-core:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: ./.github/workflows/actions/build-vue
|
||||
|
||||
build-vue-router:
|
||||
needs: [build-vue]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: ./.github/workflows/actions/build-angular
|
||||
|
||||
build-angular-server:
|
||||
needs: [build-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: ./.github/workflows/actions/build-react
|
||||
|
||||
build-react-router:
|
||||
needs: [build-react]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: ./.github/workflows/actions/test-react-e2e
|
||||
with:
|
||||
app: ${{ matrix.apps }}
|
||||
|
||||
2
.github/workflows/codeql-analysis.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
permissions:
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: javascript
|
||||
|
||||
11
.github/workflows/dev-build.yml
vendored
@@ -1,7 +1,11 @@
|
||||
name: 'Ionic Dev Build'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
create-dev-hash:
|
||||
@@ -9,7 +13,7 @@ jobs:
|
||||
outputs:
|
||||
dev-hash: ${{ steps.create-dev-hash.outputs.DEV_HASH }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
# 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
|
||||
@@ -25,13 +29,12 @@ jobs:
|
||||
release-ionic:
|
||||
needs: [create-dev-hash]
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/release-ionic.yml
|
||||
with:
|
||||
tag: dev
|
||||
version: ${{ needs.create-dev-hash.outputs.dev-hash }}
|
||||
secrets:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
get-build:
|
||||
name: Get your dev build!
|
||||
|
||||
15
.github/workflows/nightly.yml
vendored
@@ -1,10 +1,11 @@
|
||||
name: 'Ionic Nightly Build'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every Monday-Friday
|
||||
# at 6:00 UTC (6:00 am UTC)
|
||||
- cron: '00 06 * * 1-5'
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
create-nightly-hash:
|
||||
@@ -12,7 +13,7 @@ jobs:
|
||||
outputs:
|
||||
nightly-hash: ${{ steps.create-nightly-hash.outputs.NIGHTLY_HASH }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
# 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,10 +31,10 @@ jobs:
|
||||
release-ionic:
|
||||
needs: [create-nightly-hash]
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/release-ionic.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
tag: nightly
|
||||
version: ${{ needs.create-nightly-hash.outputs.nightly-hash }}
|
||||
secrets:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
47
.github/workflows/release-ionic.yml
vendored
@@ -14,23 +14,23 @@ 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: ./.github/workflows/actions/publish-npm
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: ./.github/actions/publish-npm
|
||||
with:
|
||||
scope: '@ionic/core'
|
||||
tag: ${{ inputs.tag }}
|
||||
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,34 +48,33 @@ jobs:
|
||||
needs: [release-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Restore @ionic/docs built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-docs
|
||||
path: ./packages/docs
|
||||
filename: DocsBuild.zip
|
||||
- uses: ./.github/workflows/actions/publish-npm
|
||||
- uses: ./.github/actions/publish-npm
|
||||
with:
|
||||
scope: '@ionic/docs'
|
||||
tag: ${{ inputs.tag }}
|
||||
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Restore @ionic/core built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
path: ./core
|
||||
filename: CoreBuild.zip
|
||||
- uses: ./.github/workflows/actions/publish-npm
|
||||
- uses: ./.github/actions/publish-npm
|
||||
with:
|
||||
scope: '@ionic/angular'
|
||||
tag: ${{ inputs.tag }}
|
||||
@@ -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,21 +93,20 @@ jobs:
|
||||
needs: [release-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Restore @ionic/core built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
path: ./core
|
||||
filename: CoreBuild.zip
|
||||
- uses: ./.github/workflows/actions/publish-npm
|
||||
- uses: ./.github/actions/publish-npm
|
||||
with:
|
||||
scope: '@ionic/react'
|
||||
tag: ${{ inputs.tag }}
|
||||
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,21 +118,20 @@ jobs:
|
||||
needs: [release-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Restore @ionic/core built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
path: ./core
|
||||
filename: CoreBuild.zip
|
||||
- uses: ./.github/workflows/actions/publish-npm
|
||||
- uses: ./.github/actions/publish-npm
|
||||
with:
|
||||
scope: '@ionic/vue'
|
||||
tag: ${{ inputs.tag }}
|
||||
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,14 +143,14 @@ jobs:
|
||||
needs: [release-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Restore @ionic/core built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-core
|
||||
path: ./core
|
||||
filename: CoreBuild.zip
|
||||
- uses: ./.github/workflows/actions/publish-npm
|
||||
- uses: ./.github/actions/publish-npm
|
||||
with:
|
||||
scope: '@ionic/angular-server'
|
||||
tag: ${{ inputs.tag }}
|
||||
@@ -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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Restore @ionic/core built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
@@ -181,20 +176,19 @@ jobs:
|
||||
name: ionic-react
|
||||
path: ./packages/react
|
||||
filename: ReactBuild.zip
|
||||
- uses: ./.github/workflows/actions/publish-npm
|
||||
- uses: ./.github/actions/publish-npm
|
||||
with:
|
||||
scope: '@ionic/react-router'
|
||||
tag: ${{ inputs.tag }}
|
||||
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Restore @ionic/core built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
@@ -207,11 +201,10 @@ jobs:
|
||||
name: ionic-vue
|
||||
path: ./packages/vue
|
||||
filename: VueBuild.zip
|
||||
- uses: ./.github/workflows/actions/publish-npm
|
||||
- uses: ./.github/actions/publish-npm
|
||||
with:
|
||||
scope: '@ionic/vue-router'
|
||||
tag: ${{ inputs.tag }}
|
||||
version: ${{ inputs.version }}
|
||||
preid: ${{ inputs.preid }}
|
||||
working-directory: 'packages/vue-router'
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
81
.github/workflows/release-orchestrator.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: 'Release - Ionic Framework'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every Monday-Friday
|
||||
# at 6:00 UTC (6:00 am UTC)
|
||||
- cron: '00 06 * * 1-5'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-type:
|
||||
description: 'Which Ionic release workflow should run?'
|
||||
required: true
|
||||
type: choice
|
||||
default: dev
|
||||
options:
|
||||
- dev
|
||||
- production
|
||||
version:
|
||||
description: 'Which version should be published? (Only for production releases)'
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
- prepatch
|
||||
- preminor
|
||||
- premajor
|
||||
- prerelease
|
||||
tag:
|
||||
description: 'Which npm tag should this be published to? (Only for production releases)'
|
||||
required: false
|
||||
type: choice
|
||||
default: latest
|
||||
options:
|
||||
- latest
|
||||
- next
|
||||
preid:
|
||||
description: 'Which prerelease identifier should be used? (Only for production releases)'
|
||||
required: false
|
||||
type: choice
|
||||
default: ''
|
||||
options:
|
||||
- ''
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
- next
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
run-nightly:
|
||||
if: ${{ github.event_name == 'schedule' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/nightly.yml
|
||||
secrets: inherit
|
||||
|
||||
run-dev:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.release-type == 'dev' }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/dev-build.yml
|
||||
secrets: inherit
|
||||
|
||||
run-production:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.release-type == 'production' }}
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/release.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
tag: ${{ inputs.tag }}
|
||||
preid: ${{ inputs.preid }}
|
||||
69
.github/workflows/release.yml
vendored
@@ -1,54 +1,64 @@
|
||||
name: 'Ionic Production Release'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Which version should be published?'
|
||||
required: true
|
||||
type: choice
|
||||
description: Which version should be published?
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
- prepatch
|
||||
- preminor
|
||||
- premajor
|
||||
- prerelease
|
||||
type: string
|
||||
tag:
|
||||
description: 'Which npm tag should this be published to?'
|
||||
required: true
|
||||
type: choice
|
||||
description: Which npm tag should this be published to?
|
||||
options:
|
||||
- latest
|
||||
- next
|
||||
type: string
|
||||
preid:
|
||||
type: choice
|
||||
description: Which prerelease identifier should be used? This is only needed when version is "prepatch", "preminor", "premajor", or "prerelease".
|
||||
options:
|
||||
- ''
|
||||
- alpha
|
||||
- beta
|
||||
- rc
|
||||
- next
|
||||
description: 'Which prerelease identifier should be used? This is only needed when version is "prepatch", "preminor", "premajor", or "prerelease".'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
validate_version:
|
||||
name: ✅ Validate Version Input
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🔎 Ensure version is allowed
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
case "$VERSION" in
|
||||
patch|minor|major|prepatch|preminor|premajor|prerelease)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "::error::Invalid version input: '$VERSION'. Allowed values: patch, minor, major, prepatch, preminor, premajor, prerelease."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shell: bash
|
||||
|
||||
release-ionic:
|
||||
needs: [validate_version]
|
||||
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
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.IONITRON_TOKEN }}
|
||||
fetch-depth: 0
|
||||
@@ -75,8 +85,11 @@ jobs:
|
||||
# possible for them to push at the same time.
|
||||
needs: [finalize-release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
# Pull the latest version of the reference
|
||||
# branch instead of the revision that triggered
|
||||
# the workflow otherwise we won't get the commit
|
||||
|
||||
62
.github/workflows/stencil-nightly.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
build-core-with-stencil-nightly:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: ./.github/workflows/actions/build-vue
|
||||
|
||||
build-vue-router:
|
||||
needs: [build-vue]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: ./.github/workflows/actions/build-react
|
||||
|
||||
build-react-router:
|
||||
needs: [build-react]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: ./.github/workflows/actions/test-react-e2e
|
||||
with:
|
||||
app: ${{ matrix.apps }}
|
||||
@@ -225,3 +225,35 @@ jobs:
|
||||
- name: Check build matrix status
|
||||
if: ${{ needs.test-react-e2e.result != 'success' }}
|
||||
run: exit 1
|
||||
|
||||
send-success-messages:
|
||||
needs: [test-core-clean-build, test-core-lint, test-core-spec, verify-screenshots, verify-test-vue-e2e, verify-test-angular-e2e, verify-test-react-router-e2e, verify-test-react-e2e]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') }}
|
||||
steps:
|
||||
- name: Notify success on Discord
|
||||
run: |
|
||||
curl -H "Content-Type:application/json" \
|
||||
-d '{"embeds": [{"title": "✅ Workflow ${{github.workflow}} #${{github.run_number}} finished successfully", "color": 65280, "url": "${{github.server_url}}/${{github.repository}}/actions/runs/${{github.run_id}}"}]}' \
|
||||
${{secrets.DISCORD_NOTIFY_WEBHOOK}}
|
||||
- name: Notify success on Slack
|
||||
run: |
|
||||
curl -H "Content-Type:application/json" \
|
||||
-d '{"title": "✅ Workflow ${{github.workflow}} #${{github.run_number}} finished successfully", "url": "${{github.server_url}}/${{github.repository}}/actions/runs/${{github.run_id}}"}' \
|
||||
${{secrets.SLACK_NOTIFY_SUCCESS_WEBHOOK}}
|
||||
|
||||
send-failure-messages:
|
||||
needs: [test-core-clean-build, test-core-lint, test-core-spec, verify-screenshots, verify-test-vue-e2e, verify-test-angular-e2e, verify-test-react-router-e2e, verify-test-react-e2e]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && contains(needs.*.result, 'failure') }}
|
||||
steps:
|
||||
- name: Notify failure on Discord
|
||||
run: |
|
||||
curl -H "Content-Type:application/json" \
|
||||
-d '{"content": "Alerting <@&1347593178580254761>!", "embeds": [{"title": "❌ Workflow ${{github.workflow}} #${{github.run_number}} failed", "color": 16711680, "url": "${{github.server_url}}/${{github.repository}}/actions/runs/${{github.run_id}}"}]}' \
|
||||
${{secrets.DISCORD_NOTIFY_WEBHOOK}}
|
||||
- name: Notify failure on Slack
|
||||
run: |
|
||||
curl -H "Content-Type:application/json" \
|
||||
-d '{"title": "❌ Workflow ${{github.workflow}} #${{github.run_number}} failed", "url": "${{github.server_url}}/${{github.repository}}/actions/runs/${{github.run_id}}"}' \
|
||||
${{secrets.SLACK_NOTIFY_FAILURE_WEBHOOK}}
|
||||
|
||||
6
.github/workflows/update-screenshots.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
build-core:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
# Normally, we could just push with the
|
||||
# default GITHUB_TOKEN, but that will
|
||||
# not cause the build workflow
|
||||
|
||||
58
CHANGELOG.md
@@ -3,6 +3,64 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.7.12](https://github.com/ionic-team/ionic-framework/compare/v8.7.11...v8.7.12) (2025-12-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **modal:** allow interaction with parent content through sheet modals in child routes ([#30839](https://github.com/ionic-team/ionic-framework/issues/30839)) ([b9e3cf0](https://github.com/ionic-team/ionic-framework/commit/b9e3cf0f5aae79a1f27a07b102c77e51f24825f4)), closes [#30700](https://github.com/ionic-team/ionic-framework/issues/30700)
|
||||
* **modal:** prevent browser hang when using ModalController in Angular ([#30845](https://github.com/ionic-team/ionic-framework/issues/30845)) ([b164516](https://github.com/ionic-team/ionic-framework/commit/b1645168a7fb9378dc39a081c207b2de0e180089))
|
||||
* **popover:** recalculate the content dimensions after the header has fully loaded ([#30853](https://github.com/ionic-team/ionic-framework/issues/30853)) ([99dcf38](https://github.com/ionic-team/ionic-framework/commit/99dcf3810a0c32416996d1e992ddf63359965cfc))
|
||||
* **select, action-sheet:** use radio role for options ([#30769](https://github.com/ionic-team/ionic-framework/issues/30769)) ([1c89cf0](https://github.com/ionic-team/ionic-framework/commit/1c89cf06ac959f9c9a35a66f811227c244d3198b))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.11](https://github.com/ionic-team/ionic-framework/compare/v8.7.10...v8.7.11) (2025-11-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **datetime:** ensure datetime is shown when intersection observer fails to report visibility ([#30793](https://github.com/ionic-team/ionic-framework/issues/30793)) ([9d781db](https://github.com/ionic-team/ionic-framework/commit/9d781db662d213090d0b7198d0cdc5abb16fed1b)), closes [#30706](https://github.com/ionic-team/ionic-framework/issues/30706)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.10](https://github.com/ionic-team/ionic-framework/compare/v8.7.9...v8.7.10) (2025-11-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **checkbox, toggle, radio-group:** improve screen reader announcement timing for validation errors ([#30714](https://github.com/ionic-team/ionic-framework/issues/30714)) ([92db364](https://github.com/ionic-team/ionic-framework/commit/92db36489cca944caf1593dbd518a1f025a171a2))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.9](https://github.com/ionic-team/ionic-framework/compare/v8.7.8...v8.7.9) (2025-11-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **accordion-group:** skip initial animation ([#30729](https://github.com/ionic-team/ionic-framework/issues/30729)) ([58d5638](https://github.com/ionic-team/ionic-framework/commit/58d563805fca1db88caeeb40a8f710ac30416d93)), closes [#30613](https://github.com/ionic-team/ionic-framework/issues/30613)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.8](https://github.com/ionic-team/ionic-framework/compare/v8.7.7...v8.7.8) (2025-10-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **checkbox, toggle:** fire ionFocus and ionBlur ([#30733](https://github.com/ionic-team/ionic-framework/issues/30733)) ([54a1c86](https://github.com/ionic-team/ionic-framework/commit/54a1c86d6a5d533b0c8c2d18edc62454a7c17bab))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.7](https://github.com/ionic-team/ionic-framework/compare/v8.7.6...v8.7.7) (2025-10-15)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,64 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.7.12](https://github.com/ionic-team/ionic-framework/compare/v8.7.11...v8.7.12) (2025-12-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **modal:** allow interaction with parent content through sheet modals in child routes ([#30839](https://github.com/ionic-team/ionic-framework/issues/30839)) ([b9e3cf0](https://github.com/ionic-team/ionic-framework/commit/b9e3cf0f5aae79a1f27a07b102c77e51f24825f4)), closes [#30700](https://github.com/ionic-team/ionic-framework/issues/30700)
|
||||
* **modal:** prevent browser hang when using ModalController in Angular ([#30845](https://github.com/ionic-team/ionic-framework/issues/30845)) ([b164516](https://github.com/ionic-team/ionic-framework/commit/b1645168a7fb9378dc39a081c207b2de0e180089))
|
||||
* **popover:** recalculate the content dimensions after the header has fully loaded ([#30853](https://github.com/ionic-team/ionic-framework/issues/30853)) ([99dcf38](https://github.com/ionic-team/ionic-framework/commit/99dcf3810a0c32416996d1e992ddf63359965cfc))
|
||||
* **select, action-sheet:** use radio role for options ([#30769](https://github.com/ionic-team/ionic-framework/issues/30769)) ([1c89cf0](https://github.com/ionic-team/ionic-framework/commit/1c89cf06ac959f9c9a35a66f811227c244d3198b))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.11](https://github.com/ionic-team/ionic-framework/compare/v8.7.10...v8.7.11) (2025-11-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **datetime:** ensure datetime is shown when intersection observer fails to report visibility ([#30793](https://github.com/ionic-team/ionic-framework/issues/30793)) ([9d781db](https://github.com/ionic-team/ionic-framework/commit/9d781db662d213090d0b7198d0cdc5abb16fed1b)), closes [#30706](https://github.com/ionic-team/ionic-framework/issues/30706)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.10](https://github.com/ionic-team/ionic-framework/compare/v8.7.9...v8.7.10) (2025-11-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **checkbox, toggle, radio-group:** improve screen reader announcement timing for validation errors ([#30714](https://github.com/ionic-team/ionic-framework/issues/30714)) ([92db364](https://github.com/ionic-team/ionic-framework/commit/92db36489cca944caf1593dbd518a1f025a171a2))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.9](https://github.com/ionic-team/ionic-framework/compare/v8.7.8...v8.7.9) (2025-11-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **accordion-group:** skip initial animation ([#30729](https://github.com/ionic-team/ionic-framework/issues/30729)) ([58d5638](https://github.com/ionic-team/ionic-framework/commit/58d563805fca1db88caeeb40a8f710ac30416d93)), closes [#30613](https://github.com/ionic-team/ionic-framework/issues/30613)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.8](https://github.com/ionic-team/ionic-framework/compare/v8.7.7...v8.7.8) (2025-10-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **checkbox, toggle:** fire ionFocus and ionBlur ([#30733](https://github.com/ionic-team/ionic-framework/issues/30733)) ([54a1c86](https://github.com/ionic-team/ionic-framework/commit/54a1c86d6a5d533b0c8c2d18edc62454a7c17bab))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.7](https://github.com/ionic-team/ionic-framework/compare/v8.7.6...v8.7.7) (2025-10-15)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Get Playwright
|
||||
FROM mcr.microsoft.com/playwright:v1.56.0
|
||||
FROM mcr.microsoft.com/playwright:v1.56.1
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /ionic
|
||||
|
||||
10705
core/package-lock.json
generated
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"name": "@ionic/core",
|
||||
"version": "8.7.7",
|
||||
"version": "8.7.12",
|
||||
"description": "Base components for Ionic",
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
},
|
||||
"keywords": [
|
||||
"ionic",
|
||||
"framework",
|
||||
@@ -36,15 +39,15 @@
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.2",
|
||||
"@capacitor/core": "^7.0.0",
|
||||
"@capacitor/haptics": "^7.0.0",
|
||||
"@capacitor/keyboard": "^7.0.0",
|
||||
"@capacitor/status-bar": "^7.0.0",
|
||||
"@axe-core/playwright": "^4.11.0",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@capacitor/haptics": "^8.0.0",
|
||||
"@capacitor/keyboard": "^8.0.0",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@ionic/eslint-config": "^0.3.0",
|
||||
"@ionic/prettier-config": "^2.0.0",
|
||||
"@playwright/test": "^1.56.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@rollup/plugin-node-resolve": "^8.4.0",
|
||||
"@rollup/plugin-virtual": "^2.0.3",
|
||||
"@stencil/angular-output-target": "^0.10.0",
|
||||
@@ -52,7 +55,7 @@
|
||||
"@stencil/sass": "^3.0.9",
|
||||
"@stencil/vue-output-target": "0.10.8",
|
||||
"@types/jest": "^29.5.6",
|
||||
"@types/node": "^14.6.0",
|
||||
"@types/node": "^16.18.126",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.2",
|
||||
"@typescript-eslint/parser": "^6.7.2",
|
||||
"chalk": "^5.3.0",
|
||||
@@ -65,6 +68,7 @@
|
||||
"fs-extra": "^9.0.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-cli": "^29.7.0",
|
||||
"playwright-core": "^1.56.1",
|
||||
"prettier": "^2.6.1",
|
||||
"rollup": "^2.26.4",
|
||||
"sass": "^1.33.0",
|
||||
|
||||
@@ -1,30 +1,81 @@
|
||||
/**
|
||||
* This script is loaded in testing environments to set up the
|
||||
* document based on URL parameters.
|
||||
*
|
||||
* Test pages (e.g., `chip/test/basic/index.html`) are set to use
|
||||
* URL query parameters.
|
||||
*
|
||||
* Playwright test environments (e.g., `chip/test/basic/chip.e2e.ts`)
|
||||
* are set based on whether `setContent` or `goto` has been used:
|
||||
* - `setContent` uses URL hash parameters. Tests will break if
|
||||
* query parameters are used.
|
||||
* - `goto` uses URL query parameters.
|
||||
*
|
||||
* The following URL parameters are supported:
|
||||
* - `rtl`: Set to `true` to enable right-to-left directionality.
|
||||
* - `ionic:_testing`: Set to `true` to identify testing environments.
|
||||
* - `ionic:mode`: Set to `ios` or `md` to load a specific mode.
|
||||
* Defaults to `md`.
|
||||
* - `palette`: Set to `light`, `dark`, `high-contrast`, or
|
||||
* `high-contrast-dark` to load a specific palette. Defaults to `light`.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
|
||||
if (window.location.search.indexOf('rtl=true') > -1) {
|
||||
/**
|
||||
* The `rtl` param is used to set the directionality of the
|
||||
* document. This can be `true` or `false`.
|
||||
*/
|
||||
const isRTL = window.location.search.indexOf('rtl=true') > -1 || window.location.hash.indexOf('rtl=true') > -1;
|
||||
|
||||
if (isRTL) {
|
||||
document.documentElement.setAttribute('dir', 'rtl');
|
||||
}
|
||||
|
||||
if (window.location.search.indexOf('ionic:_testing=true') > -1) {
|
||||
/**
|
||||
* The `ionic:_testing` param is used to identify testing
|
||||
* environments.
|
||||
*/
|
||||
const isTestEnv = window.location.search.indexOf('ionic:_testing=true') > -1 || window.location.hash.indexOf('ionic:_testing=true') > -1;
|
||||
|
||||
if (isTestEnv) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `
|
||||
* {
|
||||
caret-color: transparent !important;
|
||||
}`;
|
||||
* {
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* The term `palette` is used to as a param to match the
|
||||
* Ionic docs, plus here is already a `ionic:theme` query being
|
||||
* used for `md`, `ios`, and `ionic` themes.
|
||||
* The `palette` param is used to load a specific palette
|
||||
* for the theme.
|
||||
* The dark class will load the dark palette automatically
|
||||
* if no palette is specified through the URL.
|
||||
*
|
||||
* Values can be `light`, `dark`, `high-contrast`,
|
||||
* or `high-contrast-dark`. Default to `light` for tests.
|
||||
*/
|
||||
const palette = window.location.search.match(/palette=([a-z]+)/);
|
||||
if (palette && palette[1] !== 'light') {
|
||||
const validPalettes = ['light', 'dark', 'high-contrast', 'high-contrast-dark'];
|
||||
const paletteQuery = window.location.search.match(/palette=([a-z-]+)/);
|
||||
const paletteHash = window.location.hash.match(/palette=([a-z-]+)/);
|
||||
const darkClass = document.body?.classList.contains('ion-palette-dark') ? 'dark' : null;
|
||||
const highContrastClass = document.body?.classList.contains('ion-palette-high-contrast') ? 'high-contrast' : null;
|
||||
const highContrastDarkClass = darkClass && highContrastClass ? 'high-contrast-dark' : null;
|
||||
|
||||
let paletteName = paletteQuery?.[1] || paletteHash?.[1] || highContrastDarkClass || darkClass || highContrastClass || 'light';
|
||||
|
||||
if (!validPalettes.includes(paletteName)) {
|
||||
console.warn(`Invalid palette name: '${paletteName}'. Falling back to 'light' palette.`);
|
||||
paletteName = 'light';
|
||||
}
|
||||
|
||||
if (paletteName !== 'light') {
|
||||
const linkTag = document.createElement('link');
|
||||
linkTag.setAttribute('rel', 'stylesheet');
|
||||
linkTag.setAttribute('type', 'text/css');
|
||||
linkTag.setAttribute('href', `/css/palettes/${palette[1]}.always.css`);
|
||||
linkTag.setAttribute('href', `/css/palettes/${paletteName}.always.css`);
|
||||
document.head.appendChild(linkTag);
|
||||
}
|
||||
|
||||
|
||||
4
core/src/components.d.ts
vendored
@@ -868,6 +868,10 @@ export namespace Components {
|
||||
* Get the element where the actual scrolling takes place. This element can be used to subscribe to `scroll` events or manually modify `scrollTop`. However, it's recommended to use the API provided by `ion-content`: i.e. Using `ionScroll`, `ionScrollStart`, `ionScrollEnd` for scrolling events and `scrollToPoint()` to scroll the content into a certain point.
|
||||
*/
|
||||
"getScrollElement": () => Promise<HTMLElement>;
|
||||
/**
|
||||
* Recalculate content dimensions. Called by overlays (e.g., popover) when sibling elements like headers or footers have finished rendering and their heights are available, ensuring accurate offset-top calculations.
|
||||
*/
|
||||
"recalculateDimensions": () => Promise<void>;
|
||||
/**
|
||||
* Scroll by a specified X/Y distance in the component.
|
||||
* @param x The amount to scroll by on the horizontal axis.
|
||||
|
||||
@@ -38,7 +38,40 @@ const enum AccordionState {
|
||||
})
|
||||
export class Accordion implements ComponentInterface {
|
||||
private accordionGroupEl?: HTMLIonAccordionGroupElement | null;
|
||||
private updateListener = () => this.updateState(false);
|
||||
private accordionGroupUpdateHandler = () => {
|
||||
/**
|
||||
* Determine if this update will cause an actual state change.
|
||||
* We only want to mark as "interacted" if the state is changing.
|
||||
*/
|
||||
const accordionGroup = this.accordionGroupEl;
|
||||
if (accordionGroup) {
|
||||
const value = accordionGroup.value;
|
||||
const accordionValue = this.value;
|
||||
const shouldExpand = Array.isArray(value) ? value.includes(accordionValue) : value === accordionValue;
|
||||
const isExpanded = this.state === AccordionState.Expanded || this.state === AccordionState.Expanding;
|
||||
const stateWillChange = shouldExpand !== isExpanded;
|
||||
|
||||
/**
|
||||
* Only mark as interacted if:
|
||||
* 1. This is not the first update we've received with a defined value
|
||||
* 2. The state is actually changing (prevents redundant updates from enabling animations)
|
||||
*/
|
||||
if (this.hasReceivedFirstUpdate && stateWillChange) {
|
||||
this.hasInteracted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only count this as the first update if the group value is defined.
|
||||
* This prevents the initial undefined value from the group's componentDidLoad
|
||||
* from being treated as the first real update.
|
||||
*/
|
||||
if (value !== undefined) {
|
||||
this.hasReceivedFirstUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateState();
|
||||
};
|
||||
private contentEl: HTMLDivElement | undefined;
|
||||
private contentElWrapper: HTMLDivElement | undefined;
|
||||
private headerEl: HTMLDivElement | undefined;
|
||||
@@ -50,6 +83,25 @@ export class Accordion implements ComponentInterface {
|
||||
@State() state: AccordionState = AccordionState.Collapsed;
|
||||
@State() isNext = false;
|
||||
@State() isPrevious = false;
|
||||
/**
|
||||
* Tracks whether a user-initiated interaction has occurred.
|
||||
* Animations are disabled until the first interaction happens.
|
||||
* This prevents the accordion from animating when it's programmatically
|
||||
* set to an expanded or collapsed state on initial load.
|
||||
*/
|
||||
@State() hasInteracted = false;
|
||||
|
||||
/**
|
||||
* Tracks if this accordion has ever been expanded.
|
||||
* Used to prevent the first expansion from animating.
|
||||
*/
|
||||
private hasEverBeenExpanded = false;
|
||||
|
||||
/**
|
||||
* Tracks if this accordion has received its first update from the group.
|
||||
* Used to distinguish initial programmatic sets from user interactions.
|
||||
*/
|
||||
private hasReceivedFirstUpdate = false;
|
||||
|
||||
/**
|
||||
* The value of the accordion. Defaults to an autogenerated
|
||||
@@ -88,15 +140,15 @@ export class Accordion implements ComponentInterface {
|
||||
connectedCallback() {
|
||||
const accordionGroupEl = (this.accordionGroupEl = this.el?.closest('ion-accordion-group'));
|
||||
if (accordionGroupEl) {
|
||||
this.updateState(true);
|
||||
addEventListener(accordionGroupEl, 'ionValueChange', this.updateListener);
|
||||
this.updateState();
|
||||
addEventListener(accordionGroupEl, 'ionValueChange', this.accordionGroupUpdateHandler);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
const accordionGroupEl = this.accordionGroupEl;
|
||||
if (accordionGroupEl) {
|
||||
removeEventListener(accordionGroupEl, 'ionValueChange', this.updateListener);
|
||||
removeEventListener(accordionGroupEl, 'ionValueChange', this.accordionGroupUpdateHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,10 +264,16 @@ export class Accordion implements ComponentInterface {
|
||||
ionItem.appendChild(iconEl);
|
||||
};
|
||||
|
||||
private expandAccordion = (initialUpdate = false) => {
|
||||
private expandAccordion = () => {
|
||||
const { contentEl, contentElWrapper } = this;
|
||||
if (initialUpdate || contentEl === undefined || contentElWrapper === undefined) {
|
||||
|
||||
/**
|
||||
* If the content elements aren't available yet, just set the state.
|
||||
* This happens on initial render before the DOM is ready.
|
||||
*/
|
||||
if (contentEl === undefined || contentElWrapper === undefined) {
|
||||
this.state = AccordionState.Expanded;
|
||||
this.hasEverBeenExpanded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -227,6 +285,12 @@ export class Accordion implements ComponentInterface {
|
||||
cancelAnimationFrame(this.currentRaf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that this accordion has been expanded at least once.
|
||||
* This allows subsequent expansions to animate.
|
||||
*/
|
||||
this.hasEverBeenExpanded = true;
|
||||
|
||||
if (this.shouldAnimate()) {
|
||||
raf(() => {
|
||||
this.state = AccordionState.Expanding;
|
||||
@@ -247,9 +311,14 @@ export class Accordion implements ComponentInterface {
|
||||
}
|
||||
};
|
||||
|
||||
private collapseAccordion = (initialUpdate = false) => {
|
||||
private collapseAccordion = () => {
|
||||
const { contentEl } = this;
|
||||
if (initialUpdate || contentEl === undefined) {
|
||||
|
||||
/**
|
||||
* If the content element isn't available yet, just set the state.
|
||||
* This happens on initial render before the DOM is ready.
|
||||
*/
|
||||
if (contentEl === undefined) {
|
||||
this.state = AccordionState.Collapsed;
|
||||
return;
|
||||
}
|
||||
@@ -291,6 +360,19 @@ export class Accordion implements ComponentInterface {
|
||||
* of what is set in the config.
|
||||
*/
|
||||
private shouldAnimate = () => {
|
||||
/**
|
||||
* Don't animate until after the first user interaction.
|
||||
* This prevents animations on initial load when accordions
|
||||
* start in an expanded or collapsed state programmatically.
|
||||
*
|
||||
* Additionally, don't animate the very first expansion even if
|
||||
* hasInteracted is true. This handles edge cases like React StrictMode
|
||||
* where effects run twice and might incorrectly mark as interacted.
|
||||
*/
|
||||
if (!this.hasInteracted || !this.hasEverBeenExpanded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof (window as any) === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
@@ -312,7 +394,7 @@ export class Accordion implements ComponentInterface {
|
||||
return true;
|
||||
};
|
||||
|
||||
private updateState = async (initialUpdate = false) => {
|
||||
private updateState = async () => {
|
||||
const accordionGroup = this.accordionGroupEl;
|
||||
const accordionValue = this.value;
|
||||
|
||||
@@ -325,10 +407,10 @@ export class Accordion implements ComponentInterface {
|
||||
const shouldExpand = Array.isArray(value) ? value.includes(accordionValue) : value === accordionValue;
|
||||
|
||||
if (shouldExpand) {
|
||||
this.expandAccordion(initialUpdate);
|
||||
this.expandAccordion();
|
||||
this.isNext = this.isPrevious = false;
|
||||
} else {
|
||||
this.collapseAccordion(initialUpdate);
|
||||
this.collapseAccordion();
|
||||
|
||||
/**
|
||||
* When using popout or inset,
|
||||
@@ -386,6 +468,12 @@ export class Accordion implements ComponentInterface {
|
||||
|
||||
if (disabled || readonly) return;
|
||||
|
||||
/**
|
||||
* Mark that the user has interacted with the accordion.
|
||||
* This enables animations for all future state changes.
|
||||
*/
|
||||
this.hasInteracted = true;
|
||||
|
||||
if (accordionGroupEl) {
|
||||
/**
|
||||
* Because the accordion group may or may
|
||||
|
||||
@@ -200,6 +200,87 @@ it('should set default values if not provided', async () => {
|
||||
expect(accordion.classList.contains('accordion-collapsed')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should not animate when initial value is set before load', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Item, Accordion, AccordionGroup],
|
||||
});
|
||||
|
||||
const accordionGroup = page.doc.createElement('ion-accordion-group');
|
||||
accordionGroup.innerHTML = `
|
||||
<ion-accordion value="first">
|
||||
<ion-item slot="header">Label</ion-item>
|
||||
<div slot="content">Content</div>
|
||||
</ion-accordion>
|
||||
<ion-accordion value="second">
|
||||
<ion-item slot="header">Label</ion-item>
|
||||
<div slot="content">Content</div>
|
||||
</ion-accordion>
|
||||
`;
|
||||
|
||||
accordionGroup.value = 'first';
|
||||
page.body.appendChild(accordionGroup);
|
||||
|
||||
await page.waitForChanges();
|
||||
|
||||
const firstAccordion = accordionGroup.querySelector('ion-accordion[value="first"]')!;
|
||||
|
||||
expect(firstAccordion.classList.contains('accordion-expanded')).toEqual(true);
|
||||
expect(firstAccordion.classList.contains('accordion-expanding')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should not animate when initial value is set after load', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Item, Accordion, AccordionGroup],
|
||||
});
|
||||
|
||||
const accordionGroup = page.doc.createElement('ion-accordion-group');
|
||||
accordionGroup.innerHTML = `
|
||||
<ion-accordion value="first">
|
||||
<ion-item slot="header">Label</ion-item>
|
||||
<div slot="content">Content</div>
|
||||
</ion-accordion>
|
||||
<ion-accordion value="second">
|
||||
<ion-item slot="header">Label</ion-item>
|
||||
<div slot="content">Content</div>
|
||||
</ion-accordion>
|
||||
`;
|
||||
|
||||
page.body.appendChild(accordionGroup);
|
||||
await page.waitForChanges();
|
||||
|
||||
accordionGroup.value = 'first';
|
||||
await page.waitForChanges();
|
||||
|
||||
const firstAccordion = accordionGroup.querySelector('ion-accordion[value="first"]')!;
|
||||
|
||||
expect(firstAccordion.classList.contains('accordion-expanded')).toEqual(true);
|
||||
expect(firstAccordion.classList.contains('accordion-expanding')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should not have animated class on first expansion', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Item, Accordion, AccordionGroup],
|
||||
html: `
|
||||
<ion-accordion-group>
|
||||
<ion-accordion value="first">
|
||||
<ion-item slot="header">Label</ion-item>
|
||||
<div slot="content">Content</div>
|
||||
</ion-accordion>
|
||||
</ion-accordion-group>
|
||||
`,
|
||||
});
|
||||
|
||||
const accordionGroup = page.body.querySelector('ion-accordion-group')!;
|
||||
const firstAccordion = page.body.querySelector('ion-accordion[value="first"]')!;
|
||||
|
||||
// First expansion should not have the animated class
|
||||
accordionGroup.value = 'first';
|
||||
await page.waitForChanges();
|
||||
|
||||
expect(firstAccordion.classList.contains('accordion-animated')).toEqual(false);
|
||||
expect(firstAccordion.classList.contains('accordion-expanded')).toEqual(true);
|
||||
});
|
||||
|
||||
// Verifies fix for https://github.com/ionic-team/ionic-framework/issues/27047
|
||||
it('should not have animated class when animated="false"', async () => {
|
||||
const page = await newSpecPage({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Watch, Component, Element, Event, Host, Method, Prop, h, readTask } from '@stencil/core';
|
||||
import { Watch, Component, Element, Event, Host, Listen, Method, Prop, State, h, readTask } from '@stencil/core';
|
||||
import type { Gesture } from '@utils/gesture';
|
||||
import { createButtonActiveGesture } from '@utils/gesture/button-active';
|
||||
import { raf } from '@utils/helpers';
|
||||
@@ -46,11 +46,18 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
||||
private wrapperEl?: HTMLElement;
|
||||
private groupEl?: HTMLElement;
|
||||
private gesture?: Gesture;
|
||||
private hasRadioButtons = false;
|
||||
|
||||
presented = false;
|
||||
lastFocus?: HTMLElement;
|
||||
animation?: any;
|
||||
|
||||
/**
|
||||
* The ID of the currently active/selected radio button.
|
||||
* Used for keyboard navigation and ARIA attributes.
|
||||
*/
|
||||
@State() activeRadioId?: string;
|
||||
|
||||
@Element() el!: HTMLIonActionSheetElement;
|
||||
|
||||
/** @internal */
|
||||
@@ -81,6 +88,22 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
||||
* An array of buttons for the action sheet.
|
||||
*/
|
||||
@Prop() buttons: (ActionSheetButton | string)[] = [];
|
||||
@Watch('buttons')
|
||||
buttonsChanged() {
|
||||
const radioButtons = this.getRadioButtons();
|
||||
this.hasRadioButtons = radioButtons.length > 0;
|
||||
|
||||
// Initialize activeRadioId when buttons change
|
||||
if (this.hasRadioButtons) {
|
||||
const checkedButton = radioButtons.find((b) => b.htmlAttributes?.['aria-checked'] === 'true');
|
||||
|
||||
if (checkedButton) {
|
||||
const allButtons = this.getButtons();
|
||||
const checkedIndex = allButtons.indexOf(checkedButton);
|
||||
this.activeRadioId = this.getButtonId(checkedButton, checkedIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional classes to apply for custom CSS. If multiple classes are
|
||||
@@ -277,12 +300,53 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all buttons regardless of role.
|
||||
*/
|
||||
private getButtons(): ActionSheetButton[] {
|
||||
return this.buttons.map((b) => {
|
||||
return typeof b === 'string' ? { text: b } : b;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all radio buttons (buttons with role="radio").
|
||||
*/
|
||||
private getRadioButtons(): ActionSheetButton[] {
|
||||
return this.getButtons().filter((b) => {
|
||||
const role = b.htmlAttributes?.role;
|
||||
return role === 'radio' && !isCancel(role);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle radio button selection and update aria-checked state.
|
||||
*
|
||||
* @param button The radio button that was selected.
|
||||
*/
|
||||
private selectRadioButton(button: ActionSheetButton) {
|
||||
const buttonId = this.getButtonId(button);
|
||||
|
||||
// Set the active radio ID (this will trigger a re-render and update aria-checked)
|
||||
this.activeRadioId = buttonId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or generate an ID for a button.
|
||||
*
|
||||
* @param button The button for which to get the ID.
|
||||
* @param index Optional index of the button in the buttons array.
|
||||
* @returns The ID of the button.
|
||||
*/
|
||||
private getButtonId(button: ActionSheetButton, index?: number): string {
|
||||
if (button.id) {
|
||||
return button.id;
|
||||
}
|
||||
const allButtons = this.getButtons();
|
||||
const buttonIndex = index !== undefined ? index : allButtons.indexOf(button);
|
||||
return `action-sheet-button-${this.overlayIndex}-${buttonIndex}`;
|
||||
}
|
||||
|
||||
private onBackdropTap = () => {
|
||||
this.dismiss(undefined, BACKDROP);
|
||||
};
|
||||
@@ -295,6 +359,96 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When the action sheet has radio buttons, we want to follow the
|
||||
* keyboard navigation pattern for radio groups:
|
||||
* - Arrow Down/Right: Move to the next radio button (wrap to first if at end)
|
||||
* - Arrow Up/Left: Move to the previous radio button (wrap to last if at start)
|
||||
* - Space/Enter: Select the focused radio button and trigger its handler
|
||||
*/
|
||||
@Listen('keydown')
|
||||
onKeydown(ev: KeyboardEvent) {
|
||||
// Only handle keyboard navigation if we have radio buttons
|
||||
if (!this.hasRadioButtons || !this.presented) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = ev.target as HTMLElement;
|
||||
|
||||
// Ignore if the target element is not within the action sheet or not a radio button
|
||||
if (
|
||||
!this.el.contains(target) ||
|
||||
!target.classList.contains('action-sheet-button') ||
|
||||
target.getAttribute('role') !== 'radio'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all radio button elements and filter out disabled ones
|
||||
const radios = Array.from(this.el.querySelectorAll('.action-sheet-button[role="radio"]')).filter(
|
||||
(el) => !(el as HTMLButtonElement).disabled
|
||||
) as HTMLButtonElement[];
|
||||
const currentIndex = radios.findIndex((radio) => radio.id === target.id);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allButtons = this.getButtons();
|
||||
const radioButtons = this.getRadioButtons();
|
||||
/**
|
||||
* Build a map of button element IDs to their ActionSheetButton
|
||||
* config objects.
|
||||
* This allows us to quickly look up which button config corresponds
|
||||
* to a DOM element when handling keyboard navigation
|
||||
* (e.g., whenuser presses Space/Enter or arrow keys).
|
||||
* The key is the ID that was set on the DOM element during render,
|
||||
* and the value is the ActionSheetButton config that contains the
|
||||
* handler and other properties.
|
||||
*/
|
||||
const buttonIdMap = new Map<string, ActionSheetButton>();
|
||||
|
||||
radioButtons.forEach((b) => {
|
||||
const allIndex = allButtons.indexOf(b);
|
||||
const buttonId = this.getButtonId(b, allIndex);
|
||||
buttonIdMap.set(buttonId, b);
|
||||
});
|
||||
|
||||
let nextEl: HTMLButtonElement | undefined;
|
||||
|
||||
if (['ArrowDown', 'ArrowRight'].includes(ev.key)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
nextEl = currentIndex === radios.length - 1 ? radios[0] : radios[currentIndex + 1];
|
||||
} else if (['ArrowUp', 'ArrowLeft'].includes(ev.key)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
nextEl = currentIndex === 0 ? radios[radios.length - 1] : radios[currentIndex - 1];
|
||||
} else if (ev.key === ' ' || ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const button = buttonIdMap.get(target.id);
|
||||
if (button) {
|
||||
this.selectRadioButton(button);
|
||||
this.buttonClick(button);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus the next radio button
|
||||
if (nextEl) {
|
||||
const button = buttonIdMap.get(nextEl.id);
|
||||
if (button) {
|
||||
this.selectRadioButton(button);
|
||||
nextEl.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
prepareOverlay(this.el);
|
||||
this.triggerChanged();
|
||||
@@ -312,6 +466,8 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
||||
if (!this.htmlAttributes?.id) {
|
||||
setOverlayId(this.el);
|
||||
}
|
||||
// Initialize activeRadioId for radio buttons
|
||||
this.buttonsChanged();
|
||||
}
|
||||
|
||||
componentDidLoad() {
|
||||
@@ -355,8 +511,82 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
||||
this.triggerChanged();
|
||||
}
|
||||
|
||||
private renderActionSheetButtons(filteredButtons: ActionSheetButton[]) {
|
||||
const mode = getIonMode(this);
|
||||
const { activeRadioId } = this;
|
||||
|
||||
return filteredButtons.map((b, index) => {
|
||||
const isRadio = b.htmlAttributes?.role === 'radio';
|
||||
const buttonId = this.getButtonId(b, index);
|
||||
const radioButtons = this.getRadioButtons();
|
||||
const isActiveRadio = isRadio && buttonId === activeRadioId;
|
||||
const isFirstRadio = isRadio && b === radioButtons[0];
|
||||
|
||||
// For radio buttons, set tabindex: 0 for the active one, -1 for others
|
||||
// For non-radio buttons, use default tabindex (undefined, which means 0)
|
||||
|
||||
/**
|
||||
* For radio buttons, set tabindex based on activeRadioId
|
||||
* - If the button is the active radio, tabindex is 0
|
||||
* - If no radio is active, the first radio button should have tabindex 0
|
||||
* - All other radio buttons have tabindex -1
|
||||
* For non-radio buttons, use default tabindex (undefined, which means 0)
|
||||
*/
|
||||
let tabIndex: number | undefined;
|
||||
|
||||
if (isRadio) {
|
||||
// Focus on the active radio button
|
||||
if (isActiveRadio) {
|
||||
tabIndex = 0;
|
||||
} else if (!activeRadioId && isFirstRadio) {
|
||||
// No active radio, first radio gets focus
|
||||
tabIndex = 0;
|
||||
} else {
|
||||
// All other radios are not focusable
|
||||
tabIndex = -1;
|
||||
}
|
||||
} else {
|
||||
tabIndex = undefined;
|
||||
}
|
||||
|
||||
// For radio buttons, set aria-checked based on activeRadioId
|
||||
// Otherwise, use the value from htmlAttributes if provided
|
||||
const htmlAttrs = { ...b.htmlAttributes };
|
||||
if (isRadio) {
|
||||
htmlAttrs['aria-checked'] = isActiveRadio ? 'true' : 'false';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
{...htmlAttrs}
|
||||
role={isRadio ? 'radio' : undefined}
|
||||
type="button"
|
||||
id={buttonId}
|
||||
class={{
|
||||
...buttonClass(b),
|
||||
'action-sheet-selected': isActiveRadio,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isRadio) {
|
||||
this.selectRadioButton(b);
|
||||
}
|
||||
this.buttonClick(b);
|
||||
}}
|
||||
disabled={b.disabled}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
<span class="action-sheet-button-inner">
|
||||
{b.icon && <ion-icon icon={b.icon} aria-hidden="true" lazy={false} class="action-sheet-icon" />}
|
||||
{b.text}
|
||||
</span>
|
||||
{mode === 'md' && <ion-ripple-effect></ion-ripple-effect>}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { header, htmlAttributes, overlayIndex } = this;
|
||||
const { header, htmlAttributes, overlayIndex, hasRadioButtons } = this;
|
||||
const mode = getIonMode(this);
|
||||
const allButtons = this.getButtons();
|
||||
const cancelButton = allButtons.find((b) => b.role === 'cancel');
|
||||
@@ -388,7 +618,11 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
||||
|
||||
<div class="action-sheet-wrapper ion-overlay-wrapper" ref={(el) => (this.wrapperEl = el)}>
|
||||
<div class="action-sheet-container">
|
||||
<div class="action-sheet-group" ref={(el) => (this.groupEl = el)}>
|
||||
<div
|
||||
class="action-sheet-group"
|
||||
ref={(el) => (this.groupEl = el)}
|
||||
role={hasRadioButtons ? 'radiogroup' : undefined}
|
||||
>
|
||||
{header !== undefined && (
|
||||
<div
|
||||
id={headerID}
|
||||
@@ -401,22 +635,7 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
||||
{this.subHeader && <div class="action-sheet-sub-title">{this.subHeader}</div>}
|
||||
</div>
|
||||
)}
|
||||
{buttons.map((b) => (
|
||||
<button
|
||||
{...b.htmlAttributes}
|
||||
type="button"
|
||||
id={b.id}
|
||||
class={buttonClass(b)}
|
||||
onClick={() => this.buttonClick(b)}
|
||||
disabled={b.disabled}
|
||||
>
|
||||
<span class="action-sheet-button-inner">
|
||||
{b.icon && <ion-icon icon={b.icon} aria-hidden="true" lazy={false} class="action-sheet-icon" />}
|
||||
{b.text}
|
||||
</span>
|
||||
{mode === 'md' && <ion-ripple-effect></ion-ripple-effect>}
|
||||
</button>
|
||||
))}
|
||||
{this.renderActionSheetButtons(buttons)}
|
||||
</div>
|
||||
|
||||
{cancelButton && (
|
||||
|
||||
@@ -134,3 +134,58 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* This behavior does not vary across modes/directions.
|
||||
*/
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('action-sheet: radio buttons'), () => {
|
||||
test('should render action sheet with radio buttons correctly', async ({ page }) => {
|
||||
await page.goto(`/src/components/action-sheet/test/a11y`, config);
|
||||
|
||||
const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');
|
||||
const button = page.locator('#radioButtons');
|
||||
|
||||
await button.click();
|
||||
await ionActionSheetDidPresent.next();
|
||||
|
||||
const actionSheet = page.locator('ion-action-sheet');
|
||||
|
||||
const radioButtons = actionSheet.locator('.action-sheet-button[role="radio"]');
|
||||
await expect(radioButtons).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('should navigate radio buttons with keyboard', async ({ page, pageUtils }) => {
|
||||
await page.goto(`/src/components/action-sheet/test/a11y`, config);
|
||||
|
||||
const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');
|
||||
const button = page.locator('#radioButtons');
|
||||
|
||||
await button.click();
|
||||
await ionActionSheetDidPresent.next();
|
||||
|
||||
// Focus on the radios
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
// Verify the first focusable radio button is focused
|
||||
let focusedElement = await page.evaluate(() => document.activeElement?.textContent?.trim());
|
||||
expect(focusedElement).toBe('Option 2');
|
||||
|
||||
// Navigate to the next radio button
|
||||
await page.keyboard.press('ArrowDown');
|
||||
|
||||
// Verify the first radio button is focused again (wrap around)
|
||||
focusedElement = await page.evaluate(() => document.activeElement?.textContent?.trim());
|
||||
expect(focusedElement).toBe('Option 1');
|
||||
|
||||
// Navigate to the next radio button
|
||||
await page.keyboard.press('ArrowDown');
|
||||
|
||||
// Navigate to the cancel button
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
focusedElement = await page.evaluate(() => document.activeElement?.textContent?.trim());
|
||||
expect(focusedElement).toBe('Cancel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<button class="expand" id="ariaLabelCancelButton" onclick="presentAriaLabelCancelButton()">
|
||||
Aria Label Cancel Button
|
||||
</button>
|
||||
<button class="expand" id="radioButtons" onclick="presentRadioButtons()">Radio Buttons</button>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
@@ -100,6 +101,32 @@
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function presentRadioButtons() {
|
||||
openActionSheet({
|
||||
header: 'Select an option',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Option 1',
|
||||
htmlAttributes: {
|
||||
role: 'radio',
|
||||
'aria-checked': 'false',
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Option 2',
|
||||
htmlAttributes: {
|
||||
role: 'radio',
|
||||
'aria-checked': 'true',
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Method, Prop, h } from '@stencil/core';
|
||||
import { Build, Component, Element, Event, Host, Method, Prop, State, h } from '@stencil/core';
|
||||
import { checkInvalidState } from '@utils/forms';
|
||||
import type { Attributes } from '@utils/helpers';
|
||||
import { inheritAriaAttributes, renderHiddenInput } from '@utils/helpers';
|
||||
import { createColorClasses, hostContext } from '@utils/theme';
|
||||
@@ -34,8 +35,8 @@ export class Checkbox implements ComponentInterface {
|
||||
private inputLabelId = `${this.inputId}-lbl`;
|
||||
private helperTextId = `${this.inputId}-helper-text`;
|
||||
private errorTextId = `${this.inputId}-error-text`;
|
||||
private focusEl?: HTMLElement;
|
||||
private inheritedAttributes: Attributes = {};
|
||||
private validationObserver?: MutationObserver;
|
||||
|
||||
@Element() el!: HTMLIonCheckboxElement;
|
||||
|
||||
@@ -121,6 +122,13 @@ export class Checkbox implements ComponentInterface {
|
||||
*/
|
||||
@Prop() required = false;
|
||||
|
||||
/**
|
||||
* Track validation state for proper aria-live announcements.
|
||||
*/
|
||||
@State() isInvalid = false;
|
||||
|
||||
@State() private hintTextId?: string;
|
||||
|
||||
/**
|
||||
* Emitted when the checked property has changed as a result of a user action such as a click.
|
||||
*
|
||||
@@ -138,18 +146,69 @@ export class Checkbox implements ComponentInterface {
|
||||
*/
|
||||
@Event() ionBlur!: EventEmitter<void>;
|
||||
|
||||
connectedCallback() {
|
||||
const { el } = this;
|
||||
|
||||
// Watch for class changes to update validation state.
|
||||
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
|
||||
this.validationObserver = new MutationObserver(() => {
|
||||
const newIsInvalid = checkInvalidState(el);
|
||||
if (this.isInvalid !== newIsInvalid) {
|
||||
this.isInvalid = newIsInvalid;
|
||||
/**
|
||||
* Screen readers tend to announce changes
|
||||
* to `aria-describedby` when the attribute
|
||||
* is changed during a blur event for a
|
||||
* native form control.
|
||||
* However, the announcement can be spotty
|
||||
* when using a non-native form control
|
||||
* and `forceUpdate()`.
|
||||
* This is due to `forceUpdate()` internally
|
||||
* rescheduling the DOM update to a lower
|
||||
* priority queue regardless if it's called
|
||||
* inside a Promise or not, thus causing
|
||||
* the screen reader to potentially miss the
|
||||
* change.
|
||||
* By using a State variable inside a Promise,
|
||||
* it guarantees a re-render immediately at
|
||||
* a higher priority.
|
||||
*/
|
||||
Promise.resolve().then(() => {
|
||||
this.hintTextId = this.getHintTextId();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.validationObserver.observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
}
|
||||
|
||||
// Always set initial state
|
||||
this.isInvalid = checkInvalidState(el);
|
||||
}
|
||||
|
||||
componentWillLoad() {
|
||||
this.inheritedAttributes = {
|
||||
...inheritAriaAttributes(this.el),
|
||||
};
|
||||
|
||||
this.hintTextId = this.getHintTextId();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// Clean up validation observer to prevent memory leaks.
|
||||
if (this.validationObserver) {
|
||||
this.validationObserver.disconnect();
|
||||
this.validationObserver = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@Method()
|
||||
async setFocus() {
|
||||
if (this.focusEl) {
|
||||
this.focusEl.focus();
|
||||
}
|
||||
this.el.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,7 +228,6 @@ export class Checkbox implements ComponentInterface {
|
||||
private toggleChecked = (ev: Event) => {
|
||||
ev.preventDefault();
|
||||
|
||||
this.setFocus();
|
||||
this.setChecked(!this.checked);
|
||||
this.indeterminate = false;
|
||||
};
|
||||
@@ -207,10 +265,10 @@ export class Checkbox implements ComponentInterface {
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
private getHintTextID(): string | undefined {
|
||||
const { el, helperText, errorText, helperTextId, errorTextId } = this;
|
||||
private getHintTextId(): string | undefined {
|
||||
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
|
||||
|
||||
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
|
||||
if (isInvalid && errorText) {
|
||||
return errorTextId;
|
||||
}
|
||||
|
||||
@@ -226,7 +284,7 @@ export class Checkbox implements ComponentInterface {
|
||||
* This element should only be rendered if hint text is set.
|
||||
*/
|
||||
private renderHintText() {
|
||||
const { helperText, errorText, helperTextId, errorTextId } = this;
|
||||
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
|
||||
|
||||
/**
|
||||
* undefined and empty string values should
|
||||
@@ -239,11 +297,11 @@ export class Checkbox implements ComponentInterface {
|
||||
|
||||
return (
|
||||
<div class="checkbox-bottom">
|
||||
<div id={helperTextId} class="helper-text" part="supporting-text helper-text">
|
||||
{helperText}
|
||||
<div id={helperTextId} class="helper-text" part="supporting-text helper-text" aria-live="polite">
|
||||
{!isInvalid ? helperText : null}
|
||||
</div>
|
||||
<div id={errorTextId} class="error-text" part="supporting-text error-text">
|
||||
{errorText}
|
||||
<div id={errorTextId} class="error-text" part="supporting-text error-text" role="alert">
|
||||
{isInvalid ? errorText : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -278,13 +336,17 @@ export class Checkbox implements ComponentInterface {
|
||||
<Host
|
||||
role="checkbox"
|
||||
aria-checked={indeterminate ? 'mixed' : `${checked}`}
|
||||
aria-describedby={this.getHintTextID()}
|
||||
aria-invalid={this.getHintTextID() === this.errorTextId}
|
||||
aria-describedby={this.hintTextId}
|
||||
aria-invalid={this.isInvalid ? 'true' : undefined}
|
||||
aria-labelledby={hasLabelContent ? this.inputLabelId : null}
|
||||
aria-label={inheritedAttributes['aria-label'] || null}
|
||||
aria-disabled={disabled ? 'true' : null}
|
||||
aria-required={required ? 'true' : undefined}
|
||||
tabindex={disabled ? undefined : 0}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onClick={this.onClick}
|
||||
class={createColorClasses(color, {
|
||||
[mode]: true,
|
||||
'in-item': hostContext('ion-item', el),
|
||||
@@ -296,7 +358,6 @@ export class Checkbox implements ComponentInterface {
|
||||
[`checkbox-alignment-${alignment}`]: alignment !== undefined,
|
||||
[`checkbox-label-placement-${labelPlacement}`]: true,
|
||||
})}
|
||||
onClick={this.onClick}
|
||||
>
|
||||
<label class="checkbox-wrapper" htmlFor={inputId}>
|
||||
{/*
|
||||
@@ -309,9 +370,6 @@ export class Checkbox implements ComponentInterface {
|
||||
disabled={disabled}
|
||||
id={inputId}
|
||||
onChange={this.toggleChecked}
|
||||
onFocus={() => this.onFocus()}
|
||||
onBlur={() => this.onBlur()}
|
||||
ref={(focusEl) => (this.focusEl = focusEl)}
|
||||
required={required}
|
||||
{...inheritedAttributes}
|
||||
/>
|
||||
|
||||
@@ -44,7 +44,10 @@ configs().forEach(({ title, screenshot, config }) => {
|
||||
});
|
||||
});
|
||||
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
/**
|
||||
* This behavior does not vary across modes/directions
|
||||
*/
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('checkbox: ionChange'), () => {
|
||||
test('should fire ionChange when interacting with checkbox', async ({ page }) => {
|
||||
await page.setContent(
|
||||
@@ -133,4 +136,195 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
expect(clickCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('checkbox: ionFocus'), () => {
|
||||
test('should not have visual regressions', async ({ page, pageUtils }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
#container {
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="container">
|
||||
<ion-checkbox>Unchecked</ion-checkbox>
|
||||
</div>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
const container = page.locator('#container');
|
||||
|
||||
await expect(container).toHaveScreenshot(screenshot(`checkbox-focus`));
|
||||
});
|
||||
|
||||
test('should not have visual regressions when interacting with checkbox in item', async ({ page, pageUtils }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-item class="ion-focused">
|
||||
<ion-checkbox>Unchecked</ion-checkbox>
|
||||
</ion-item>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
// Test focus with keyboard navigation
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
const item = page.locator('ion-item');
|
||||
|
||||
await expect(item).toHaveScreenshot(screenshot(`checkbox-in-item-focus`));
|
||||
});
|
||||
|
||||
test('should fire ionFocus when checkbox is focused', async ({ page, pageUtils }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionFocus = await page.spyOnEvent('ionFocus');
|
||||
|
||||
// Test focus with keyboard navigation
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
expect(ionFocus).toHaveReceivedEventTimes(1);
|
||||
|
||||
// Reset focus
|
||||
const checkbox = page.locator('ion-checkbox');
|
||||
const checkboxBoundingBox = (await checkbox.boundingBox())!;
|
||||
await page.mouse.click(0, checkboxBoundingBox.height + 1);
|
||||
|
||||
// Test focus with click
|
||||
await checkbox.click();
|
||||
|
||||
expect(ionFocus).toHaveReceivedEventTimes(2);
|
||||
});
|
||||
|
||||
test('should fire ionFocus when interacting with checkbox in item', async ({ page, pageUtils }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-item>
|
||||
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
|
||||
</ion-item>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionFocus = await page.spyOnEvent('ionFocus');
|
||||
|
||||
// Test focus with keyboard navigation
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
expect(ionFocus).toHaveReceivedEventTimes(1);
|
||||
|
||||
// Verify that the event target is the checkbox and not the item
|
||||
const eventByKeyboard = ionFocus.events[0];
|
||||
expect((eventByKeyboard.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
|
||||
|
||||
// Reset focus
|
||||
const checkbox = page.locator('ion-checkbox');
|
||||
const checkboxBoundingBox = (await checkbox.boundingBox())!;
|
||||
await page.mouse.click(0, checkboxBoundingBox.height + 1);
|
||||
|
||||
// Test focus with click
|
||||
const item = page.locator('ion-item');
|
||||
await item.click();
|
||||
|
||||
expect(ionFocus).toHaveReceivedEventTimes(2);
|
||||
|
||||
// Verify that the event target is the checkbox and not the item
|
||||
const eventByClick = ionFocus.events[0];
|
||||
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
|
||||
});
|
||||
|
||||
test('should not fire when programmatically setting a value', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionFocus = await page.spyOnEvent('ionFocus');
|
||||
const checkbox = page.locator('ion-checkbox');
|
||||
|
||||
await checkbox.evaluate((el: HTMLIonCheckboxElement) => (el.checked = true));
|
||||
expect(ionFocus).not.toHaveReceivedEvent();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('checkbox: ionBlur'), () => {
|
||||
test('should fire ionBlur when checkbox is blurred', async ({ page, pageUtils }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionBlur = await page.spyOnEvent('ionBlur');
|
||||
|
||||
// Test blur with keyboard navigation
|
||||
// Focus the checkbox
|
||||
await pageUtils.pressKeys('Tab');
|
||||
// Blur the checkbox
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
expect(ionBlur).toHaveReceivedEventTimes(1);
|
||||
|
||||
// Test blur with click
|
||||
const checkbox = page.locator('ion-checkbox');
|
||||
// Focus the checkbox
|
||||
await checkbox.click();
|
||||
// Blur the checkbox by clicking outside of it
|
||||
const checkboxBoundingBox = (await checkbox.boundingBox())!;
|
||||
await page.mouse.click(0, checkboxBoundingBox.height + 1);
|
||||
|
||||
expect(ionBlur).toHaveReceivedEventTimes(2);
|
||||
});
|
||||
|
||||
test('should fire ionBlur after interacting with checkbox in item', async ({ page, pageUtils }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-item>
|
||||
<ion-checkbox aria-label="checkbox" value="my-checkbox"></ion-checkbox>
|
||||
</ion-item>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionBlur = await page.spyOnEvent('ionBlur');
|
||||
|
||||
// Test blur with keyboard navigation
|
||||
// Focus the checkbox
|
||||
await pageUtils.pressKeys('Tab');
|
||||
// Blur the checkbox
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
expect(ionBlur).toHaveReceivedEventTimes(1);
|
||||
|
||||
// Verify that the event target is the checkbox and not the item
|
||||
const event = ionBlur.events[0];
|
||||
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
|
||||
|
||||
// Test blur with click
|
||||
const item = page.locator('ion-item');
|
||||
await item.click();
|
||||
// Blur the checkbox by clicking outside of it
|
||||
const itemBoundingBox = (await item.boundingBox())!;
|
||||
await page.mouse.click(0, itemBoundingBox.height + 1);
|
||||
|
||||
expect(ionBlur).toHaveReceivedEventTimes(2);
|
||||
|
||||
// Verify that the event target is the checkbox and not the item
|
||||
const eventByClick = ionBlur.events[0];
|
||||
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-checkbox');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -50,6 +50,20 @@
|
||||
<ion-checkbox checked style="width: 200px">Specified width</ion-checkbox><br />
|
||||
<ion-checkbox checked style="width: 100%">Full-width</ion-checkbox><br />
|
||||
</ion-content>
|
||||
|
||||
<script>
|
||||
document.addEventListener('ionBlur', (ev) => {
|
||||
console.log('ionBlur', ev);
|
||||
});
|
||||
|
||||
document.addEventListener('ionChange', (ev) => {
|
||||
console.log('ionChange', ev);
|
||||
});
|
||||
|
||||
document.addEventListener('ionFocus', (ev) => {
|
||||
console.log('ionFocus', ev);
|
||||
});
|
||||
</script>
|
||||
</ion-app>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -246,6 +246,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
<script>
|
||||
document.addEventListener('ionBlur', (ev) => {
|
||||
console.log('ionBlur', ev);
|
||||
});
|
||||
|
||||
document.addEventListener('ionChange', (ev) => {
|
||||
console.log('ionChange', ev);
|
||||
});
|
||||
|
||||
document.addEventListener('ionFocus', (ev) => {
|
||||
console.log('ionFocus', ev);
|
||||
});
|
||||
</script>
|
||||
</ion-app>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
184
core/src/components/checkbox/test/validation/index.html
Normal 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>
|
||||
@@ -254,6 +254,17 @@ export class Content implements ComponentInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate content dimensions. Called by overlays (e.g., popover) when
|
||||
* sibling elements like headers or footers have finished rendering and their
|
||||
* heights are available, ensuring accurate offset-top calculations.
|
||||
* @internal
|
||||
*/
|
||||
@Method()
|
||||
async recalculateDimensions(): Promise<void> {
|
||||
readTask(() => this.readDimensions());
|
||||
}
|
||||
|
||||
private readDimensions() {
|
||||
const page = getPageElement(this.el);
|
||||
const top = Math.max(this.el.offsetTop, 0);
|
||||
|
||||
@@ -21,6 +21,7 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, screenshot, c
|
||||
);
|
||||
|
||||
const datetimeButton = page.locator('ion-datetime-button');
|
||||
await page.locator('.datetime-ready').waitFor();
|
||||
|
||||
await expect(datetimeButton).toHaveScreenshot(screenshot(`datetime-button-scale`));
|
||||
});
|
||||
@@ -40,6 +41,7 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, screenshot, c
|
||||
);
|
||||
|
||||
const datetimeButton = page.locator('ion-datetime-button');
|
||||
await page.locator('.datetime-ready').waitFor();
|
||||
|
||||
await expect(datetimeButton).toHaveScreenshot(screenshot(`datetime-button-scale-wrap`));
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Locator } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
import { expect } from '@playwright/test';
|
||||
import type { EventSpy } from '@utils/test/playwright';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
/**
|
||||
* This behavior does not vary across directions.
|
||||
@@ -24,6 +24,9 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
await dateButton.click();
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
// Wait for datetime to be ready before taking screenshot
|
||||
await page.locator('ion-datetime.datetime-ready').waitFor();
|
||||
|
||||
await expect(page).toHaveScreenshot(screenshot(`datetime-overlay-modal`));
|
||||
});
|
||||
|
||||
@@ -44,6 +47,9 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
await dateButton.click();
|
||||
await ionPopoverDidPresent.next();
|
||||
|
||||
// Wait for datetime to be ready before taking screenshot
|
||||
await page.locator('ion-datetime.datetime-ready').waitFor();
|
||||
|
||||
await expect(page).toHaveScreenshot(screenshot(`datetime-overlay-popover`));
|
||||
});
|
||||
});
|
||||
@@ -176,5 +182,34 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
await ionModalDidPresent.next();
|
||||
await expect(datetime).toBeVisible();
|
||||
});
|
||||
test('should set datetime ready state and keep calendar interactive when reopening modal', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30706',
|
||||
});
|
||||
|
||||
const openAndInteract = async () => {
|
||||
await page.click('#date-button');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
await page.locator('ion-datetime.datetime-ready').waitFor();
|
||||
|
||||
const calendarBody = datetime.locator('.calendar-body');
|
||||
await expect(calendarBody).toBeVisible();
|
||||
};
|
||||
|
||||
await openAndInteract();
|
||||
|
||||
const firstEnabledDay = datetime.locator('.calendar-day:not([disabled])').first();
|
||||
await firstEnabledDay.click();
|
||||
await page.waitForChanges();
|
||||
|
||||
await modal.evaluate((el: HTMLIonModalElement) => el.dismiss());
|
||||
await ionModalDidDismiss.next();
|
||||
|
||||
await openAndInteract();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,8 +108,8 @@ export class Datetime implements ComponentInterface {
|
||||
private inputId = `ion-dt-${datetimeIds++}`;
|
||||
private calendarBodyRef?: HTMLElement;
|
||||
private popoverRef?: HTMLIonPopoverElement;
|
||||
private intersectionTrackerRef?: HTMLElement;
|
||||
private clearFocusVisible?: () => void;
|
||||
private resizeObserver?: ResizeObserver;
|
||||
private parsedMinuteValues?: number[];
|
||||
private parsedHourValues?: number[];
|
||||
private parsedMonthValues?: number[];
|
||||
@@ -118,6 +118,7 @@ export class Datetime implements ComponentInterface {
|
||||
|
||||
private destroyCalendarListener?: () => void;
|
||||
private destroyKeyboardMO?: () => void;
|
||||
private destroyOverlayListeners?: () => void;
|
||||
|
||||
// TODO(FW-2832): types (DatetimeParts causes some errors that need untangling)
|
||||
private minParts?: any;
|
||||
@@ -1077,6 +1078,14 @@ export class Datetime implements ComponentInterface {
|
||||
this.clearFocusVisible();
|
||||
this.clearFocusVisible = undefined;
|
||||
}
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = undefined;
|
||||
}
|
||||
if (this.destroyOverlayListeners) {
|
||||
this.destroyOverlayListeners();
|
||||
this.destroyOverlayListeners = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1101,76 +1110,101 @@ export class Datetime implements ComponentInterface {
|
||||
this.initializeKeyboardListeners();
|
||||
}
|
||||
|
||||
componentDidLoad() {
|
||||
const { el, intersectionTrackerRef } = this;
|
||||
/**
|
||||
* Sets up visibility detection for the datetime component.
|
||||
*
|
||||
* Uses multiple strategies to reliably detect when the datetime becomes
|
||||
* visible, which is necessary for proper initialization of scrollable areas:
|
||||
* 1. ResizeObserver - detects dimension changes
|
||||
* 2. Overlay event listeners - for datetime inside modals/popovers
|
||||
* 3. Polling fallback - for browsers where observers are unreliable (WebKit)
|
||||
*/
|
||||
private initializeVisibilityObserver() {
|
||||
const { el } = this;
|
||||
|
||||
/**
|
||||
* If a scrollable element is hidden using `display: none`,
|
||||
* it will not have a scroll height meaning we cannot scroll elements
|
||||
* into view. As a result, we will need to wait for the datetime to become
|
||||
* visible if used inside of a modal or a popover otherwise the scrollable
|
||||
* areas will not have the correct values snapped into place.
|
||||
*/
|
||||
const visibleCallback = (entries: IntersectionObserverEntry[]) => {
|
||||
const ev = entries[0];
|
||||
if (!ev.isIntersecting) {
|
||||
const markReady = () => {
|
||||
if (el.classList.contains('datetime-ready')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.initializeListeners();
|
||||
|
||||
/**
|
||||
* TODO FW-2793: Datetime needs a frame to ensure that it
|
||||
* can properly scroll contents into view. As a result
|
||||
* we hide the scrollable content until after that frame
|
||||
* so users do not see the content quickly shifting. The downside
|
||||
* is that the content will pop into view a frame after. Maybe there
|
||||
* is a better way to handle this?
|
||||
*/
|
||||
writeTask(() => {
|
||||
this.el.classList.add('datetime-ready');
|
||||
el.classList.add('datetime-ready');
|
||||
});
|
||||
};
|
||||
const visibleIO = new IntersectionObserver(visibleCallback, { threshold: 0.01, root: el });
|
||||
|
||||
/**
|
||||
* Use raf to avoid a race condition between the component loading and
|
||||
* its display animation starting (such as when shown in a modal). This
|
||||
* could cause the datetime to start at a visibility of 0, erroneously
|
||||
* triggering the `hiddenIO` observer below.
|
||||
*/
|
||||
raf(() => visibleIO?.observe(intersectionTrackerRef!));
|
||||
|
||||
/**
|
||||
* We need to clean up listeners when the datetime is hidden
|
||||
* in a popover/modal so that we can properly scroll containers
|
||||
* back into view if they are re-presented. When the datetime is hidden
|
||||
* the scroll areas have scroll widths/heights of 0px, so any snapping
|
||||
* we did originally has been lost.
|
||||
*/
|
||||
const hiddenCallback = (entries: IntersectionObserverEntry[]) => {
|
||||
const ev = entries[0];
|
||||
if (ev.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markHidden = () => {
|
||||
this.destroyInteractionListeners();
|
||||
|
||||
/**
|
||||
* When datetime is hidden, we need to make sure that
|
||||
* the month/year picker is closed. Otherwise,
|
||||
* it will be open when the datetime re-appears
|
||||
* and the scroll area of the calendar grid will be 0.
|
||||
* As a result, the wrong month will be shown.
|
||||
*/
|
||||
this.showMonthAndYear = false;
|
||||
|
||||
writeTask(() => {
|
||||
this.el.classList.remove('datetime-ready');
|
||||
el.classList.remove('datetime-ready');
|
||||
});
|
||||
startVisibilityPolling();
|
||||
};
|
||||
const hiddenIO = new IntersectionObserver(hiddenCallback, { threshold: 0, root: el });
|
||||
raf(() => hiddenIO?.observe(intersectionTrackerRef!));
|
||||
|
||||
/**
|
||||
* FW-6931: Poll for visibility as a fallback for browsers where
|
||||
* ResizeObserver doesn't fire reliably (e.g., WebKit).
|
||||
*/
|
||||
const startVisibilityPolling = () => {
|
||||
let pollCount = 0;
|
||||
const poll = () => {
|
||||
if (el.classList.contains('datetime-ready') || pollCount++ >= 60) {
|
||||
return;
|
||||
}
|
||||
const { width, height } = el.getBoundingClientRect();
|
||||
if (width > 0 && height > 0) {
|
||||
markReady();
|
||||
} else {
|
||||
raf(poll);
|
||||
}
|
||||
};
|
||||
raf(poll);
|
||||
};
|
||||
|
||||
/**
|
||||
* FW-6931: Listen for overlay present/dismiss events when datetime
|
||||
* is inside a modal or popover.
|
||||
*/
|
||||
const parentOverlay = el.closest('ion-modal, ion-popover') as HTMLIonModalElement | HTMLIonPopoverElement | null;
|
||||
if (parentOverlay) {
|
||||
const handlePresent = () => markReady();
|
||||
const handleDismiss = () => markHidden();
|
||||
|
||||
parentOverlay.addEventListener('didPresent', handlePresent);
|
||||
parentOverlay.addEventListener('didDismiss', handleDismiss);
|
||||
|
||||
this.destroyOverlayListeners = () => {
|
||||
parentOverlay.removeEventListener('didPresent', handlePresent);
|
||||
parentOverlay.removeEventListener('didDismiss', handleDismiss);
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
const isVisible = width > 0 && height > 0;
|
||||
const isReady = el.classList.contains('datetime-ready');
|
||||
|
||||
if (isVisible && !isReady) {
|
||||
markReady();
|
||||
} else if (!isVisible && isReady) {
|
||||
markHidden();
|
||||
}
|
||||
});
|
||||
|
||||
// Use raf to avoid race condition with modal/popover animations
|
||||
raf(() => this.resizeObserver?.observe(el));
|
||||
startVisibilityPolling();
|
||||
} else {
|
||||
// Test environment fallback - mark ready immediately
|
||||
writeTask(() => {
|
||||
el.classList.add('datetime-ready');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidLoad() {
|
||||
this.initializeVisibilityObserver();
|
||||
|
||||
/**
|
||||
* Datetime uses Ionic components that emit
|
||||
@@ -2655,20 +2689,6 @@ export class Datetime implements ComponentInterface {
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
WebKit has a quirk where IntersectionObserver callbacks are delayed until after
|
||||
an accelerated animation finishes if the "root" specified in the config is the
|
||||
browser viewport (the default behavior if "root" is not specified). This means
|
||||
that when presenting a datetime in a modal on iOS the calendar body appears
|
||||
blank until the modal animation finishes.
|
||||
|
||||
We can work around this by observing .intersection-tracker and using the host
|
||||
(ion-datetime) as the "root". This allows the IO callback to fire the moment
|
||||
the datetime is visible. The .intersection-tracker element should not have
|
||||
dimensions or additional styles, and it should not be positioned absolutely
|
||||
otherwise the IO callback may fire at unexpected times.
|
||||
*/}
|
||||
<div class="intersection-tracker" ref={(el) => (this.intersectionTrackerRef = el)}></div>
|
||||
{this.renderDatetime(mode)}
|
||||
</Host>
|
||||
);
|
||||
|
||||
@@ -394,6 +394,39 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that datetime becomes ready via ResizeObserver.
|
||||
* This tests that the datetime properly initializes when it has
|
||||
* dimensions, using ResizeObserver to detect visibility.
|
||||
*/
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('datetime: visibility detection'), () => {
|
||||
test('should become ready when rendered with dimensions', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30706',
|
||||
});
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-datetime value="2022-05-03"></ion-datetime>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const datetime = page.locator('ion-datetime');
|
||||
|
||||
// Wait for the datetime to become ready via ResizeObserver
|
||||
await page.locator('.datetime-ready').waitFor();
|
||||
|
||||
await expect(datetime).toHaveClass(/datetime-ready/);
|
||||
|
||||
const calendarBody = datetime.locator('.calendar-body');
|
||||
await expect(calendarBody).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* We are setting RTL on the component
|
||||
* instead, so we don't need to test
|
||||
|
||||
@@ -52,6 +52,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('datetime: custom focus'), () => {
|
||||
test('should focus the selected day and then the day after', async ({ page }) => {
|
||||
await page.goto(`/src/components/datetime/test/custom`, config);
|
||||
await page.locator('.datetime-ready').last().waitFor();
|
||||
|
||||
const datetime = page.locator('#custom-calendar-days');
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ configs().forEach(({ title, screenshot, config }) => {
|
||||
await page.goto('/src/components/datetime/test/first-day-of-week', config);
|
||||
|
||||
const datetime = page.locator('ion-datetime');
|
||||
await page.locator('.datetime-ready').waitFor();
|
||||
await expect(datetime).toHaveScreenshot(screenshot(`datetime-day-of-week`));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
`,
|
||||
config
|
||||
);
|
||||
await page.locator('.datetime-ready').waitFor();
|
||||
});
|
||||
|
||||
test('should render highlights correctly when using an array', async ({ page }) => {
|
||||
|
||||
@@ -71,7 +71,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
private gesture?: Gesture;
|
||||
private coreDelegate: FrameworkDelegate = CoreDelegate();
|
||||
private sheetTransition?: Promise<any>;
|
||||
private isSheetModal = false;
|
||||
@State() private isSheetModal = false;
|
||||
private currentBreakpoint?: number;
|
||||
private wrapperEl?: HTMLElement;
|
||||
private backdropEl?: HTMLIonBackdropElement;
|
||||
@@ -100,6 +100,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
private parentRemovalObserver?: MutationObserver;
|
||||
// Cached original parent from before modal is moved to body during presentation
|
||||
private cachedOriginalParent?: HTMLElement;
|
||||
// Cached ion-page ancestor for child route passthrough
|
||||
private cachedPageParent?: HTMLElement | null;
|
||||
|
||||
lastFocus?: HTMLElement;
|
||||
animation?: Animation;
|
||||
@@ -644,7 +646,14 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
window.addEventListener(KEYBOARD_DID_OPEN, this.keyboardOpenCallback);
|
||||
}
|
||||
|
||||
if (this.isSheetModal) {
|
||||
/**
|
||||
* Recalculate isSheetModal because framework bindings (e.g., Angular)
|
||||
* may not have been applied when componentWillLoad ran.
|
||||
*/
|
||||
const isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined;
|
||||
this.isSheetModal = isSheetModal;
|
||||
|
||||
if (isSheetModal) {
|
||||
this.initSheetGesture();
|
||||
} else if (hasCardModal) {
|
||||
this.initSwipeToClose();
|
||||
@@ -753,6 +762,91 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
this.moveSheetToBreakpoint = moveSheetToBreakpoint;
|
||||
|
||||
this.gesture.enable(true);
|
||||
|
||||
/**
|
||||
* When backdrop interaction is allowed, nested router outlets from child routes
|
||||
* may block pointer events to parent content. Apply passthrough styles only when
|
||||
* the modal was the sole content of a child route page.
|
||||
* See https://github.com/ionic-team/ionic-framework/issues/30700
|
||||
*/
|
||||
const backdropNotBlocking = this.showBackdrop === false || this.focusTrap === false || backdropBreakpoint > 0;
|
||||
if (backdropNotBlocking) {
|
||||
this.setupChildRoutePassthrough();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For sheet modals that allow background interaction, sets up pointer-events
|
||||
* passthrough on child route page wrappers and nested router outlets.
|
||||
*/
|
||||
private setupChildRoutePassthrough() {
|
||||
// Cache the page parent for cleanup
|
||||
this.cachedPageParent = this.getOriginalPageParent();
|
||||
const pageParent = this.cachedPageParent;
|
||||
|
||||
// Skip ion-app (controller modals) and pages with visible sibling content next to the modal
|
||||
if (!pageParent || pageParent.tagName === 'ION-APP') {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasVisibleContent = Array.from(pageParent.children).some(
|
||||
(child) =>
|
||||
child !== this.el &&
|
||||
!(child instanceof HTMLElement && window.getComputedStyle(child).display === 'none') &&
|
||||
child.tagName !== 'TEMPLATE' &&
|
||||
child.tagName !== 'SLOT' &&
|
||||
!(child.nodeType === Node.TEXT_NODE && !child.textContent?.trim())
|
||||
);
|
||||
|
||||
if (hasVisibleContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Child route case: page only contained the modal
|
||||
pageParent.classList.add('ion-page-overlay-passthrough');
|
||||
|
||||
// Also make nested router outlets passthrough
|
||||
const routerOutlet = pageParent.parentElement;
|
||||
if (routerOutlet?.tagName === 'ION-ROUTER-OUTLET' && routerOutlet.parentElement?.tagName !== 'ION-APP') {
|
||||
routerOutlet.style.setProperty('pointer-events', 'none');
|
||||
routerOutlet.setAttribute('data-overlay-passthrough', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the ion-page ancestor of the modal's original parent location.
|
||||
*/
|
||||
private getOriginalPageParent(): HTMLElement | null {
|
||||
if (!this.cachedOriginalParent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pageParent: HTMLElement | null = this.cachedOriginalParent;
|
||||
while (pageParent && !pageParent.classList.contains('ion-page')) {
|
||||
pageParent = pageParent.parentElement;
|
||||
}
|
||||
return pageParent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes passthrough styles added by setupChildRoutePassthrough.
|
||||
*/
|
||||
private cleanupChildRoutePassthrough() {
|
||||
const pageParent = this.cachedPageParent;
|
||||
if (!pageParent) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageParent.classList.remove('ion-page-overlay-passthrough');
|
||||
|
||||
const routerOutlet = pageParent.parentElement;
|
||||
if (routerOutlet?.hasAttribute('data-overlay-passthrough')) {
|
||||
routerOutlet.style.removeProperty('pointer-events');
|
||||
routerOutlet.removeAttribute('data-overlay-passthrough');
|
||||
}
|
||||
|
||||
// Clear the cached reference
|
||||
this.cachedPageParent = undefined;
|
||||
}
|
||||
|
||||
private sheetOnDismiss() {
|
||||
@@ -862,6 +956,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
}
|
||||
this.cleanupViewTransitionListener();
|
||||
this.cleanupParentRemovalObserver();
|
||||
|
||||
this.cleanupChildRoutePassthrough();
|
||||
}
|
||||
this.currentBreakpoint = undefined;
|
||||
this.animation = undefined;
|
||||
@@ -1183,6 +1279,20 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't observe for controller-based modals or when the parent is the
|
||||
* app root (document.body or ion-app). These parents won't be removed,
|
||||
* and observing document.body with subtree: true causes performance
|
||||
* issues with frameworks like Angular during change detection.
|
||||
*/
|
||||
if (
|
||||
this.hasController ||
|
||||
this.cachedOriginalParent === document.body ||
|
||||
this.cachedOriginalParent.tagName === 'ION-APP'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.parentRemovalObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test, Viewports } from '@utils/test/playwright';
|
||||
import type { E2EPage } from '@utils/test/playwright';
|
||||
import { configs, test, Viewports } from '@utils/test/playwright';
|
||||
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('modal: focus trapping'), () => {
|
||||
@@ -104,6 +104,28 @@ configs().forEach(({ title, screenshot, config }) => {
|
||||
});
|
||||
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('modal: parent removal observer'), () => {
|
||||
test('should not set up parentRemovalObserver for controller-created modals', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'FW-6766',
|
||||
});
|
||||
|
||||
await page.goto('/src/components/modal/test/basic', config);
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#basic-modal');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const modal = page.locator('ion-modal');
|
||||
const hasObserver = await modal.evaluate((el: any) => {
|
||||
return el.parentRemovalObserver !== undefined;
|
||||
});
|
||||
|
||||
expect(hasObserver).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('modal: backdrop'), () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/src/components/modal/test/basic', config);
|
||||
|
||||
@@ -64,6 +64,7 @@ export class Popover implements ComponentInterface, PopoverInterface {
|
||||
private destroyTriggerInteraction?: () => void;
|
||||
private destroyKeyboardInteraction?: () => void;
|
||||
private destroyDismissInteraction?: () => void;
|
||||
private headerResizeObserver?: ResizeObserver;
|
||||
|
||||
private inline = false;
|
||||
private workingDelegate?: FrameworkDelegate;
|
||||
@@ -361,6 +362,11 @@ export class Popover implements ComponentInterface, PopoverInterface {
|
||||
if (destroyTriggerInteraction) {
|
||||
destroyTriggerInteraction();
|
||||
}
|
||||
|
||||
if (this.headerResizeObserver) {
|
||||
this.headerResizeObserver.disconnect();
|
||||
this.headerResizeObserver = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillLoad() {
|
||||
@@ -491,6 +497,8 @@ export class Popover implements ComponentInterface, PopoverInterface {
|
||||
inline
|
||||
);
|
||||
|
||||
this.recalculateContentOnHeaderReady();
|
||||
|
||||
if (!this.keyboardEvents) {
|
||||
this.configureKeyboardInteraction();
|
||||
}
|
||||
@@ -540,6 +548,39 @@ export class Popover implements ComponentInterface, PopoverInterface {
|
||||
unlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the header for height changes and trigger content dimension
|
||||
* recalculation when the header has a height > 0. This sets the offset-top
|
||||
* of the content to the height of the header correctly.
|
||||
*/
|
||||
private recalculateContentOnHeaderReady() {
|
||||
const popoverContent = this.el.shadowRoot?.querySelector('.popover-content');
|
||||
if (!popoverContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentContainer = this.usersElement || popoverContent;
|
||||
|
||||
const header = contentContainer.querySelector('ion-header') as HTMLElement | null;
|
||||
const contentElements = contentContainer.querySelectorAll('ion-content');
|
||||
|
||||
if (!header || contentElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.headerResizeObserver = new ResizeObserver(async () => {
|
||||
if (header.offsetHeight > 0) {
|
||||
this.headerResizeObserver?.disconnect();
|
||||
this.headerResizeObserver = undefined;
|
||||
for (const contentEl of contentElements) {
|
||||
await contentEl.recalculateDimensions();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.headerResizeObserver.observe(header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the popover overlay after it has been presented.
|
||||
* This is a no-op if the overlay has not been presented yet. If you want
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
194
core/src/components/radio-group/test/validation/index.html
Normal 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>
|
||||
@@ -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() {
|
||||
@@ -556,14 +556,19 @@ export class Select implements ComponentInterface {
|
||||
.filter((cls) => cls !== 'hydrated')
|
||||
.join(' ');
|
||||
const optClass = `${OPTION_CLASS} ${copyClasses}`;
|
||||
const isSelected = isOptionSelected(selectValue, value, this.compareWith);
|
||||
|
||||
return {
|
||||
role: isOptionSelected(selectValue, value, this.compareWith) ? 'selected' : '',
|
||||
role: isSelected ? 'selected' : '',
|
||||
text: option.textContent,
|
||||
cssClass: optClass,
|
||||
handler: () => {
|
||||
this.setValue(value);
|
||||
},
|
||||
htmlAttributes: {
|
||||
'aria-checked': isSelected ? 'true' : 'false',
|
||||
role: 'radio',
|
||||
},
|
||||
} as ActionSheetButton;
|
||||
});
|
||||
|
||||
@@ -1111,7 +1116,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 +1126,7 @@ export class Select implements ComponentInterface {
|
||||
);
|
||||
}
|
||||
|
||||
private getHintTextID(): string | undefined {
|
||||
private getHintTextId(): string | undefined {
|
||||
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
|
||||
|
||||
if (isInvalid && errorText) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('textarea: a11y'), () => {
|
||||
test.describe(title('select: a11y'), () => {
|
||||
test('default layout should not have accessibility violations', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
|
||||
@@ -45,6 +45,20 @@
|
||||
<ion-toggle style="width: 100%"> Full-width </ion-toggle><br />
|
||||
<ion-toggle> Long Label Long Label Long Label Long Label Long Label Long Label </ion-toggle><br />
|
||||
</ion-content>
|
||||
|
||||
<script>
|
||||
document.addEventListener('ionBlur', (ev) => {
|
||||
console.log('ionBlur', ev);
|
||||
});
|
||||
|
||||
document.addEventListener('ionChange', (ev) => {
|
||||
console.log('ionChange', ev);
|
||||
});
|
||||
|
||||
document.addEventListener('ionFocus', (ev) => {
|
||||
console.log('ionFocus', ev);
|
||||
});
|
||||
</script>
|
||||
</ion-app>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,65 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
/**
|
||||
* This behavior does not vary across modes/directions
|
||||
*/
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('toggle: ionChange'), () => {
|
||||
test('should fire ionChange when interacting with toggle', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionChange = await page.spyOnEvent('ionChange');
|
||||
const toggle = page.locator('ion-toggle');
|
||||
|
||||
await toggle.click();
|
||||
expect(ionChange).toHaveReceivedEventDetail({ value: 'my-toggle', checked: true });
|
||||
|
||||
await toggle.click();
|
||||
expect(ionChange).toHaveReceivedEventDetail({ value: 'my-toggle', checked: false });
|
||||
});
|
||||
|
||||
test('should fire ionChange when interacting with toggle in item', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-item>
|
||||
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
|
||||
</ion-item>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionChange = await page.spyOnEvent('ionChange');
|
||||
const item = page.locator('ion-item');
|
||||
|
||||
await item.click();
|
||||
expect(ionChange).toHaveReceivedEventDetail({ value: 'my-toggle', checked: true });
|
||||
|
||||
await item.click();
|
||||
expect(ionChange).toHaveReceivedEventDetail({ value: 'my-toggle', checked: false });
|
||||
});
|
||||
|
||||
test('should not fire when programmatically setting a value', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionChange = await page.spyOnEvent('ionChange');
|
||||
const toggle = page.locator('ion-toggle');
|
||||
|
||||
await toggle.evaluate((el: HTMLIonToggleElement) => (el.checked = true));
|
||||
expect(ionChange).not.toHaveReceivedEvent();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('toggle: click'), () => {
|
||||
test('should trigger onclick only once when clicking the label', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
@@ -35,4 +93,195 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
expect(clickCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('toggle: ionFocus'), () => {
|
||||
test('should not have visual regressions', async ({ page, pageUtils }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<style>
|
||||
#container {
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="container">
|
||||
<ion-toggle>Unchecked</ion-toggle>
|
||||
</div>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
const container = page.locator('#container');
|
||||
|
||||
await expect(container).toHaveScreenshot(screenshot(`toggle-focus`));
|
||||
});
|
||||
|
||||
test('should not have visual regressions when interacting with toggle in item', async ({ page, pageUtils }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-item class="ion-focused">
|
||||
<ion-toggle>Unchecked</ion-toggle>
|
||||
</ion-item>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
// Test focus with keyboard navigation
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
const item = page.locator('ion-item');
|
||||
|
||||
await expect(item).toHaveScreenshot(screenshot(`toggle-in-item-focus`));
|
||||
});
|
||||
|
||||
test('should fire ionFocus when toggle is focused', async ({ page, pageUtils }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionFocus = await page.spyOnEvent('ionFocus');
|
||||
|
||||
// Test focus with keyboard navigation
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
expect(ionFocus).toHaveReceivedEventTimes(1);
|
||||
|
||||
// Reset focus
|
||||
const toggle = page.locator('ion-toggle');
|
||||
const toggleBoundingBox = (await toggle.boundingBox())!;
|
||||
await page.mouse.click(0, toggleBoundingBox.height + 1);
|
||||
|
||||
// Test focus with click
|
||||
await toggle.click();
|
||||
|
||||
expect(ionFocus).toHaveReceivedEventTimes(2);
|
||||
});
|
||||
|
||||
test('should fire ionFocus when interacting with toggle in item', async ({ page, pageUtils }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-item>
|
||||
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
|
||||
</ion-item>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionFocus = await page.spyOnEvent('ionFocus');
|
||||
|
||||
// Test focus with keyboard navigation
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
expect(ionFocus).toHaveReceivedEventTimes(1);
|
||||
|
||||
// Verify that the event target is the toggle and not the item
|
||||
const eventByKeyboard = ionFocus.events[0];
|
||||
expect((eventByKeyboard.target as HTMLElement).tagName.toLowerCase()).toBe('ion-toggle');
|
||||
|
||||
// Reset focus
|
||||
const toggle = page.locator('ion-toggle');
|
||||
const toggleBoundingBox = (await toggle.boundingBox())!;
|
||||
await page.mouse.click(0, toggleBoundingBox.height + 1);
|
||||
|
||||
// Test focus with click
|
||||
const item = page.locator('ion-item');
|
||||
await item.click();
|
||||
|
||||
expect(ionFocus).toHaveReceivedEventTimes(2);
|
||||
|
||||
// Verify that the event target is the toggle and not the item
|
||||
const eventByClick = ionFocus.events[0];
|
||||
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-toggle');
|
||||
});
|
||||
|
||||
test('should not fire when programmatically setting a value', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionFocus = await page.spyOnEvent('ionFocus');
|
||||
const toggle = page.locator('ion-toggle');
|
||||
|
||||
await toggle.evaluate((el: HTMLIonToggleElement) => (el.checked = true));
|
||||
expect(ionFocus).not.toHaveReceivedEvent();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('toggle: ionBlur'), () => {
|
||||
test('should fire ionBlur when toggle is blurred', async ({ page, pageUtils }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionBlur = await page.spyOnEvent('ionBlur');
|
||||
|
||||
// Test blur with keyboard navigation
|
||||
// Focus the toggle
|
||||
await pageUtils.pressKeys('Tab');
|
||||
// Blur the toggle
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
expect(ionBlur).toHaveReceivedEventTimes(1);
|
||||
|
||||
// Test blur with click
|
||||
const toggle = page.locator('ion-toggle');
|
||||
// Focus the toggle
|
||||
await toggle.click();
|
||||
// Blur the toggle by clicking outside of it
|
||||
const toggleBoundingBox = (await toggle.boundingBox())!;
|
||||
await page.mouse.click(0, toggleBoundingBox.height + 1);
|
||||
|
||||
expect(ionBlur).toHaveReceivedEventTimes(2);
|
||||
});
|
||||
|
||||
test('should fire ionBlur after interacting with toggle in item', async ({ page, pageUtils }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-item>
|
||||
<ion-toggle aria-label="toggle" value="my-toggle"></ion-toggle>
|
||||
</ion-item>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const ionBlur = await page.spyOnEvent('ionBlur');
|
||||
|
||||
// Test blur with keyboard navigation
|
||||
// Focus the toggle
|
||||
await pageUtils.pressKeys('Tab');
|
||||
// Blur the toggle
|
||||
await pageUtils.pressKeys('Tab');
|
||||
|
||||
expect(ionBlur).toHaveReceivedEventTimes(1);
|
||||
|
||||
// Verify that the event target is the toggle and not the item
|
||||
const event = ionBlur.events[0];
|
||||
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-toggle');
|
||||
|
||||
// Test blur with click
|
||||
const item = page.locator('ion-item');
|
||||
await item.click();
|
||||
// Blur the toggle by clicking outside of it
|
||||
const itemBoundingBox = (await item.boundingBox())!;
|
||||
await page.mouse.click(0, itemBoundingBox.height + 1);
|
||||
|
||||
expect(ionBlur).toHaveReceivedEventTimes(2);
|
||||
|
||||
// Verify that the event target is the toggle and not the item
|
||||
const eventByClick = ionBlur.events[0];
|
||||
expect((eventByClick.target as HTMLElement).tagName.toLowerCase()).toBe('ion-toggle');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -223,6 +223,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
<script>
|
||||
document.addEventListener('ionBlur', (ev) => {
|
||||
console.log('ionBlur', ev);
|
||||
});
|
||||
|
||||
document.addEventListener('ionChange', (ev) => {
|
||||
console.log('ionChange', ev);
|
||||
});
|
||||
|
||||
document.addEventListener('ionFocus', (ev) => {
|
||||
console.log('ionFocus', ev);
|
||||
});
|
||||
</script>
|
||||
</ion-app>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
184
core/src/components/toggle/test/validation/index.html
Normal 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>
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
|
||||
import { Build, Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
|
||||
import { checkInvalidState } from '@utils/forms';
|
||||
import { renderHiddenInput, inheritAriaAttributes } from '@utils/helpers';
|
||||
import type { Attributes } from '@utils/helpers';
|
||||
import { hapticSelection } from '@utils/native/haptic';
|
||||
@@ -40,16 +41,23 @@ export class Toggle implements ComponentInterface {
|
||||
private helperTextId = `${this.inputId}-helper-text`;
|
||||
private errorTextId = `${this.inputId}-error-text`;
|
||||
private gesture?: Gesture;
|
||||
private focusEl?: HTMLElement;
|
||||
private lastDrag = 0;
|
||||
private inheritedAttributes: Attributes = {};
|
||||
private toggleTrack?: HTMLElement;
|
||||
private didLoad = false;
|
||||
private validationObserver?: MutationObserver;
|
||||
|
||||
@Element() el!: HTMLIonToggleElement;
|
||||
|
||||
@State() activated = false;
|
||||
|
||||
/**
|
||||
* Track validation state for proper aria-live announcements.
|
||||
*/
|
||||
@State() isInvalid = false;
|
||||
|
||||
@State() private hintTextId?: string;
|
||||
|
||||
/**
|
||||
* The color to use from your application's color palette.
|
||||
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
|
||||
@@ -162,7 +170,6 @@ export class Toggle implements ComponentInterface {
|
||||
const isNowChecked = !checked;
|
||||
this.checked = isNowChecked;
|
||||
|
||||
this.setFocus();
|
||||
this.ionChange.emit({
|
||||
checked: isNowChecked,
|
||||
value,
|
||||
@@ -170,15 +177,56 @@ export class Toggle implements ComponentInterface {
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
const { didLoad, el } = this;
|
||||
|
||||
/**
|
||||
* If we have not yet rendered
|
||||
* ion-toggle, then toggleTrack is not defined.
|
||||
* But if we are moving ion-toggle via appendChild,
|
||||
* then toggleTrack will be defined.
|
||||
*/
|
||||
if (this.didLoad) {
|
||||
if (didLoad) {
|
||||
this.setupGesture();
|
||||
}
|
||||
|
||||
// Watch for class changes to update validation state.
|
||||
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
|
||||
this.validationObserver = new MutationObserver(() => {
|
||||
const newIsInvalid = checkInvalidState(el);
|
||||
if (this.isInvalid !== newIsInvalid) {
|
||||
this.isInvalid = newIsInvalid;
|
||||
/**
|
||||
* Screen readers tend to announce changes
|
||||
* to `aria-describedby` when the attribute
|
||||
* is changed during a blur event for a
|
||||
* native form control.
|
||||
* However, the announcement can be spotty
|
||||
* when using a non-native form control
|
||||
* and `forceUpdate()`.
|
||||
* This is due to `forceUpdate()` internally
|
||||
* rescheduling the DOM update to a lower
|
||||
* priority queue regardless if it's called
|
||||
* inside a Promise or not, thus causing
|
||||
* the screen reader to potentially miss the
|
||||
* change.
|
||||
* By using a State variable inside a Promise,
|
||||
* it guarantees a re-render immediately at
|
||||
* a higher priority.
|
||||
*/
|
||||
Promise.resolve().then(() => {
|
||||
this.hintTextId = this.getHintTextId();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.validationObserver.observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
}
|
||||
|
||||
// Always set initial state
|
||||
this.isInvalid = checkInvalidState(el);
|
||||
}
|
||||
|
||||
componentDidLoad() {
|
||||
@@ -209,12 +257,20 @@ export class Toggle implements ComponentInterface {
|
||||
this.gesture.destroy();
|
||||
this.gesture = undefined;
|
||||
}
|
||||
|
||||
// Clean up validation observer to prevent memory leaks.
|
||||
if (this.validationObserver) {
|
||||
this.validationObserver.disconnect();
|
||||
this.validationObserver = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillLoad() {
|
||||
this.inheritedAttributes = {
|
||||
...inheritAriaAttributes(this.el),
|
||||
};
|
||||
|
||||
this.hintTextId = this.getHintTextId();
|
||||
}
|
||||
|
||||
private onStart() {
|
||||
@@ -243,9 +299,7 @@ export class Toggle implements ComponentInterface {
|
||||
}
|
||||
|
||||
private setFocus() {
|
||||
if (this.focusEl) {
|
||||
this.focusEl.focus();
|
||||
}
|
||||
this.el.focus();
|
||||
}
|
||||
|
||||
private onKeyDown = (ev: KeyboardEvent) => {
|
||||
@@ -339,10 +393,10 @@ export class Toggle implements ComponentInterface {
|
||||
return this.el.textContent !== '';
|
||||
}
|
||||
|
||||
private getHintTextID(): string | undefined {
|
||||
const { el, helperText, errorText, helperTextId, errorTextId } = this;
|
||||
private getHintTextId(): string | undefined {
|
||||
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
|
||||
|
||||
if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
|
||||
if (isInvalid && errorText) {
|
||||
return errorTextId;
|
||||
}
|
||||
|
||||
@@ -358,7 +412,7 @@ export class Toggle implements ComponentInterface {
|
||||
* This element should only be rendered if hint text is set.
|
||||
*/
|
||||
private renderHintText() {
|
||||
const { helperText, errorText, helperTextId, errorTextId } = this;
|
||||
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
|
||||
|
||||
/**
|
||||
* undefined and empty string values should
|
||||
@@ -371,11 +425,11 @@ export class Toggle implements ComponentInterface {
|
||||
|
||||
return (
|
||||
<div class="toggle-bottom">
|
||||
<div id={helperTextId} class="helper-text" part="supporting-text helper-text">
|
||||
{helperText}
|
||||
<div id={helperTextId} class="helper-text" part="supporting-text helper-text" aria-live="polite">
|
||||
{!isInvalid ? helperText : null}
|
||||
</div>
|
||||
<div id={errorTextId} class="error-text" part="supporting-text error-text">
|
||||
{errorText}
|
||||
<div id={errorTextId} class="error-text" part="supporting-text error-text" role="alert">
|
||||
{isInvalid ? errorText : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -389,7 +443,6 @@ export class Toggle implements ComponentInterface {
|
||||
color,
|
||||
disabled,
|
||||
el,
|
||||
errorTextId,
|
||||
hasLabel,
|
||||
inheritedAttributes,
|
||||
inputId,
|
||||
@@ -409,14 +462,17 @@ export class Toggle implements ComponentInterface {
|
||||
<Host
|
||||
role="switch"
|
||||
aria-checked={`${checked}`}
|
||||
aria-describedby={this.getHintTextID()}
|
||||
aria-invalid={this.getHintTextID() === errorTextId}
|
||||
aria-describedby={this.hintTextId}
|
||||
aria-invalid={this.isInvalid ? 'true' : undefined}
|
||||
onClick={this.onClick}
|
||||
aria-labelledby={hasLabel ? inputLabelId : null}
|
||||
aria-label={inheritedAttributes['aria-label'] || null}
|
||||
aria-disabled={disabled ? 'true' : null}
|
||||
aria-required={required ? 'true' : undefined}
|
||||
tabindex={disabled ? undefined : 0}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
class={createColorClasses(color, {
|
||||
[mode]: true,
|
||||
'in-item': hostContext('ion-item', el),
|
||||
@@ -441,9 +497,6 @@ export class Toggle implements ComponentInterface {
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
id={inputId}
|
||||
onFocus={() => this.onFocus()}
|
||||
onBlur={() => this.onBlur()}
|
||||
ref={(focusEl) => (this.focusEl = focusEl)}
|
||||
required={required}
|
||||
{...inheritedAttributes}
|
||||
/>
|
||||
|
||||
@@ -181,6 +181,15 @@ html.ios ion-modal.modal-card .ion-page {
|
||||
z-index: $z-index-page-container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows pointer events to pass through child route page wrappers
|
||||
* when they only contain a sheet modal that permits background interaction.
|
||||
* https://github.com/ionic-team/ionic-framework/issues/30700
|
||||
*/
|
||||
.ion-page.ion-page-overlay-passthrough {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* When making custom dialogs, using
|
||||
* ion-content is not required. As a result,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,6 +38,20 @@ let lastId = 0;
|
||||
|
||||
export const activeAnimations = new WeakMap<OverlayInterface, Animation[]>();
|
||||
|
||||
type OverlayWithFocusTrapProps = HTMLIonOverlayElement & {
|
||||
focusTrap?: boolean;
|
||||
showBackdrop?: boolean;
|
||||
backdropBreakpoint?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if the overlay's backdrop is always blocking (no background interaction).
|
||||
* Returns false if showBackdrop=false or backdropBreakpoint > 0.
|
||||
*/
|
||||
const isBackdropAlwaysBlocking = (el: OverlayWithFocusTrapProps): boolean => {
|
||||
return el.showBackdrop !== false && !((el.backdropBreakpoint ?? 0) > 0);
|
||||
};
|
||||
|
||||
const createController = <Opts extends object, HTMLElm>(tagName: string) => {
|
||||
return {
|
||||
create(options: Opts): Promise<HTMLElm> {
|
||||
@@ -539,11 +553,9 @@ 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 overlayEl = overlay.el as OverlayWithFocusTrapProps;
|
||||
const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false;
|
||||
// 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 && isBackdropAlwaysBlocking(overlayEl);
|
||||
|
||||
overlay.presented = true;
|
||||
overlay.willPresent.emit();
|
||||
@@ -680,12 +692,12 @@ export const dismiss = async <OverlayDismissOptions>(
|
||||
* is dismissed.
|
||||
*/
|
||||
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 el = o as OverlayWithFocusTrapProps;
|
||||
return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && isBackdropAlwaysBlocking(el);
|
||||
});
|
||||
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
|
||||
const overlayEl = overlay.el as OverlayWithFocusTrapProps;
|
||||
const locksRoot =
|
||||
overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && overlayEl.showBackdrop !== false;
|
||||
overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && isBackdropAlwaysBlocking(overlayEl);
|
||||
|
||||
/**
|
||||
* If this is the last visible overlay that is trapping focus
|
||||
|
||||
@@ -102,6 +102,13 @@ export const setContent = async (page: Page, html: string, testInfo: TestInfo, o
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* URL query parameters cause the custom Playwright `page.route`
|
||||
* interceptor to fail, which is necessary to inject the test HTML.
|
||||
*
|
||||
* To avoid this, the final navigation URL is kept simple by using
|
||||
* hash params to ensure the route interceptor always works.
|
||||
*/
|
||||
await page.goto(`${baseUrl}#`, options);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,40 @@ import type { E2EPage } from '../../playwright-declarations';
|
||||
import { addE2EListener, EventSpy } from '../event-spy';
|
||||
|
||||
export const spyOnEvent = async (page: E2EPage, eventName: string): Promise<EventSpy> => {
|
||||
/**
|
||||
* Tabbing out of the page boundary can lead to unreliable `ionBlur events,
|
||||
* particularly in Firefox.
|
||||
*
|
||||
* This occurs because Playwright may incorrectly maintain focus state on the
|
||||
* last element, even after a Tab press attempts to shift focus outside the
|
||||
* viewport. To reliably trigger the necessary blur event, add a visually
|
||||
* hidden, focusable element at the end of the page to receive focus instead of
|
||||
* the browser.
|
||||
*
|
||||
* Playwright issue reference:
|
||||
* https://github.com/microsoft/playwright/issues/32269
|
||||
*/
|
||||
if (eventName === 'ionBlur') {
|
||||
const hiddenInput = await page.$('#hidden-input-for-ion-blur');
|
||||
if (!hiddenInput) {
|
||||
await page.evaluate(() => {
|
||||
const input = document.createElement('input');
|
||||
input.id = 'hidden-input-for-ion-blur';
|
||||
input.style.position = 'absolute';
|
||||
input.style.opacity = '0';
|
||||
input.style.height = '0';
|
||||
input.style.width = '0';
|
||||
input.style.pointerEvents = 'none';
|
||||
document.body.appendChild(input);
|
||||
|
||||
// Clean up the element when the page is unloaded.
|
||||
window.addEventListener('unload', () => {
|
||||
input.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const spy = new EventSpy(eventName);
|
||||
|
||||
const handle = await page.evaluateHandle(() => window);
|
||||
|
||||
@@ -186,7 +186,21 @@ test.describe.only('group of tests', () => {
|
||||
> [!IMPORTANT]
|
||||
> After debugging, make sure to remove the `.only` suffix to ensure all tests run again during normal execution.
|
||||
|
||||
#### 3. Pausing Test Execution
|
||||
#### 3. Repeating Tests to Reproduce Flaky Failures
|
||||
|
||||
Flaky tests may not fail consistently. To help reproduce intermittent failures locally, you can use Playwright's `--repeat-each` flag to run the same test multiple times in a row.
|
||||
|
||||
This is especially useful when CI reports a failure you cannot reproduce on your first local run.
|
||||
|
||||
**Example:**
|
||||
|
||||
```shell
|
||||
npm run test.e2e.docker.update-snapshots src/components/radio/test/a11y/radio.e2e.ts -- --repeat-each=10
|
||||
```
|
||||
|
||||
This runs the test 10 times, increasing the chance of catching the flaky behavior.
|
||||
|
||||
#### 4. Pausing Test Execution
|
||||
|
||||
Additionally, you can pause execution of a test by using the `page.pause()` method. This pauses the script execution and allows you to manually inspect the page in the browser.
|
||||
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"core",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "8.7.7"
|
||||
"version": "8.7.12"
|
||||
}
|
||||
@@ -3,6 +3,46 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.7.12](https://github.com/ionic-team/ionic-framework/compare/v8.7.11...v8.7.12) (2025-12-10)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular-server
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.11](https://github.com/ionic-team/ionic-framework/compare/v8.7.10...v8.7.11) (2025-11-26)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular-server
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.10](https://github.com/ionic-team/ionic-framework/compare/v8.7.9...v8.7.10) (2025-11-19)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular-server
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.9](https://github.com/ionic-team/ionic-framework/compare/v8.7.8...v8.7.9) (2025-11-05)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular-server
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.8](https://github.com/ionic-team/ionic-framework/compare/v8.7.7...v8.7.8) (2025-10-29)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular-server
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.7](https://github.com/ionic-team/ionic-framework/compare/v8.7.6...v8.7.7) (2025-10-15)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular-server
|
||||
|
||||
23
packages/angular-server/package-lock.json
generated
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "@ionic/angular-server",
|
||||
"version": "8.7.7",
|
||||
"version": "8.7.12",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@ionic/angular-server",
|
||||
"version": "8.7.7",
|
||||
"version": "8.7.12",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ionic/core": "^8.7.7"
|
||||
"@ionic/core": "^8.7.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-eslint/eslint-plugin": "^16.0.0",
|
||||
@@ -1031,14 +1031,17 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@ionic/core": {
|
||||
"version": "8.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.6.tgz",
|
||||
"integrity": "sha512-ufV64Pl0BYSoNla+DaTRXTS3hX6MQZZJPhAR3fJQ4N5Fg/vwMcHADQffstKZeoPqk6mbJoLqoTBjcWvaLRdO0g==",
|
||||
"version": "8.7.12",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.12.tgz",
|
||||
"integrity": "sha512-+QnytOHsMMDEz45hi/t9AN8ATaWMNZ7jNdx621BGSHi0JkEl1c4NylL3cfYIPJ/78y40ZG5NzprwNiR9sXdswg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stencil/core": "4.38.0",
|
||||
"ionicons": "^8.0.13",
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@ionic/eslint-config": {
|
||||
@@ -7306,9 +7309,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@ionic/core": {
|
||||
"version": "8.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.6.tgz",
|
||||
"integrity": "sha512-ufV64Pl0BYSoNla+DaTRXTS3hX6MQZZJPhAR3fJQ4N5Fg/vwMcHADQffstKZeoPqk6mbJoLqoTBjcWvaLRdO0g==",
|
||||
"version": "8.7.12",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.12.tgz",
|
||||
"integrity": "sha512-+QnytOHsMMDEz45hi/t9AN8ATaWMNZ7jNdx621BGSHi0JkEl1c4NylL3cfYIPJ/78y40ZG5NzprwNiR9sXdswg==",
|
||||
"requires": {
|
||||
"@stencil/core": "4.38.0",
|
||||
"ionicons": "^8.0.13",
|
||||
@@ -11286,4 +11289,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ionic/angular-server",
|
||||
"version": "8.7.7",
|
||||
"version": "8.7.12",
|
||||
"description": "Angular SSR Module for Ionic",
|
||||
"keywords": [
|
||||
"ionic",
|
||||
@@ -62,6 +62,6 @@
|
||||
},
|
||||
"prettier": "@ionic/prettier-config",
|
||||
"dependencies": {
|
||||
"@ionic/core": "^8.7.7"
|
||||
"@ionic/core": "^8.7.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,52 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.7.12](https://github.com/ionic-team/ionic-framework/compare/v8.7.11...v8.7.12) (2025-12-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **modal:** allow interaction with parent content through sheet modals in child routes ([#30839](https://github.com/ionic-team/ionic-framework/issues/30839)) ([b9e3cf0](https://github.com/ionic-team/ionic-framework/commit/b9e3cf0f5aae79a1f27a07b102c77e51f24825f4)), closes [#30700](https://github.com/ionic-team/ionic-framework/issues/30700)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.11](https://github.com/ionic-team/ionic-framework/compare/v8.7.10...v8.7.11) (2025-11-26)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.10](https://github.com/ionic-team/ionic-framework/compare/v8.7.9...v8.7.10) (2025-11-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **checkbox, toggle, radio-group:** improve screen reader announcement timing for validation errors ([#30714](https://github.com/ionic-team/ionic-framework/issues/30714)) ([92db364](https://github.com/ionic-team/ionic-framework/commit/92db36489cca944caf1593dbd518a1f025a171a2))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.9](https://github.com/ionic-team/ionic-framework/compare/v8.7.8...v8.7.9) (2025-11-05)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.8](https://github.com/ionic-team/ionic-framework/compare/v8.7.7...v8.7.8) (2025-10-29)
|
||||
|
||||
**Note:** Version bump only for package @ionic/angular
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.7](https://github.com/ionic-team/ionic-framework/compare/v8.7.6...v8.7.7) (2025-10-15)
|
||||
|
||||
|
||||
|
||||
46
packages/angular/package-lock.json
generated
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "@ionic/angular",
|
||||
"version": "8.7.7",
|
||||
"version": "8.7.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@ionic/angular",
|
||||
"version": "8.7.7",
|
||||
"version": "8.7.12",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ionic/core": "^8.7.7",
|
||||
"@ionic/core": "^8.7.12",
|
||||
"ionicons": "^8.0.13",
|
||||
"jsonc-parser": "^3.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
@@ -32,7 +32,7 @@
|
||||
"@ionic/eslint-config": "^0.3.0",
|
||||
"@ionic/prettier-config": "^2.0.0",
|
||||
"@schematics/angular": "^17.0.0",
|
||||
"@types/node": "12.12.5",
|
||||
"@types/node": "20.19.25",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
@@ -41,7 +41,7 @@
|
||||
"ng-packagr": "^16.0.0",
|
||||
"prettier": "^2.4.1",
|
||||
"rxjs": "~7.5.0",
|
||||
"typescript": "~4.9.3",
|
||||
"typescript": "~5.0.2",
|
||||
"typescript-eslint-language-service": "^5.0.0",
|
||||
"zone.js": "~0.13.0"
|
||||
},
|
||||
@@ -1398,14 +1398,17 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@ionic/core": {
|
||||
"version": "8.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.6.tgz",
|
||||
"integrity": "sha512-ufV64Pl0BYSoNla+DaTRXTS3hX6MQZZJPhAR3fJQ4N5Fg/vwMcHADQffstKZeoPqk6mbJoLqoTBjcWvaLRdO0g==",
|
||||
"version": "8.7.12",
|
||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.7.12.tgz",
|
||||
"integrity": "sha512-+QnytOHsMMDEz45hi/t9AN8ATaWMNZ7jNdx621BGSHi0JkEl1c4NylL3cfYIPJ/78y40ZG5NzprwNiR9sXdswg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stencil/core": "4.38.0",
|
||||
"ionicons": "^8.0.13",
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@ionic/eslint-config": {
|
||||
@@ -2402,9 +2405,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "12.12.5",
|
||||
"version": "20.19.25",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
|
||||
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.2",
|
||||
@@ -8719,16 +8727,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.2.tgz",
|
||||
"integrity": "sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.0"
|
||||
"node": ">=12.20"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint-language-service": {
|
||||
@@ -8756,6 +8765,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unique-filename": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz",
|
||||
@@ -9079,4 +9095,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ionic/angular",
|
||||
"version": "8.7.7",
|
||||
"version": "8.7.12",
|
||||
"description": "Angular specific wrappers for @ionic/core",
|
||||
"keywords": [
|
||||
"ionic",
|
||||
@@ -48,7 +48,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@ionic/core": "^8.7.7",
|
||||
"@ionic/core": "^8.7.12",
|
||||
"ionicons": "^8.0.13",
|
||||
"jsonc-parser": "^3.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
@@ -78,7 +78,7 @@
|
||||
"@ionic/eslint-config": "^0.3.0",
|
||||
"@ionic/prettier-config": "^2.0.0",
|
||||
"@schematics/angular": "^17.0.0",
|
||||
"@types/node": "12.12.5",
|
||||
"@types/node": "20.19.25",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
@@ -87,7 +87,7 @@
|
||||
"ng-packagr": "^16.0.0",
|
||||
"prettier": "^2.4.1",
|
||||
"rxjs": "~7.5.0",
|
||||
"typescript": "~4.9.3",
|
||||
"typescript": "~5.0.2",
|
||||
"typescript-eslint-language-service": "^5.0.0",
|
||||
"zone.js": "~0.13.0"
|
||||
},
|
||||
|
||||
59
packages/angular/test/apps/ng17/package-lock.json
generated
@@ -40,6 +40,7 @@
|
||||
"@angular/language-service": "^17.0.2",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@types/express": "^4.17.7",
|
||||
"@types/node": "^18.19.0",
|
||||
"@types/ws": "8.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
@@ -567,17 +568,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/@types/node": {
|
||||
"version": "20.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.0.tgz",
|
||||
"integrity": "sha512-nekiGu2NDb1BcVofVcEKMIwzlx4NjHlcjhoxxKBNLtz15Y1z7MYf549DFvkHSId02Ax6kGwWntIBPC3l/JZcmw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular/node_modules/@vitejs/plugin-basic-ssl": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.0.1.tgz",
|
||||
@@ -595,6 +585,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
|
||||
"integrity": "sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.18.10",
|
||||
"postcss": "^8.4.27",
|
||||
@@ -848,6 +839,7 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-17.0.2.tgz",
|
||||
"integrity": "sha512-32RHWhTgFLMonI3kRdstACay/nvetfxXjdwcTtABjcvBoND7nD9GMhkISQdgS+hcR/IhgXxaPidq8f2UAY5DBw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -863,6 +855,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.0.0.tgz",
|
||||
"integrity": "sha512-VzKYmN+1wSE2wLBEFu/NHSrRQbeaKkEDO9aZxd7nAnxC5T1Zcva7dfqZIXMHXH2HESYAZCvOS8ikBE/7DCtWWw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/architect": "0.1700.0",
|
||||
"@angular-devkit/core": "17.0.0",
|
||||
@@ -896,6 +889,7 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-17.0.2.tgz",
|
||||
"integrity": "sha512-hCW0njHgrcwTWNoKZDwf02DnhYLVWNXM2FMw66MKpfxTp7McSyaXjGBU9/hchW3dZJ0xTwyxoyoqJFoHYvg0yg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -911,6 +905,7 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.0.2.tgz",
|
||||
"integrity": "sha512-ewUFbKhMEhAmw2dGfk0ImhTlyrO2y4pJSKIZdFrkR1d0HiJX8bCHUdTiiR/2jeP7w2eamjXj15Rptb+iZZes2Q==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -931,6 +926,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.0.2.tgz",
|
||||
"integrity": "sha512-IUYL3Yz5RbR0Z0/x7it4GK3sMb2qVihxu0tlgfUW53P1Vi6nU/Zda0bCJTu6Z64qEtS8zwCwF1Ekomuq6UaiKg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.23.2",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@@ -958,6 +954,7 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-17.0.2.tgz",
|
||||
"integrity": "sha512-MjDxWeyn3Txi0qo/V/I+B/gndh0uptQ0XWgBRwOx6Wcr5zRGeZIFlXBxPpyXnGTlJkeyErsTN7FfFCZ4C3kCPA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -973,6 +970,7 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.0.2.tgz",
|
||||
"integrity": "sha512-w1QKifaVG4daxUktcBNZqBtOH1vn8t0YiwDR3woEdUYt0XYKMipfDzQfyIK+6fIVPOJUd42pRns1nbWJQHOInA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -999,6 +997,7 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.0.2.tgz",
|
||||
"integrity": "sha512-eTnPILEA/eAMkVUR/+g6fWhhMTmnmOzcZSGX/bBgQcvOhayZrDDxA6/Qf+jIB4RwC0wd3KA9zT5BCMmNojoUsg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -1037,6 +1036,7 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-17.0.2.tgz",
|
||||
"integrity": "sha512-+1uCnAw7Ql2r4BDnfaRvQrXI1H5qUB/1f8CwCjaVoIn7kLJs/ps4I0WbOVtujJ2VPnxIggfVtenXRRMlungZlg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0",
|
||||
"xhr2": "^0.2.0"
|
||||
@@ -1056,6 +1056,7 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-17.0.2.tgz",
|
||||
"integrity": "sha512-A1Ulv4qBAtJyK5g1yBlK1qZHe+KaaL5vMPAaPWUxICH8lHEodDkJlbYAUI2e4VL2BN7zBmdOep6tlBKPmHY3mw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -1115,6 +1116,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz",
|
||||
"integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.22.13",
|
||||
@@ -4492,10 +4494,14 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "12.20.55",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
|
||||
"integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==",
|
||||
"dev": true
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-forge": {
|
||||
"version": "1.3.9",
|
||||
@@ -4617,6 +4623,7 @@
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.10.0.tgz",
|
||||
"integrity": "sha512-+sZwIj+s+io9ozSxIWbNB5873OSdfeBEH/FR0re14WLI6BaKuSOnnwCJ2foUiu8uXf4dRp1UqHP0vrZ1zXGrog==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.10.0",
|
||||
"@typescript-eslint/types": "6.10.0",
|
||||
@@ -5024,6 +5031,7 @@
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
|
||||
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5095,6 +5103,7 @@
|
||||
"version": "8.12.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
@@ -5822,6 +5831,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001541",
|
||||
"electron-to-chromium": "^1.4.535",
|
||||
@@ -7605,6 +7615,7 @@
|
||||
"version": "7.32.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz",
|
||||
"integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.12.11",
|
||||
"@eslint/eslintrc": "^0.4.3",
|
||||
@@ -9838,6 +9849,7 @@
|
||||
"resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz",
|
||||
"integrity": "sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"copy-anything": "^2.0.1",
|
||||
"parse-node-version": "^1.0.1",
|
||||
@@ -11135,6 +11147,7 @@
|
||||
"integrity": "sha512-VShJISKCYt3iVJoMUPZiv67+0tiItxWMnfVmTmPZPio2Fu+wGc9U4ijjPxcmp2RJmLRaxkB9cn5rlrAvkIrNMA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@nrwl/tao": "17.0.3",
|
||||
"@yarnpkg/lockfile": "^1.1.0",
|
||||
@@ -12111,6 +12124,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"picocolors": "^1.0.0",
|
||||
@@ -12856,6 +12870,7 @@
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
@@ -12889,6 +12904,7 @@
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz",
|
||||
"integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": ">=3.0.0 <4.0.0",
|
||||
"immutable": "^4.0.0",
|
||||
@@ -13913,6 +13929,7 @@
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz",
|
||||
"integrity": "sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.8.2",
|
||||
@@ -13965,6 +13982,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -14275,6 +14293,7 @@
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
|
||||
"integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14332,9 +14351,7 @@
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
"version": "2.0.0",
|
||||
@@ -14586,6 +14603,7 @@
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz",
|
||||
"integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.3",
|
||||
"@types/estree": "^1.0.0",
|
||||
@@ -14633,6 +14651,7 @@
|
||||
"resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz",
|
||||
"integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@discoveryjs/json-ext": "^0.5.0",
|
||||
"@webpack-cli/configtest": "^1.2.0",
|
||||
@@ -14717,6 +14736,7 @@
|
||||
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz",
|
||||
"integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/bonjour": "^3.5.9",
|
||||
"@types/connect-history-api-fallback": "^1.3.5",
|
||||
@@ -14891,6 +14911,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz",
|
||||
"integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -14912,6 +14933,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -15251,6 +15273,7 @@
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.2.tgz",
|
||||
"integrity": "sha512-X4U7J1isDhoOmHmFWiLhloWc2lzMkdnumtfQ1LXzf/IOZp5NQYuMUTaviVzG/q1ugMBIXzin2AqeVJUoSEkNyQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"@angular/language-service": "^17.0.2",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@types/express": "^4.17.7",
|
||||
"@types/node": "^18.19.0",
|
||||
"@types/ws": "8.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Tests for sheet modals in child routes with showBackdrop=false.
|
||||
* Parent has buttons + nested outlet; child route contains only the modal.
|
||||
* See https://github.com/ionic-team/ionic-framework/issues/30700
|
||||
*/
|
||||
test.describe('Modals: Inline Sheet in Child Route (standalone)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/standalone/modal-child-route/child');
|
||||
});
|
||||
|
||||
test('should render parent content and child modal', async ({ page }) => {
|
||||
await expect(page.locator('#increment-btn')).toBeVisible();
|
||||
await expect(page.locator('#decrement-btn')).toBeVisible();
|
||||
await expect(page.locator('#background-action-count')).toHaveText('0');
|
||||
await expect(page.locator('ion-modal.show-modal')).toBeVisible();
|
||||
await expect(page.locator('#modal-content-loaded')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow interacting with parent content while modal is open in child route', async ({ page }) => {
|
||||
await expect(page.locator('ion-modal.show-modal')).toBeVisible();
|
||||
|
||||
await page.locator('#increment-btn').click();
|
||||
await expect(page.locator('#background-action-count')).toHaveText('1');
|
||||
});
|
||||
|
||||
test('should allow multiple interactions with parent content while modal is open', async ({ page }) => {
|
||||
await expect(page.locator('ion-modal.show-modal')).toBeVisible();
|
||||
|
||||
await page.locator('#increment-btn').click();
|
||||
await page.locator('#increment-btn').click();
|
||||
await expect(page.locator('#background-action-count')).toHaveText('2');
|
||||
|
||||
await page.locator('#decrement-btn').click();
|
||||
await expect(page.locator('#background-action-count')).toHaveText('1');
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -10,6 +10,9 @@ export class TemplateFormComponent {
|
||||
textareaValue = '';
|
||||
minLengthValue = '';
|
||||
selectValue = '';
|
||||
checkboxValue = false;
|
||||
toggleValue = false;
|
||||
radioGroupValue = '';
|
||||
|
||||
// Track if form has been submitted
|
||||
submitted = false;
|
||||
|
||||
@@ -13,6 +13,14 @@ export const routes: Routes = [
|
||||
{ path: 'modal', loadComponent: () => import('../modal/modal.component').then(c => c.ModalComponent) },
|
||||
{ path: 'modal-sheet-inline', loadComponent: () => import('../modal-sheet-inline/modal-sheet-inline.component').then(c => c.ModalSheetInlineComponent) },
|
||||
{ path: 'modal-dynamic-wrapper', loadComponent: () => import('../modal-dynamic-wrapper/modal-dynamic-wrapper.component').then(c => c.ModalDynamicWrapperComponent) },
|
||||
{ path: 'modal-child-route', redirectTo: '/standalone/modal-child-route/child', pathMatch: 'full' },
|
||||
{
|
||||
path: 'modal-child-route',
|
||||
loadComponent: () => import('../modal-child-route/modal-child-route-parent.component').then(c => c.ModalChildRouteParentComponent),
|
||||
children: [
|
||||
{ path: 'child', loadComponent: () => import('../modal-child-route/modal-child-route-child.component').then(c => c.ModalChildRouteChildComponent) },
|
||||
]
|
||||
},
|
||||
{ path: 'programmatic-modal', loadComponent: () => import('../programmatic-modal/programmatic-modal.component').then(c => c.ProgrammaticModalComponent) },
|
||||
{ path: 'router-outlet', loadComponent: () => import('../router-outlet/router-outlet.component').then(c => c.RouterOutletComponent) },
|
||||
{ path: 'back-button', loadComponent: () => import('../back-button/back-button.component').then(c => c.BackButtonComponent) },
|
||||
@@ -48,6 +56,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' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -100,6 +100,11 @@
|
||||
Modal Dynamic Wrapper Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/standalone/modal-child-route">
|
||||
<ion-label>
|
||||
Modal Child Route Test
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item routerLink="/standalone/programmatic-modal">
|
||||
<ion-label>
|
||||
Programmatic Modal Test
|
||||
@@ -121,19 +126,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>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { IonContent, IonHeader, IonModal, IonTitle, IonToolbar } from '@ionic/angular/standalone';
|
||||
|
||||
/**
|
||||
* Child route component containing only the sheet modal with showBackdrop=false.
|
||||
* Verifies issue https://github.com/ionic-team/ionic-framework/issues/30700
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-modal-child-route-child',
|
||||
template: `
|
||||
<ion-modal
|
||||
[isOpen]="true"
|
||||
[breakpoints]="[0.2, 0.5, 0.7]"
|
||||
[initialBreakpoint]="0.5"
|
||||
[showBackdrop]="false"
|
||||
>
|
||||
<ng-template>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Modal in Child Route</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<p id="modal-content-loaded">Modal content loaded in child route</p>
|
||||
</ion-content>
|
||||
</ng-template>
|
||||
</ion-modal>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [CommonModule, IonContent, IonHeader, IonModal, IonTitle, IonToolbar],
|
||||
})
|
||||
export class ModalChildRouteChildComponent {}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { IonButton, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar } from '@ionic/angular/standalone';
|
||||
|
||||
/**
|
||||
* Parent with interactive buttons and nested outlet for child route modal.
|
||||
* See https://github.com/ionic-team/ionic-framework/issues/30700
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-modal-child-route-parent',
|
||||
template: `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Parent Page with Nested Route</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<ion-button id="decrement-btn" (click)="decrement()">-</ion-button>
|
||||
<p id="background-action-count">{{ count }}</p>
|
||||
<ion-button id="increment-btn" (click)="increment()">+</ion-button>
|
||||
</div>
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
</ion-content>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [IonButton, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar],
|
||||
})
|
||||
export class ModalChildRouteParentComponent {
|
||||
count = 0;
|
||||
|
||||
increment() {
|
||||
this.count++;
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.count--;
|
||||
}
|
||||
}
|
||||