Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e4e9b9ff8 | ||
|
|
9097a1d146 | ||
|
|
edc202db34 | ||
|
|
1d232c8202 | ||
|
|
7584e617f1 | ||
|
|
cc75ff42e1 | ||
|
|
fdb24e4f5f | ||
|
|
bc5b3a3a84 | ||
|
|
b943db479e | ||
|
|
04c10985b1 | ||
|
|
ab7c863e36 | ||
|
|
1895f8fb20 | ||
|
|
d4f646104b | ||
|
|
82584aaf83 | ||
|
|
3f1e2c0644 | ||
|
|
fcc50d6d16 | ||
|
|
893d523997 | ||
|
|
1f68ad48f2 | ||
|
|
be14dc4bb8 | ||
|
|
364faced75 | ||
|
|
442e3e9831 | ||
|
|
62d880d620 | ||
|
|
4eca8d39d8 | ||
|
|
040bdf78c5 | ||
|
|
1bccf76d35 | ||
|
|
dd1c1e8fa3 | ||
|
|
d7b4d0690b | ||
|
|
3bbb0a78f3 | ||
|
|
4d81e2d820 | ||
|
|
e1388e646a | ||
|
|
56190b2c79 | ||
|
|
95b87020d6 | ||
|
|
ab733b71dd | ||
|
|
f99d0007a8 | ||
|
|
3b3318da51 | ||
|
|
d8abf4ce35 | ||
|
|
8ee1069b93 | ||
|
|
3b7beca8d0 | ||
|
|
0174a3938c | ||
|
|
f9159e1e90 | ||
|
|
095b72ef30 | ||
|
|
e953f7b506 | ||
|
|
a63afa3db6 | ||
|
|
26b6b7bb02 | ||
|
|
553aa65376 | ||
|
|
a5bd1dd518 | ||
|
|
48e4bc4776 | ||
|
|
fc496043d8 | ||
|
|
4fe98a42ff | ||
|
|
7c197c2c99 | ||
|
|
4a165bc26c | ||
|
|
9c404a6839 | ||
|
|
39b15cb3b0 | ||
|
|
fa16c3a7bd | ||
|
|
d6eb8ce8e9 | ||
|
|
07b46d745a | ||
|
|
37f87b39c4 | ||
|
|
f71f4bf454 | ||
|
|
36f4b4d600 | ||
|
|
3fac5ccbf8 | ||
|
|
35579250d5 | ||
|
|
61b588c6b9 | ||
|
|
e5634d45ee | ||
|
|
4b7f2fadef | ||
|
|
415245b9b4 | ||
|
|
61dc7eb4f0 | ||
|
|
b87cd07e91 | ||
|
|
fea0a3da0f | ||
|
|
f66c84a9b9 | ||
|
|
c54f257633 | ||
|
|
7d6430738e | ||
|
|
72826edf9a | ||
|
|
4360e39a58 | ||
|
|
622d62a3f4 | ||
|
|
12ede4b79c | ||
|
|
f83b000530 | ||
|
|
3b60a1d68a | ||
|
|
8573bf8083 | ||
|
|
2c6fac9060 | ||
|
|
b9fdfab667 | ||
|
|
f7af5d3ca5 | ||
|
|
03fb422bfa | ||
|
|
82de33b96e | ||
|
|
76b715874a | ||
|
|
6205338620 | ||
|
|
f775815a13 | ||
|
|
cf3caa287e | ||
|
|
2ee52d77c8 | ||
|
|
0e110de5e3 | ||
|
|
f50994a6ef | ||
|
|
5bf6f6e825 |
2
.github/actions/publish-npm/action.yml
vendored
@@ -22,7 +22,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: 🟢 Configure Node for Publish
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
3
.github/ionic-issue-bot.yml
vendored
@@ -40,7 +40,7 @@ comment:
|
||||
|
||||
|
||||
If the requested feature is something you would find useful for your applications, please react to the original post with 👍 (`+1`). If you would like to provide an additional use case for the feature, please post a comment.
|
||||
|
||||
|
||||
|
||||
The team will review this feedback and make a final decision. Any decision will be posted on this thread, but please note that we may ultimately decide not to pursue this feature.
|
||||
|
||||
@@ -83,6 +83,7 @@ stale:
|
||||
exemptLabels:
|
||||
- "good first issue"
|
||||
- "triage"
|
||||
- "bug: external"
|
||||
- "type: bug"
|
||||
- "type: feature request"
|
||||
- "needs: investigation"
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Build Ionic Angular Server'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -27,6 +27,10 @@ runs:
|
||||
run: npm run build
|
||||
shell: bash
|
||||
working-directory: ./packages/angular
|
||||
- name: Clean core package.json
|
||||
run: git checkout ./package.json
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
- name: 🔍 Check Diff
|
||||
run: git diff --exit-code
|
||||
shell: bash
|
||||
|
||||
@@ -8,8 +8,8 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
|
||||
@@ -29,4 +29,4 @@ runs:
|
||||
with:
|
||||
name: ionic-core
|
||||
output: core/CoreBuild.zip
|
||||
paths: core/dist core/components core/css core/hydrate core/loader core/src/components.d.ts
|
||||
paths: core/dist core/components core/css core/hydrate core/loader core/src/components.d.ts core/package.json
|
||||
|
||||
@@ -8,8 +8,8 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- name: 🕸️ Install Dependencies
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Build Ionic React Router'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Build Ionic React'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
@@ -31,6 +31,10 @@ runs:
|
||||
run: npm run test.spec
|
||||
shell: bash
|
||||
working-directory: ./packages/react
|
||||
- name: Clean core package.json
|
||||
run: git checkout ./package.json
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
- name: 🔍 Check Diff
|
||||
run: git diff --exit-code
|
||||
shell: bash
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Builds Ionic Vue Router'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Build Ionic Vue'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
@@ -27,6 +27,10 @@ runs:
|
||||
run: npm run build
|
||||
shell: bash
|
||||
working-directory: ./packages/vue
|
||||
- name: Clean core package.json
|
||||
run: git checkout ./package.json
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
- name: 🔍 Check Diff
|
||||
run: git diff --exit-code
|
||||
shell: bash
|
||||
|
||||
@@ -10,7 +10,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/download-artifact@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.name }}
|
||||
path: ${{ inputs.path }}
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
@@ -17,7 +17,7 @@ runs:
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
name: ionic-angular
|
||||
path: ./angular
|
||||
path: ./packages/angular
|
||||
filename: AngularBuild.zip
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
|
||||
@@ -3,7 +3,7 @@ description: 'Test Core Clean Build'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
|
||||
@@ -12,6 +12,10 @@ runs:
|
||||
name: ionic-core
|
||||
path: ./core
|
||||
filename: CoreBuild.zip
|
||||
- name: Clean core package.json
|
||||
run: git checkout ./package.json
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
- name: 🔍 Check Diff
|
||||
run: |
|
||||
git diff --exit-code || {
|
||||
|
||||
@@ -3,13 +3,17 @@ description: 'Test Core Lint'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- name: 🕸️ Install Dependencies
|
||||
run: npm ci
|
||||
working-directory: ./core
|
||||
shell: bash
|
||||
- name: Clean core package.json
|
||||
run: git checkout ./package.json
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
- name: 🖌️ Lint
|
||||
run: npm run lint
|
||||
shell: bash
|
||||
|
||||
@@ -13,7 +13,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
@@ -30,6 +30,10 @@ runs:
|
||||
run: npm run test.e2e.docker.ci ${{ inputs.component }} -- --shard=${{ inputs.shard }}/${{ inputs.totalShards }}
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
- name: Clean core package.json
|
||||
run: git checkout ./package.json
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
- name: Test and Update
|
||||
id: test-and-update
|
||||
if: inputs.update == 'true'
|
||||
@@ -62,7 +66,7 @@ runs:
|
||||
working-directory: ./core
|
||||
- name: 📦 Archive Updated Screenshots
|
||||
if: inputs.update == 'true' && steps.test-and-update.outputs.hasUpdatedScreenshots == 'true'
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: updated-screenshots-${{ inputs.shard }}-${{ inputs.totalShards }}
|
||||
path: UpdatedScreenshots-${{ inputs.shard }}-${{ inputs.totalShards }}.zip
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- name: 🕸️ Install Dependencies
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -6,7 +6,7 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: ./.github/workflows/actions/download-archive
|
||||
|
||||
@@ -7,10 +7,10 @@ on:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
- uses: actions/download-artifact@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ./artifacts
|
||||
- name: 🔎 Extract Archives
|
||||
@@ -21,6 +21,10 @@ runs:
|
||||
find . -type f -name 'UpdatedScreenshots-*.zip' -exec unzip -q -o -d ../ {} \;
|
||||
shell: bash
|
||||
working-directory: ./artifacts
|
||||
- name: Clean core package.json
|
||||
run: git checkout ./package.json
|
||||
shell: bash
|
||||
working-directory: ./core
|
||||
- name: 📸 Push Screenshots
|
||||
# Configure user as Ionitron
|
||||
# and push only the changed .png snapshots
|
||||
|
||||
@@ -13,7 +13,7 @@ runs:
|
||||
- name: 🗄️ Create Archive
|
||||
run: zip -q -r ${{ inputs.output }} ${{ inputs.paths }}
|
||||
shell: bash
|
||||
- uses: actions/upload-artifact@v5
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ inputs.name }}
|
||||
path: ${{ inputs.output }}
|
||||
|
||||
30
.github/workflows/build.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
build-core:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-core
|
||||
with:
|
||||
ionicons-version: ${{ inputs.ionicons_npm_release_tag }}
|
||||
@@ -31,21 +31,21 @@ jobs:
|
||||
needs: [build-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-core-clean-build
|
||||
|
||||
test-core-lint:
|
||||
needs: [build-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-core-lint
|
||||
|
||||
test-core-spec:
|
||||
needs: [build-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-core-spec
|
||||
|
||||
test-core-screenshot:
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
needs: [build-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-core-screenshot
|
||||
with:
|
||||
shard: ${{ matrix.shard }}
|
||||
@@ -90,14 +90,14 @@ jobs:
|
||||
needs: [build-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-vue
|
||||
|
||||
build-vue-router:
|
||||
needs: [build-vue]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-vue-router
|
||||
|
||||
test-vue-e2e:
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
needs: [build-vue, build-vue-router]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-vue-e2e
|
||||
with:
|
||||
app: ${{ matrix.apps }}
|
||||
@@ -126,14 +126,14 @@ jobs:
|
||||
needs: [build-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-angular
|
||||
|
||||
build-angular-server:
|
||||
needs: [build-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-angular-server
|
||||
|
||||
test-angular-e2e:
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
needs: [build-angular, build-angular-server]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-angular-e2e
|
||||
with:
|
||||
app: ${{ matrix.apps }}
|
||||
@@ -162,14 +162,14 @@ jobs:
|
||||
needs: [build-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-react
|
||||
|
||||
build-react-router:
|
||||
needs: [build-react]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-react-router
|
||||
|
||||
test-react-router-e2e:
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
needs: [build-react, build-react-router]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-react-router-e2e
|
||||
with:
|
||||
app: ${{ matrix.apps }}
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
needs: [build-react, build-react-router]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-react-e2e
|
||||
with:
|
||||
app: ${{ matrix.apps }}
|
||||
|
||||
2
.github/workflows/codeql-analysis.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
permissions:
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: javascript
|
||||
|
||||
2
.github/workflows/dev-build.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
outputs:
|
||||
dev-hash: ${{ steps.create-dev-hash.outputs.DEV_HASH }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# A 1 is required before the timestamp
|
||||
# as lerna will fail when there is a leading 0
|
||||
# See https://github.com/lerna/lerna/issues/2840
|
||||
|
||||
2
.github/workflows/nightly.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
outputs:
|
||||
nightly-hash: ${{ steps.create-nightly-hash.outputs.NIGHTLY_HASH }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# A 1 is required before the timestamp
|
||||
# as lerna will fail when there is a leading 0
|
||||
# See https://github.com/lerna/lerna/issues/2840
|
||||
|
||||
16
.github/workflows/release-ionic.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
release-core:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/publish-npm
|
||||
with:
|
||||
scope: '@ionic/core'
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
needs: [release-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Restore @ionic/docs built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
needs: [release-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Restore @ionic/core built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
needs: [release-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Restore @ionic/core built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
needs: [release-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Restore @ionic/core built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
needs: [release-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Restore @ionic/core built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
needs: [release-react]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Restore @ionic/core built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
needs: [release-vue]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Restore @ionic/core built cache
|
||||
uses: ./.github/workflows/actions/download-archive
|
||||
with:
|
||||
|
||||
4
.github/workflows/release.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ secrets.IONITRON_TOKEN }}
|
||||
fetch-depth: 0
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# Pull the latest version of the reference
|
||||
# branch instead of the revision that triggered
|
||||
# the workflow otherwise we won't get the commit
|
||||
|
||||
30
.github/workflows/stencil-nightly.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
build-core-with-stencil-nightly:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-core-stencil-prerelease
|
||||
with:
|
||||
stencil-version: ${{ inputs.npm_release_tag || 'nightly' }}
|
||||
@@ -35,21 +35,21 @@ jobs:
|
||||
needs: [build-core-with-stencil-nightly]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-core-clean-build
|
||||
|
||||
test-core-lint:
|
||||
needs: [build-core-with-stencil-nightly]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-core-lint
|
||||
|
||||
test-core-spec:
|
||||
needs: [build-core-with-stencil-nightly]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-core-spec
|
||||
with:
|
||||
stencil-version: ${{ inputs.npm_release_tag || 'nightly' }}
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
needs: [build-core-with-stencil-nightly]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-core-screenshot
|
||||
with:
|
||||
shard: ${{ matrix.shard }}
|
||||
@@ -100,14 +100,14 @@ jobs:
|
||||
needs: [build-core-with-stencil-nightly]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-vue
|
||||
|
||||
build-vue-router:
|
||||
needs: [build-vue]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-vue-router
|
||||
|
||||
test-vue-e2e:
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
needs: [build-vue, build-vue-router]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-vue-e2e
|
||||
with:
|
||||
app: ${{ matrix.apps }}
|
||||
@@ -136,14 +136,14 @@ jobs:
|
||||
needs: [build-core-with-stencil-nightly]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-angular
|
||||
|
||||
build-angular-server:
|
||||
needs: [build-core-with-stencil-nightly]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-angular-server
|
||||
|
||||
test-angular-e2e:
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
needs: [build-angular, build-angular-server]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-angular-e2e
|
||||
with:
|
||||
app: ${{ matrix.apps }}
|
||||
@@ -172,14 +172,14 @@ jobs:
|
||||
needs: [build-core-with-stencil-nightly]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-react
|
||||
|
||||
build-react-router:
|
||||
needs: [build-react]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-react-router
|
||||
|
||||
test-react-router-e2e:
|
||||
@@ -190,7 +190,7 @@ jobs:
|
||||
needs: [build-react, build-react-router]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-react-router-e2e
|
||||
with:
|
||||
app: ${{ matrix.apps }}
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
needs: [build-react, build-react-router]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-react-e2e
|
||||
with:
|
||||
app: ${{ matrix.apps }}
|
||||
|
||||
6
.github/workflows/update-screenshots.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
build-core:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/build-core
|
||||
|
||||
test-core-screenshot:
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
needs: [build-core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/workflows/actions/test-core-screenshot
|
||||
with:
|
||||
shard: ${{ matrix.shard }}
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-core-screenshot]
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# Normally, we could just push with the
|
||||
# default GITHUB_TOKEN, but that will
|
||||
# not cause the build workflow
|
||||
|
||||
57
CHANGELOG.md
@@ -3,6 +3,63 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.7.17](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.17) (2026-01-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **input:** prevent Android TalkBack from focusing label separately ([#30895](https://github.com/ionic-team/ionic-framework/issues/30895)) ([ab733b7](https://github.com/ionic-team/ionic-framework/commit/ab733b71dd355d9486757f219fe09acaefeeefcc))
|
||||
* **input:** prevent placeholder from overlapping start slot during scroll assist ([#30896](https://github.com/ionic-team/ionic-framework/issues/30896)) ([3b3318d](https://github.com/ionic-team/ionic-framework/commit/3b3318da513b199128f3822bd8226797cd118b0f))
|
||||
* **tab-bar:** prevent keyboard controller memory leak on rapid mount/unmount ([#30906](https://github.com/ionic-team/ionic-framework/issues/30906)) ([f99d000](https://github.com/ionic-team/ionic-framework/commit/f99d0007a8ffc9c7d3d2636e912c37c12112b21d))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.16](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.16) (2025-12-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **modal:** prevent card modal animation on viewport resize when modal is closed ([#30894](https://github.com/ionic-team/ionic-framework/issues/30894)) ([e5634d4](https://github.com/ionic-team/ionic-framework/commit/e5634d45ee5fd32715f6e6b75e0448f74ee1f8f2)), closes [#30679](https://github.com/ionic-team/ionic-framework/issues/30679)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.15](https://github.com/ionic-team/ionic-framework/compare/v8.7.14...v8.7.15) (2025-12-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** use Capacitor safe-area CSS variables on older WebViews ([#30865](https://github.com/ionic-team/ionic-framework/issues/30865)) ([8573bf8](https://github.com/ionic-team/ionic-framework/commit/8573bf8083f75eda13c954a56731a6aac8ca5724))
|
||||
* **header:** show iOS condense header when app is in MD mode ([#30690](https://github.com/ionic-team/ionic-framework/issues/30690)) ([f83b000](https://github.com/ionic-team/ionic-framework/commit/f83b0005309400d674e43c497bdffbcb9d2c4d94)), closes [#29929](https://github.com/ionic-team/ionic-framework/issues/29929)
|
||||
* **input-password-toggle:** improve screen reader announcements ([#30885](https://github.com/ionic-team/ionic-framework/issues/30885)) ([12ede4b](https://github.com/ionic-team/ionic-framework/commit/12ede4b79c8d5cffc2b014c7c8a0d2ef1d3bf90d))
|
||||
* **modal:** dismiss top-most overlay when multiple IDs match ([#30883](https://github.com/ionic-team/ionic-framework/issues/30883)) ([3b60a1d](https://github.com/ionic-team/ionic-framework/commit/3b60a1d68a1df1606ffee0bde7db7a206bac404a)), closes [#30030](https://github.com/ionic-team/ionic-framework/issues/30030)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.14](https://github.com/ionic-team/ionic-framework/compare/v8.7.13...v8.7.14) (2025-12-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tabs:** select correct tab when routes have similar prefixes ([#30863](https://github.com/ionic-team/ionic-framework/issues/30863)) ([03fb422](https://github.com/ionic-team/ionic-framework/commit/03fb422bfa775e3e9dd695ea1857fa88d4245ecd)), closes [#30448](https://github.com/ionic-team/ionic-framework/issues/30448)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.13](https://github.com/ionic-team/ionic-framework/compare/v8.7.12...v8.7.13) (2025-12-13)
|
||||
|
||||
**Note:** Version bump only for package ionic-framework
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.12](https://github.com/ionic-team/ionic-framework/compare/v8.7.11...v8.7.12) (2025-12-10)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,60 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [8.7.17](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.17) (2026-01-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **input:** prevent Android TalkBack from focusing label separately ([#30895](https://github.com/ionic-team/ionic-framework/issues/30895)) ([ab733b7](https://github.com/ionic-team/ionic-framework/commit/ab733b71dd355d9486757f219fe09acaefeeefcc))
|
||||
* **input:** prevent placeholder from overlapping start slot during scroll assist ([#30896](https://github.com/ionic-team/ionic-framework/issues/30896)) ([3b3318d](https://github.com/ionic-team/ionic-framework/commit/3b3318da513b199128f3822bd8226797cd118b0f))
|
||||
* **tab-bar:** prevent keyboard controller memory leak on rapid mount/unmount ([#30906](https://github.com/ionic-team/ionic-framework/issues/30906)) ([f99d000](https://github.com/ionic-team/ionic-framework/commit/f99d0007a8ffc9c7d3d2636e912c37c12112b21d))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.16](https://github.com/ionic-team/ionic-framework/compare/v8.7.15...v8.7.16) (2025-12-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **modal:** prevent card modal animation on viewport resize when modal is closed ([#30894](https://github.com/ionic-team/ionic-framework/issues/30894)) ([e5634d4](https://github.com/ionic-team/ionic-framework/commit/e5634d45ee5fd32715f6e6b75e0448f74ee1f8f2)), closes [#30679](https://github.com/ionic-team/ionic-framework/issues/30679)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.15](https://github.com/ionic-team/ionic-framework/compare/v8.7.14...v8.7.15) (2025-12-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** use Capacitor safe-area CSS variables on older WebViews ([#30865](https://github.com/ionic-team/ionic-framework/issues/30865)) ([8573bf8](https://github.com/ionic-team/ionic-framework/commit/8573bf8083f75eda13c954a56731a6aac8ca5724))
|
||||
* **header:** show iOS condense header when app is in MD mode ([#30690](https://github.com/ionic-team/ionic-framework/issues/30690)) ([f83b000](https://github.com/ionic-team/ionic-framework/commit/f83b0005309400d674e43c497bdffbcb9d2c4d94)), closes [#29929](https://github.com/ionic-team/ionic-framework/issues/29929)
|
||||
* **input-password-toggle:** improve screen reader announcements ([#30885](https://github.com/ionic-team/ionic-framework/issues/30885)) ([12ede4b](https://github.com/ionic-team/ionic-framework/commit/12ede4b79c8d5cffc2b014c7c8a0d2ef1d3bf90d))
|
||||
* **modal:** dismiss top-most overlay when multiple IDs match ([#30883](https://github.com/ionic-team/ionic-framework/issues/30883)) ([3b60a1d](https://github.com/ionic-team/ionic-framework/commit/3b60a1d68a1df1606ffee0bde7db7a206bac404a)), closes [#30030](https://github.com/ionic-team/ionic-framework/issues/30030)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.14](https://github.com/ionic-team/ionic-framework/compare/v8.7.13...v8.7.14) (2025-12-17)
|
||||
|
||||
**Note:** Version bump only for package @ionic/core
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.13](https://github.com/ionic-team/ionic-framework/compare/v8.7.12...v8.7.13) (2025-12-13)
|
||||
|
||||
**Note:** Version bump only for package @ionic/core
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [8.7.12](https://github.com/ionic-team/ionic-framework/compare/v8.7.11...v8.7.12) (2025-12-10)
|
||||
|
||||
|
||||
|
||||
59
core/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@ionic/core",
|
||||
"version": "8.7.12",
|
||||
"version": "8.7.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@ionic/core",
|
||||
"version": "8.7.12",
|
||||
"version": "8.7.17",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stencil/core": "4.38.0",
|
||||
@@ -15,11 +15,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.0",
|
||||
"@capacitor/core": "^7.0.0",
|
||||
"@capacitor/haptics": "^7.0.0",
|
||||
"@capacitor/keyboard": "^7.0.0",
|
||||
"@capacitor/status-bar": "^7.0.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@capacitor/haptics": "^8.0.0",
|
||||
"@capacitor/keyboard": "^8.0.0",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@ionic/eslint-config": "^0.3.0",
|
||||
"@ionic/prettier-config": "^2.0.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
@@ -50,6 +49,9 @@
|
||||
"serve": "^14.0.1",
|
||||
"stylelint": "^13.13.1",
|
||||
"stylelint-order": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"custom-rules": {
|
||||
@@ -626,7 +628,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@capacitor/core": {
|
||||
"version": "7.4.4",
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.0.2.tgz",
|
||||
"integrity": "sha512-EXZfxkL6GFJS2cb7TIBR7RiHA5iz6ufDcl1VmUpI2pga3lJ5Ck2+iqbx7N+osL3XYem9ad4XCidJEMm64DX6UQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -635,46 +639,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/haptics": {
|
||||
"version": "7.0.2",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.0.tgz",
|
||||
"integrity": "sha512-DY1IUOjke1T4ITl7mFHQIKCaJJyHYAYRYHG9bVApU7PDOZiMVGMp48Yjzdqjya+wv/AHS5mDabSTUmhJ5uDvBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/keyboard": {
|
||||
"version": "7.0.3",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.0.tgz",
|
||||
"integrity": "sha512-ycPW6iQyFwzDK95jihesj5EGiyyGSfbBqNek11iNp9tBOB7zDeYkUA2S/vPpOETt3dhP6pWr7a9gNVGuEfj11g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/status-bar": {
|
||||
"version": "7.0.3",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.0.tgz",
|
||||
"integrity": "sha512-aIj3bc7z8lfPgOen8HlrBrkfnxpFnh21OCx6jCUx4Mvv+B6eEkUQ49b32DOddgVfr+igRHLX2SYi7duqIsNDXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/core": {
|
||||
"version": "0.5.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picocolors": "^1.0.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts": {
|
||||
"version": "0.11.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/core": "0.5.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
@@ -9823,4 +9814,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@ionic/core",
|
||||
"version": "8.7.12",
|
||||
"version": "8.7.17",
|
||||
"description": "Base components for Ionic",
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
"node": ">= 16"
|
||||
},
|
||||
"keywords": [
|
||||
"ionic",
|
||||
@@ -40,11 +40,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.0",
|
||||
"@capacitor/core": "^7.0.0",
|
||||
"@capacitor/haptics": "^7.0.0",
|
||||
"@capacitor/keyboard": "^7.0.0",
|
||||
"@capacitor/status-bar": "^7.0.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@capacitor/haptics": "^8.0.0",
|
||||
"@capacitor/keyboard": "^8.0.0",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@ionic/eslint-config": "^0.3.0",
|
||||
"@ionic/prettier-config": "^2.0.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
@@ -104,8 +103,7 @@
|
||||
"docker.build": "docker build -t ionic-playwright .",
|
||||
"test.e2e.docker": "npm run docker.build && node ./scripts/docker.mjs",
|
||||
"test.e2e.docker.update-snapshots": "npm run test.e2e.docker -- --update-snapshots='changed'",
|
||||
"test.e2e.docker.ci": "npm run docker.build && CI=true node ./scripts/docker.mjs",
|
||||
"test.e2e.script": "node scripts/testing/e2e-script.mjs"
|
||||
"test.e2e.docker.ci": "npm run docker.build && CI=true node ./scripts/docker.mjs"
|
||||
},
|
||||
"author": "Ionic Team",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
// The purpose of this script is to provide a way run the E2E tests
|
||||
// without having the developer to manually run multiple commands based
|
||||
// on the desired end result.
|
||||
// E.g. update the local ground truths for a specific component or
|
||||
// open the Playwright report after running the E2E tests.
|
||||
|
||||
import {
|
||||
intro,
|
||||
outro,
|
||||
confirm,
|
||||
spinner,
|
||||
isCancel,
|
||||
cancel,
|
||||
text,
|
||||
log,
|
||||
} from '@clack/prompts';
|
||||
import { exec, spawn } from 'child_process';
|
||||
import fs from 'node:fs';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import util from 'node:util';
|
||||
import color from 'picocolors';
|
||||
|
||||
async function main() {
|
||||
const execAsync = util.promisify(exec);
|
||||
const cleanUpFiles = async () => {
|
||||
// Clean up the local ground truths.
|
||||
const cleanUp = spinner();
|
||||
|
||||
// Inform the user that the local ground truths are being cleaned up.
|
||||
cleanUp.start('Restoring local ground truths');
|
||||
|
||||
// Reset the local ground truths.
|
||||
await execAsync('git reset -- src/**/*-linux.png').catch((error) => {
|
||||
cleanUp.stop('Failed to reset local ground truths');
|
||||
console.error(error);
|
||||
return process.exit(0);
|
||||
});
|
||||
|
||||
// Restore the local ground truths.
|
||||
await execAsync('git restore -- src/**/*-linux.png').catch((error) => {
|
||||
cleanUp.stop('Failed to restore local ground truths');
|
||||
console.error(error);
|
||||
return process.exit(0);
|
||||
});
|
||||
|
||||
// Inform the user that the local ground truths have been cleaned up.
|
||||
cleanUp.stop('Local ground truths have been restored to their original state in order to avoid committing them.');
|
||||
};
|
||||
|
||||
intro(color.inverse(' Update Local Ground Truths'));
|
||||
|
||||
// Ask user for the component name they want to test.
|
||||
const componentValue = await text({
|
||||
message: 'Enter the component or path you want to test (e.g. chip, src/components/chip)',
|
||||
placeholder: 'Empty for all components',
|
||||
});
|
||||
|
||||
// User cancelled the operation with `Ctrl+C` or `CMD+C`.
|
||||
if (isCancel(componentValue)) {
|
||||
cancel('Operation cancelled');
|
||||
return process.exit(0);
|
||||
}
|
||||
|
||||
// Ask user if they want to update their local ground truths.
|
||||
const shouldUpdateTruths = await confirm({
|
||||
message: 'Do you want to update your local ground truths?',
|
||||
});
|
||||
|
||||
// User cancelled the operation with `Ctrl+C` or `CMD+C`.
|
||||
if (isCancel(shouldUpdateTruths)) {
|
||||
cancel('Operation cancelled');
|
||||
return process.exit(0);
|
||||
}
|
||||
|
||||
if (shouldUpdateTruths) {
|
||||
const defaultBaseBranch = 'main';
|
||||
|
||||
// Ask user for the base branch.
|
||||
let baseBranch = await text({
|
||||
message: 'Enter the base branch name:',
|
||||
placeholder: `default: ${defaultBaseBranch}`,
|
||||
})
|
||||
|
||||
// User cancelled the operation with `Ctrl+C` or `CMD+C`.
|
||||
if (isCancel(baseBranch)) {
|
||||
cancel('Operation cancelled');
|
||||
return process.exit(0);
|
||||
}
|
||||
|
||||
// User didn't provide a base branch.
|
||||
if (!baseBranch) {
|
||||
baseBranch = defaultBaseBranch;
|
||||
}
|
||||
|
||||
/**
|
||||
* The provided base branch needs to be fetched.
|
||||
* This ensures that the local base branch is up-to-date with the
|
||||
* remote base branch. Otherwise, there might be errors stating that
|
||||
* certain files don't exist in the local base branch.
|
||||
*/
|
||||
const fetchBaseBranch = spinner();
|
||||
|
||||
// Inform the user that the base branch is being fetched.
|
||||
fetchBaseBranch.start(`Fetching "${baseBranch}" to have the latest changes`);
|
||||
|
||||
// Fetch the base branch.
|
||||
await execAsync(`git fetch origin ${baseBranch}`).catch((error) => {
|
||||
fetchBaseBranch.stop(`Failed to fetch "${baseBranch}"`);
|
||||
console.error(error);
|
||||
return process.exit(0);
|
||||
});
|
||||
|
||||
// Inform the user that the base branch has been fetched.
|
||||
fetchBaseBranch.stop(`Fetched "${baseBranch}"`);
|
||||
|
||||
|
||||
const updateGroundTruth = spinner();
|
||||
|
||||
// Inform the user that the local ground truths are being updated.
|
||||
updateGroundTruth.start('Updating local ground truths');
|
||||
|
||||
// Check if user provided an existing file or directory.
|
||||
const isValidLocation = fs.existsSync(componentValue);
|
||||
|
||||
// User provided an existing file or directory.
|
||||
if (isValidLocation) {
|
||||
const stats = fs.statSync(componentValue);
|
||||
|
||||
// User provided a file as the component.
|
||||
// ex: `componentValue` = `src/components/chip/test/basic/chip.e2e.ts`
|
||||
if (stats.isFile()) {
|
||||
// Update the local ground truths for the provided path.
|
||||
await execAsync(`git checkout origin/${baseBranch} -- ${componentValue}-snapshots/*-linux.png`).catch((error) => {
|
||||
updateGroundTruth.stop('Failed to update local ground truths');
|
||||
console.error(error);
|
||||
return process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// User provided a directory as the component.
|
||||
// ex: `componentValue` = `src/components/chip`
|
||||
if (stats.isDirectory()) {
|
||||
// Update the local ground truths for the provided directory.
|
||||
await execAsync(`git checkout origin/${baseBranch} -- ${componentValue}/test/*/*.e2e.ts-snapshots/*-linux.png`).catch((error) => {
|
||||
updateGroundTruth.stop('Failed to update local ground truths');
|
||||
console.error(error);
|
||||
return process.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
// User provided a component name as the component.
|
||||
// ex: `componentValue` = `chip`
|
||||
else if (componentValue) {
|
||||
// Update the local ground truths for the provided component.
|
||||
await execAsync(`git checkout origin/${baseBranch} -- src/components/${componentValue}/test/*/${componentValue}.e2e.ts-snapshots/*-linux.png`).catch((error) => {
|
||||
updateGroundTruth.stop('Failed to update local ground truths');
|
||||
console.error(error);
|
||||
return process.exit(0);
|
||||
});
|
||||
}
|
||||
// User provided an empty string.
|
||||
else {
|
||||
// Update the local ground truths for all components.
|
||||
await execAsync(`git checkout origin/${baseBranch} -- src/components/*/test/*/*.e2e.ts-snapshots/*-linux.png`).catch((error) => {
|
||||
updateGroundTruth.stop('Failed to update local ground truths');
|
||||
console.error(error);
|
||||
return process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Inform the user that the local ground truths have been updated.
|
||||
updateGroundTruth.stop('Updated local ground truths');
|
||||
}
|
||||
|
||||
const buildCore = spinner();
|
||||
|
||||
// Inform the user that the core is being built.
|
||||
buildCore.start('Building core');
|
||||
|
||||
/**
|
||||
* Build core
|
||||
* Otherwise, the uncommitted changes will not be reflected in the tests because:
|
||||
* - popping the stash doesn't trigger a re-render even if `npm start` is running
|
||||
* - app is not running the `npm start` command
|
||||
*/
|
||||
await execAsync('npm run build').catch((error) => {
|
||||
// Clean up the local ground truths.
|
||||
cleanUpFiles();
|
||||
|
||||
buildCore.stop('Failed to build core');
|
||||
console.error(error);
|
||||
return process.exit(0);
|
||||
});
|
||||
|
||||
buildCore.stop('Built core');
|
||||
|
||||
const runE2ETests = spinner();
|
||||
|
||||
// Inform the user that the E2E tests are being run.
|
||||
runE2ETests.start('Running E2E tests');
|
||||
|
||||
// User provided a component value.
|
||||
if (componentValue) {
|
||||
await execAsync(`npm run test.e2e.docker.ci ${componentValue}`).catch((error) => {
|
||||
// Clean up the local ground truths.
|
||||
cleanUpFiles();
|
||||
|
||||
runE2ETests.stop('Failed to run E2E tests');
|
||||
console.error(error);
|
||||
return process.exit(0);
|
||||
});
|
||||
} else {
|
||||
await execAsync('npm run test.e2e.docker.ci').catch((error) => {
|
||||
// Clean up the local ground truths.
|
||||
cleanUpFiles();
|
||||
|
||||
runE2ETests.stop('Failed to run E2E tests');
|
||||
console.error(error);
|
||||
return process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
runE2ETests.stop('Ran E2E tests');
|
||||
|
||||
// Clean up the local ground truths.
|
||||
await cleanUpFiles();
|
||||
|
||||
// Ask user if they want to open the Playwright report.
|
||||
const shouldOpenReport = await confirm({
|
||||
message: 'Do you want to open the Playwright report?',
|
||||
});
|
||||
|
||||
// User cancelled the operation with `Ctrl+C` or `CMD+C`.
|
||||
if (isCancel(shouldOpenReport)) {
|
||||
cancel('Operation cancelled');
|
||||
return process.exit(0);
|
||||
}
|
||||
|
||||
// User chose to open the Playwright report.
|
||||
if (shouldOpenReport) {
|
||||
// Use spawn to display the server information and the key to quit the server.
|
||||
spawn('npx', ['playwright', 'show-report'], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} else {
|
||||
// Inform the user that the Playwright report can be opened by running the following command.
|
||||
log.info('If you change your mind, you can open the Playwright report by running the following command:');
|
||||
log.info(color.bold('npx playwright show-report'));
|
||||
}
|
||||
|
||||
if (shouldOpenReport) {
|
||||
outro("You're all set! Don't forget to quit serving the Playwright report when you're done.");
|
||||
} else {
|
||||
outro("You're all set!");
|
||||
}
|
||||
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -18,20 +18,66 @@ configs({ directions: ['ltr'] }).forEach(({ config, title, screenshot }) => {
|
||||
|
||||
await expect(page).toHaveScreenshot(screenshot(`app-${screenshotModifier}-diff`));
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/src/components/app/test/safe-area`, config);
|
||||
});
|
||||
test('should not have visual regressions with action sheet', async ({ page }) => {
|
||||
await testOverlay(page, '#show-action-sheet', 'ionActionSheetDidPresent', 'action-sheet');
|
||||
|
||||
test.describe(title('Ionic safe area variables'), () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const htmlTag = page.locator('html');
|
||||
const hasSafeAreaClass = await htmlTag.evaluate((el) => el.classList.contains('safe-area'));
|
||||
|
||||
expect(hasSafeAreaClass).toBe(true);
|
||||
});
|
||||
|
||||
test('should not have visual regressions with action sheet', async ({ page }) => {
|
||||
await testOverlay(page, '#show-action-sheet', 'ionActionSheetDidPresent', 'action-sheet');
|
||||
});
|
||||
test('should not have visual regressions with menu', async ({ page }) => {
|
||||
await testOverlay(page, '#show-menu', 'ionDidOpen', 'menu');
|
||||
});
|
||||
test('should not have visual regressions with picker', async ({ page }) => {
|
||||
await testOverlay(page, '#show-picker', 'ionPickerDidPresent', 'picker');
|
||||
});
|
||||
test('should not have visual regressions with toast', async ({ page }) => {
|
||||
await testOverlay(page, '#show-toast', 'ionToastDidPresent', 'toast');
|
||||
});
|
||||
});
|
||||
test('should not have visual regressions with menu', async ({ page }) => {
|
||||
await testOverlay(page, '#show-menu', 'ionDidOpen', 'menu');
|
||||
});
|
||||
test('should not have visual regressions with picker', async ({ page }) => {
|
||||
await testOverlay(page, '#show-picker', 'ionPickerDidPresent', 'picker');
|
||||
});
|
||||
test('should not have visual regressions with toast', async ({ page }) => {
|
||||
await testOverlay(page, '#show-toast', 'ionToastDidPresent', 'toast');
|
||||
|
||||
test.describe(title('Capacitor safe area variables'), () => {
|
||||
test('should use safe-area-inset vars when safe-area class is not defined', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
const html = document.documentElement;
|
||||
|
||||
// Remove the safe area class
|
||||
html.classList.remove('safe-area');
|
||||
|
||||
// Set the safe area inset variables
|
||||
html.style.setProperty('--safe-area-inset-top', '10px');
|
||||
html.style.setProperty('--safe-area-inset-bottom', '20px');
|
||||
html.style.setProperty('--safe-area-inset-left', '30px');
|
||||
html.style.setProperty('--safe-area-inset-right', '40px');
|
||||
});
|
||||
|
||||
const top = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--ion-safe-area-top').trim()
|
||||
);
|
||||
const bottom = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--ion-safe-area-bottom').trim()
|
||||
);
|
||||
const left = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--ion-safe-area-left').trim()
|
||||
);
|
||||
const right = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--ion-safe-area-right').trim()
|
||||
);
|
||||
|
||||
expect(top).toBe('10px');
|
||||
expect(bottom).toBe('20px');
|
||||
expect(left).toBe('30px');
|
||||
expect(right).toBe('40px');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,6 +170,7 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
|
||||
*/
|
||||
@Watch('aria-checked')
|
||||
@Watch('aria-label')
|
||||
@Watch('aria-pressed')
|
||||
onAriaChanged(newValue: string, _oldValue: string, propName: string) {
|
||||
this.inheritedAttributes = {
|
||||
...this.inheritedAttributes,
|
||||
|
||||
@@ -22,6 +22,7 @@ export class Footer implements ComponentInterface {
|
||||
private scrollEl?: HTMLElement;
|
||||
private contentScrollCallback?: () => void;
|
||||
private keyboardCtrl: KeyboardController | null = null;
|
||||
private keyboardCtrlPromise: Promise<KeyboardController> | null = null;
|
||||
|
||||
@State() private keyboardVisible = false;
|
||||
|
||||
@@ -52,7 +53,7 @@ export class Footer implements ComponentInterface {
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.keyboardCtrl = await createKeyboardController(async (keyboardOpen, waitForResize) => {
|
||||
const promise = createKeyboardController(async (keyboardOpen, waitForResize) => {
|
||||
/**
|
||||
* If the keyboard is hiding, then we need to wait
|
||||
* for the webview to resize. Otherwise, the footer
|
||||
@@ -64,11 +65,32 @@ export class Footer implements ComponentInterface {
|
||||
|
||||
this.keyboardVisible = keyboardOpen; // trigger re-render by updating state
|
||||
});
|
||||
this.keyboardCtrlPromise = promise;
|
||||
|
||||
const keyboardCtrl = await promise;
|
||||
|
||||
/**
|
||||
* Only assign if this is still the current promise.
|
||||
* Otherwise, a new connectedCallback has started or
|
||||
* disconnectedCallback was called, so destroy this instance.
|
||||
*/
|
||||
if (this.keyboardCtrlPromise === promise) {
|
||||
this.keyboardCtrl = keyboardCtrl;
|
||||
this.keyboardCtrlPromise = null;
|
||||
} else {
|
||||
keyboardCtrl.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.keyboardCtrlPromise) {
|
||||
this.keyboardCtrlPromise.then((ctrl) => ctrl.destroy());
|
||||
this.keyboardCtrlPromise = null;
|
||||
}
|
||||
|
||||
if (this.keyboardCtrl) {
|
||||
this.keyboardCtrl.destroy();
|
||||
this.keyboardCtrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
box-shadow: $header-md-box-shadow;
|
||||
}
|
||||
|
||||
.header-collapse-condense {
|
||||
.header-md.header-collapse-condense {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
71
core/src/components/header/test/condense-modal/header.e2e.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
/**
|
||||
* This test verifies that collapsible headers with mode="ios" work correctly
|
||||
* when both iOS and MD stylesheets are loaded. The bug occurred because
|
||||
* `.header-collapse-condense { display: none }` in the MD stylesheet was not
|
||||
* scoped to `.header-md`, causing it to hide iOS condense headers when both
|
||||
* stylesheets were present.
|
||||
*/
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('header: condense with iOS mode override'), () => {
|
||||
test('should show iOS condense header when both MD and iOS styles are loaded', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/29929',
|
||||
});
|
||||
|
||||
// Include both an MD header and an iOS modal to force both stylesheets to load
|
||||
await page.setContent(
|
||||
`
|
||||
<!-- MD header to force MD stylesheet to load -->
|
||||
<ion-header mode="md" id="mdHeader">
|
||||
<ion-toolbar>
|
||||
<ion-title>MD Header</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<!-- Modal with iOS condense header -->
|
||||
<ion-modal>
|
||||
<ion-header mode="ios" id="smallTitleHeader">
|
||||
<ion-toolbar>
|
||||
<ion-title>Header</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content fullscreen="true">
|
||||
<ion-header collapse="condense" mode="ios" id="largeTitleHeader">
|
||||
<ion-toolbar>
|
||||
<ion-title size="large">Large Header</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<p>Content</p>
|
||||
</ion-content>
|
||||
</ion-modal>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const modal = page.locator('ion-modal');
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await modal.evaluate((el: HTMLIonModalElement) => el.present());
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const largeTitleHeader = modal.locator('#largeTitleHeader');
|
||||
|
||||
// The large title header should be visible, not hidden by MD styles
|
||||
await expect(largeTitleHeader).toBeVisible();
|
||||
|
||||
// Verify it has the iOS mode class
|
||||
await expect(largeTitleHeader).toHaveClass(/header-ios/);
|
||||
|
||||
// Verify it does NOT have display: none applied
|
||||
// This would fail if the MD stylesheet's unscoped .header-collapse-condense rule applies
|
||||
const display = await largeTitleHeader.evaluate((el) => {
|
||||
return window.getComputedStyle(el).display;
|
||||
});
|
||||
expect(display).not.toBe('none');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -126,9 +126,8 @@ export class InputPasswordToggle implements ComponentInterface {
|
||||
color={color}
|
||||
fill="clear"
|
||||
shape="round"
|
||||
aria-checked={isPasswordVisible ? 'true' : 'false'}
|
||||
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
||||
role="switch"
|
||||
aria-pressed={isPasswordVisible ? 'true' : 'false'}
|
||||
type="button"
|
||||
onPointerDown={(ev) => {
|
||||
/**
|
||||
|
||||
@@ -22,7 +22,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
});
|
||||
|
||||
test.describe(title('input password toggle: aria attributes'), () => {
|
||||
test('should inherit aria attributes to inner button on load', async ({ page }) => {
|
||||
test('should have correct aria attributes on load', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input label="input" type="password">
|
||||
@@ -35,9 +35,9 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
const nativeButton = page.locator('ion-input-password-toggle button');
|
||||
|
||||
await expect(nativeButton).toHaveAttribute('aria-label', 'Show password');
|
||||
await expect(nativeButton).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(nativeButton).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
test('should inherit aria attributes to inner button after toggle', async ({ page }) => {
|
||||
test('should update aria attributes after toggle', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input label="input" type="password">
|
||||
@@ -51,7 +51,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
await nativeButton.click();
|
||||
|
||||
await expect(nativeButton).toHaveAttribute('aria-label', 'Hide password');
|
||||
await expect(nativeButton).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(nativeButton).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,9 +165,13 @@
|
||||
// otherwise the .input-cover will not be rendered at all
|
||||
// The input cover is not clickable when the input is disabled
|
||||
.cloned-input {
|
||||
@include position(0, null, 0, 0);
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
// Reset height since absolute positioning with top/bottom handles sizing
|
||||
height: auto;
|
||||
max-height: none;
|
||||
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export class Input implements ComponentInterface {
|
||||
private inputId = `ion-input-${inputIds++}`;
|
||||
private helperTextId = `${this.inputId}-helper-text`;
|
||||
private errorTextId = `${this.inputId}-error-text`;
|
||||
private labelTextId = `${this.inputId}-label`;
|
||||
private inheritedAttributes: Attributes = {};
|
||||
private isComposing = false;
|
||||
private slotMutationController?: SlotMutationController;
|
||||
@@ -406,7 +407,12 @@ export class Input implements ComponentInterface {
|
||||
connectedCallback() {
|
||||
const { el } = this;
|
||||
|
||||
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
|
||||
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => {
|
||||
this.setSlottedLabelId();
|
||||
forceUpdate(this);
|
||||
});
|
||||
|
||||
this.setSlottedLabelId();
|
||||
this.notchController = createNotchController(
|
||||
el,
|
||||
() => this.notchSpacerEl,
|
||||
@@ -721,7 +727,7 @@ export class Input implements ComponentInterface {
|
||||
}
|
||||
|
||||
private renderLabel() {
|
||||
const { label } = this;
|
||||
const { label, labelTextId } = this;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -729,8 +735,17 @@ export class Input implements ComponentInterface {
|
||||
'label-text-wrapper': true,
|
||||
'label-text-wrapper-hidden': !this.hasLabel,
|
||||
}}
|
||||
// Prevents Android TalkBack from focusing the label separately.
|
||||
// The input remains labelled via aria-labelledby.
|
||||
aria-hidden={this.hasLabel ? 'true' : null}
|
||||
>
|
||||
{label === undefined ? <slot name="label"></slot> : <div class="label-text">{label}</div>}
|
||||
{label === undefined ? (
|
||||
<slot name="label"></slot>
|
||||
) : (
|
||||
<div class="label-text" id={labelTextId}>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -743,6 +758,33 @@ export class Input implements ComponentInterface {
|
||||
return this.el.querySelector('[slot="label"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the slotted label element has an ID for aria-labelledby.
|
||||
* If no ID exists, we assign one using our generated labelTextId.
|
||||
*/
|
||||
private setSlottedLabelId() {
|
||||
const slottedLabel = this.labelSlot;
|
||||
if (slottedLabel && !slottedLabel.id) {
|
||||
slottedLabel.id = this.labelTextId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ID to use for aria-labelledby on the native input,
|
||||
* or undefined if aria-label is explicitly set (to avoid conflicts).
|
||||
*/
|
||||
private getLabelledById(): string | undefined {
|
||||
if (this.inheritedAttributes['aria-label']) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.label !== undefined) {
|
||||
return this.labelTextId;
|
||||
}
|
||||
|
||||
return this.labelSlot?.id || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if label content is provided
|
||||
* either by a prop or a content. If you want
|
||||
@@ -898,6 +940,7 @@ export class Input implements ComponentInterface {
|
||||
onCompositionend={this.onCompositionEnd}
|
||||
aria-describedby={this.getHintTextID()}
|
||||
aria-invalid={this.isInvalid ? 'true' : undefined}
|
||||
aria-labelledby={this.getLabelledById()}
|
||||
{...this.inheritedAttributes}
|
||||
/>
|
||||
{this.clearInput && !readonly && !disabled && (
|
||||
|
||||
@@ -57,6 +57,104 @@ configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title,
|
||||
});
|
||||
});
|
||||
|
||||
configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('input: label a11y for Android TalkBack'), () => {
|
||||
/**
|
||||
* Android TalkBack treats visible text elements as separate focusable items.
|
||||
* These tests verify that the label is hidden from a11y tree (aria-hidden)
|
||||
* while remaining associated with the input via aria-labelledby.
|
||||
*/
|
||||
test('label text wrapper should be hidden from accessibility tree when using label prop', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input label="Email" value="test@example.com"></ion-input>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const labelTextWrapper = page.locator('ion-input .label-text-wrapper');
|
||||
await expect(labelTextWrapper).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
test('label text wrapper should be hidden from accessibility tree when using label slot', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input value="test@example.com">
|
||||
<div slot="label">Email</div>
|
||||
</ion-input>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const labelTextWrapper = page.locator('ion-input .label-text-wrapper');
|
||||
await expect(labelTextWrapper).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
test('native input should have aria-labelledby pointing to label text when using label prop', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input label="Email" value="test@example.com"></ion-input>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const nativeInput = page.locator('ion-input input');
|
||||
const labelText = page.locator('ion-input .label-text');
|
||||
|
||||
const labelTextId = await labelText.getAttribute('id');
|
||||
expect(labelTextId).not.toBeNull();
|
||||
await expect(nativeInput).toHaveAttribute('aria-labelledby', labelTextId!);
|
||||
});
|
||||
|
||||
test('native input should have aria-labelledby pointing to slotted label when using label slot', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input value="test@example.com">
|
||||
<div slot="label">Email</div>
|
||||
</ion-input>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const nativeInput = page.locator('ion-input input');
|
||||
const slottedLabel = page.locator('ion-input [slot="label"]');
|
||||
|
||||
const slottedLabelId = await slottedLabel.getAttribute('id');
|
||||
expect(slottedLabelId).not.toBeNull();
|
||||
await expect(nativeInput).toHaveAttribute('aria-labelledby', slottedLabelId!);
|
||||
});
|
||||
|
||||
test('should not add aria-labelledby when aria-label is provided on host', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input aria-label="Custom Label" value="test@example.com"></ion-input>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const nativeInput = page.locator('ion-input input');
|
||||
|
||||
await expect(nativeInput).toHaveAttribute('aria-label', 'Custom Label');
|
||||
await expect(nativeInput).not.toHaveAttribute('aria-labelledby');
|
||||
});
|
||||
|
||||
test('should not add aria-hidden to label wrapper when no label is present', async ({ page }) => {
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-input aria-label="Hidden Label" value="test@example.com"></ion-input>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const labelTextWrapper = page.locator('ion-input .label-text-wrapper');
|
||||
|
||||
await expect(labelTextWrapper).not.toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => {
|
||||
test.describe(title('input: font scaling'), () => {
|
||||
test('should scale text on larger font sizes', async ({ page }) => {
|
||||
|
||||
@@ -52,7 +52,8 @@ export const createSheetGesture = (
|
||||
expandToScroll: boolean,
|
||||
getCurrentBreakpoint: () => number,
|
||||
onDismiss: () => void,
|
||||
onBreakpointChange: (breakpoint: number) => void
|
||||
onBreakpointChange: (breakpoint: number) => void,
|
||||
onGestureMove?: () => void
|
||||
) => {
|
||||
// Defaults for the sheet swipe animation
|
||||
const defaultBackdrop = [
|
||||
@@ -423,6 +424,9 @@ export const createSheetGesture = (
|
||||
|
||||
offset = clamp(0.0001, processedStep, maxStep);
|
||||
animation.progressStep(offset);
|
||||
|
||||
// Notify modal of position change for safe-area updates
|
||||
onGestureMove?.();
|
||||
};
|
||||
|
||||
const onEnd = (detail: GestureDetail) => {
|
||||
|
||||
@@ -20,7 +20,8 @@ export const createSwipeToCloseGesture = (
|
||||
el: HTMLIonModalElement,
|
||||
animation: Animation,
|
||||
statusBarStyle: StatusBarStyle,
|
||||
onDismiss: () => void
|
||||
onDismiss: () => void,
|
||||
onGestureMove?: () => void
|
||||
) => {
|
||||
/**
|
||||
* The step value at which a card modal
|
||||
@@ -199,6 +200,9 @@ export const createSwipeToCloseGesture = (
|
||||
|
||||
animation.progressStep(clampedStep);
|
||||
|
||||
// Notify modal of position change for safe-area updates
|
||||
onGestureMove?.();
|
||||
|
||||
/**
|
||||
* When swiping down half way, the status bar style
|
||||
* should be reset to its default value.
|
||||
|
||||
@@ -94,10 +94,6 @@ ion-backdrop {
|
||||
:host {
|
||||
--width: #{$modal-inset-width};
|
||||
--height: #{$modal-inset-height-small};
|
||||
--ion-safe-area-top: 0px;
|
||||
--ion-safe-area-bottom: 0px;
|
||||
--ion-safe-area-right: 0px;
|
||||
--ion-safe-area-left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
||||
import { Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h, writeTask } from '@stencil/core';
|
||||
import { win } from '@utils/browser';
|
||||
import { findIonContent, printIonContentErrorMsg } from '@utils/content';
|
||||
import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework-delegate';
|
||||
import { raf, inheritAttributes, hasLazyBuild, getElementRoot } from '@utils/helpers';
|
||||
@@ -74,6 +75,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
@State() private isSheetModal = false;
|
||||
private currentBreakpoint?: number;
|
||||
private wrapperEl?: HTMLElement;
|
||||
private shadowEl?: HTMLElement;
|
||||
private backdropEl?: HTMLIonBackdropElement;
|
||||
private dragHandleEl?: HTMLButtonElement;
|
||||
private sortedBreakpoints?: number[];
|
||||
@@ -98,10 +100,18 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
|
||||
// Mutation observer to watch for parent removal
|
||||
private parentRemovalObserver?: MutationObserver;
|
||||
// Watches for dynamic footer additions/removals to update safe-area padding
|
||||
private footerObserver?: MutationObserver;
|
||||
// Cached original parent from before modal is moved to body during presentation
|
||||
private cachedOriginalParent?: HTMLElement;
|
||||
// Cached ion-page ancestor for child route passthrough
|
||||
private cachedPageParent?: HTMLElement | null;
|
||||
// Whether to skip coordinate-based safe-area detection (for fullscreen phone modals)
|
||||
private skipSafeAreaCoordinateDetection = false;
|
||||
// Cached safe-area values to avoid getComputedStyle calls during gestures
|
||||
private cachedSafeAreas?: { top: number; bottom: number; left: number; right: number };
|
||||
// Track previous safe-area state to avoid redundant DOM writes
|
||||
private prevSafeAreaState = { top: false, bottom: false, left: false, right: false };
|
||||
|
||||
lastFocus?: HTMLElement;
|
||||
animation?: Animation;
|
||||
@@ -276,7 +286,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
|
||||
@Listen('resize', { target: 'window' })
|
||||
onWindowResize() {
|
||||
// Only handle resize for iOS card modals when no custom animations are provided
|
||||
// Invalidate safe-area cache on resize (device rotation may change values)
|
||||
this.cachedSafeAreas = undefined;
|
||||
this.updateSafeAreaOverrides();
|
||||
|
||||
// Only handle view transition for iOS card modals when no custom animations are provided
|
||||
if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
|
||||
return;
|
||||
}
|
||||
@@ -406,6 +420,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
this.triggerController.removeClickListener();
|
||||
this.cleanupViewTransitionListener();
|
||||
this.cleanupParentRemovalObserver();
|
||||
// Reset safe-area state to handle removal without dismiss (e.g., framework unmount)
|
||||
this.resetSafeAreaState();
|
||||
}
|
||||
|
||||
componentWillLoad() {
|
||||
@@ -592,6 +608,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
await waitForMount();
|
||||
}
|
||||
|
||||
// Predict safe-area needs based on modal configuration to avoid visual snap
|
||||
this.setInitialSafeAreaOverrides(presentingElement);
|
||||
|
||||
writeTask(() => this.el.classList.add('show-modal'));
|
||||
|
||||
const hasCardModal = presentingElement !== undefined;
|
||||
@@ -659,6 +678,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
this.initSwipeToClose();
|
||||
}
|
||||
|
||||
// Now that animation is complete, update safe-area based on actual position
|
||||
this.updateSafeAreaOverrides();
|
||||
|
||||
// Initialize view transition listener for iOS card modals
|
||||
this.initViewTransitionListener();
|
||||
|
||||
@@ -692,33 +714,39 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
|
||||
const statusBarStyle = this.statusBarStyle ?? StatusBarStyle.Default;
|
||||
|
||||
this.gesture = createSwipeToCloseGesture(el, ani, statusBarStyle, () => {
|
||||
/**
|
||||
* While the gesture animation is finishing
|
||||
* it is possible for a user to tap the backdrop.
|
||||
* This would result in the dismiss animation
|
||||
* being played again. Typically this is avoided
|
||||
* by setting `presented = false` on the overlay
|
||||
* component; however, we cannot do that here as
|
||||
* that would prevent the element from being
|
||||
* removed from the DOM.
|
||||
*/
|
||||
this.gestureAnimationDismissing = true;
|
||||
this.gesture = createSwipeToCloseGesture(
|
||||
el,
|
||||
ani,
|
||||
statusBarStyle,
|
||||
() => {
|
||||
/**
|
||||
* While the gesture animation is finishing
|
||||
* it is possible for a user to tap the backdrop.
|
||||
* This would result in the dismiss animation
|
||||
* being played again. Typically this is avoided
|
||||
* by setting `presented = false` on the overlay
|
||||
* component; however, we cannot do that here as
|
||||
* that would prevent the element from being
|
||||
* removed from the DOM.
|
||||
*/
|
||||
this.gestureAnimationDismissing = true;
|
||||
|
||||
/**
|
||||
* Reset the status bar style as the dismiss animation
|
||||
* starts otherwise the status bar will be the wrong
|
||||
* color for the duration of the dismiss animation.
|
||||
* The dismiss method does this as well, but
|
||||
* in this case it's only called once the animation
|
||||
* has finished.
|
||||
*/
|
||||
setCardStatusBarDefault(this.statusBarStyle);
|
||||
this.animation!.onFinish(async () => {
|
||||
await this.dismiss(undefined, GESTURE);
|
||||
this.gestureAnimationDismissing = false;
|
||||
});
|
||||
});
|
||||
/**
|
||||
* Reset the status bar style as the dismiss animation
|
||||
* starts otherwise the status bar will be the wrong
|
||||
* color for the duration of the dismiss animation.
|
||||
* The dismiss method does this as well, but
|
||||
* in this case it's only called once the animation
|
||||
* has finished.
|
||||
*/
|
||||
setCardStatusBarDefault(this.statusBarStyle);
|
||||
this.animation!.onFinish(async () => {
|
||||
await this.dismiss(undefined, GESTURE);
|
||||
this.gestureAnimationDismissing = false;
|
||||
});
|
||||
},
|
||||
() => this.updateSafeAreaOverrides()
|
||||
);
|
||||
this.gesture.enable(true);
|
||||
}
|
||||
|
||||
@@ -755,7 +783,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
this.currentBreakpoint = breakpoint;
|
||||
this.ionBreakpointDidChange.emit({ breakpoint });
|
||||
}
|
||||
}
|
||||
this.updateSafeAreaOverrides();
|
||||
},
|
||||
() => this.updateSafeAreaOverrides()
|
||||
);
|
||||
|
||||
this.gesture = gesture;
|
||||
@@ -849,6 +879,212 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
this.cachedPageParent = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets initial safe-area overrides based on modal configuration before
|
||||
* the modal becomes visible. This predicts whether the modal will touch
|
||||
* screen edges to avoid a visual snap after animation completes.
|
||||
*/
|
||||
private setInitialSafeAreaOverrides(presentingElement: HTMLElement | undefined) {
|
||||
const style = this.el.style;
|
||||
const mode = getIonMode(this);
|
||||
const isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined;
|
||||
// Card modals only exist in iOS mode - in MD mode, presentingElement is ignored
|
||||
const isCardModal = presentingElement !== undefined && mode === 'ios';
|
||||
const isTablet = window.innerWidth >= 768;
|
||||
|
||||
// Sheet modals always touch bottom edge, never top/left/right
|
||||
if (isSheetModal) {
|
||||
style.setProperty('--ion-safe-area-top', '0px');
|
||||
style.setProperty('--ion-safe-area-left', '0px');
|
||||
style.setProperty('--ion-safe-area-right', '0px');
|
||||
return;
|
||||
}
|
||||
|
||||
// Card modals have rounded top corners
|
||||
if (isCardModal) {
|
||||
style.setProperty('--ion-safe-area-top', '0px');
|
||||
if (isTablet) {
|
||||
// On tablets, card modals are inset from all edges
|
||||
this.zeroAllSafeAreas();
|
||||
} else {
|
||||
// On phones, card modals still extend to the bottom edge
|
||||
style.setProperty('--ion-safe-area-left', '0px');
|
||||
style.setProperty('--ion-safe-area-right', '0px');
|
||||
this.applyFullscreenSafeArea();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if modal is fullscreen via CSS custom properties
|
||||
// This applies to both phone and tablet sizes - custom modals may have
|
||||
// non-fullscreen dimensions even on phones (e.g., --height: 70%)
|
||||
const computedStyle = getComputedStyle(this.el);
|
||||
const width = computedStyle.getPropertyValue('--width').trim();
|
||||
const height = computedStyle.getPropertyValue('--height').trim();
|
||||
const isFullscreen = width === '100%' && height === '100%';
|
||||
|
||||
if (isFullscreen) {
|
||||
this.applyFullscreenSafeArea();
|
||||
} else if (isTablet) {
|
||||
// Centered dialog on tablet doesn't touch edges
|
||||
this.zeroAllSafeAreas();
|
||||
} else {
|
||||
// Non-fullscreen modal on phone - use coordinate-based detection
|
||||
// to determine which edges it touches (e.g., bottom-aligned custom modals)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies safe-area handling for fullscreen modals.
|
||||
* Adds wrapper padding when no footer is present to prevent
|
||||
* content from overlapping system navigation areas.
|
||||
*/
|
||||
private applyFullscreenSafeArea() {
|
||||
this.skipSafeAreaCoordinateDetection = true;
|
||||
this.updateFooterPadding();
|
||||
|
||||
// Watch for dynamic footer additions/removals (e.g., async data loading)
|
||||
// Use subtree:true to support wrapped footers in framework components
|
||||
// (e.g., <my-footer><ion-footer>...</ion-footer></my-footer>)
|
||||
if (!this.footerObserver && win !== undefined && 'MutationObserver' in win) {
|
||||
this.footerObserver = new MutationObserver(() => this.updateFooterPadding());
|
||||
this.footerObserver.observe(this.el, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates wrapper and shadow padding based on footer presence.
|
||||
* Called initially and when footer is dynamically added/removed.
|
||||
* Both elements must be styled identically to prevent visual mismatches.
|
||||
*/
|
||||
private updateFooterPadding() {
|
||||
if (!this.wrapperEl) return;
|
||||
|
||||
const hasFooter = this.el.querySelector('ion-footer') !== null;
|
||||
// Apply to both wrapper and shadow to keep them in sync
|
||||
const elements = [this.wrapperEl, this.shadowEl].filter(Boolean) as HTMLElement[];
|
||||
|
||||
if (hasFooter) {
|
||||
elements.forEach((el) => {
|
||||
el.style.removeProperty('padding-bottom');
|
||||
el.style.removeProperty('box-sizing');
|
||||
});
|
||||
} else {
|
||||
elements.forEach((el) => {
|
||||
el.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
|
||||
el.style.setProperty('box-sizing', 'border-box');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets all safe-area CSS variables to 0px for modals that
|
||||
* don't touch screen edges.
|
||||
*/
|
||||
private zeroAllSafeAreas() {
|
||||
const style = this.el.style;
|
||||
style.setProperty('--ion-safe-area-top', '0px');
|
||||
style.setProperty('--ion-safe-area-bottom', '0px');
|
||||
style.setProperty('--ion-safe-area-left', '0px');
|
||||
style.setProperty('--ion-safe-area-right', '0px');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all safe-area related state and styles.
|
||||
* Called during dismiss and disconnectedCallback to ensure clean state
|
||||
* for re-presentation of inline modals.
|
||||
*/
|
||||
private resetSafeAreaState() {
|
||||
this.skipSafeAreaCoordinateDetection = false;
|
||||
this.cachedSafeAreas = undefined;
|
||||
this.prevSafeAreaState = { top: false, bottom: false, left: false, right: false };
|
||||
this.footerObserver?.disconnect();
|
||||
this.footerObserver = undefined;
|
||||
|
||||
// Clear wrapper and shadow styles that may have been set for safe-area handling
|
||||
[this.wrapperEl, this.shadowEl].forEach((el) => {
|
||||
if (el) {
|
||||
el.style.removeProperty('padding-bottom');
|
||||
el.style.removeProperty('box-sizing');
|
||||
}
|
||||
});
|
||||
|
||||
// Clear safe-area CSS variable overrides
|
||||
const style = this.el.style;
|
||||
style.removeProperty('--ion-safe-area-top');
|
||||
style.removeProperty('--ion-safe-area-bottom');
|
||||
style.removeProperty('--ion-safe-area-left');
|
||||
style.removeProperty('--ion-safe-area-right');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the root safe-area values from the document element.
|
||||
* Uses cached values during gestures to avoid getComputedStyle calls.
|
||||
*/
|
||||
private getSafeAreaValues(): { top: number; bottom: number; left: number; right: number } {
|
||||
if (!this.cachedSafeAreas) {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
this.cachedSafeAreas = {
|
||||
top: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-top')) || 0,
|
||||
bottom: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-bottom')) || 0,
|
||||
left: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-left')) || 0,
|
||||
right: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-right')) || 0,
|
||||
};
|
||||
}
|
||||
return this.cachedSafeAreas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates safe-area CSS variable overrides based on whether the modal
|
||||
* extends into each safe-area region. Called after animation
|
||||
* and during gestures to handle dynamic position changes.
|
||||
*
|
||||
* Optimized to avoid redundant DOM writes by tracking previous state.
|
||||
*/
|
||||
private updateSafeAreaOverrides() {
|
||||
if (this.skipSafeAreaCoordinateDetection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = this.wrapperEl;
|
||||
if (!wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const safeAreas = this.getSafeAreaValues();
|
||||
|
||||
const extendsIntoTop = rect.top < safeAreas.top;
|
||||
const extendsIntoBottom = rect.bottom > window.innerHeight - safeAreas.bottom;
|
||||
const extendsIntoLeft = rect.left < safeAreas.left;
|
||||
const extendsIntoRight = rect.right > window.innerWidth - safeAreas.right;
|
||||
|
||||
// Only update DOM when state actually changes
|
||||
const prev = this.prevSafeAreaState;
|
||||
const style = this.el.style;
|
||||
|
||||
if (extendsIntoTop !== prev.top) {
|
||||
extendsIntoTop ? style.removeProperty('--ion-safe-area-top') : style.setProperty('--ion-safe-area-top', '0px');
|
||||
prev.top = extendsIntoTop;
|
||||
}
|
||||
if (extendsIntoBottom !== prev.bottom) {
|
||||
extendsIntoBottom
|
||||
? style.removeProperty('--ion-safe-area-bottom')
|
||||
: style.setProperty('--ion-safe-area-bottom', '0px');
|
||||
prev.bottom = extendsIntoBottom;
|
||||
}
|
||||
if (extendsIntoLeft !== prev.left) {
|
||||
extendsIntoLeft ? style.removeProperty('--ion-safe-area-left') : style.setProperty('--ion-safe-area-left', '0px');
|
||||
prev.left = extendsIntoLeft;
|
||||
}
|
||||
if (extendsIntoRight !== prev.right) {
|
||||
extendsIntoRight
|
||||
? style.removeProperty('--ion-safe-area-right')
|
||||
: style.setProperty('--ion-safe-area-right', '0px');
|
||||
prev.right = extendsIntoRight;
|
||||
}
|
||||
}
|
||||
|
||||
private sheetOnDismiss() {
|
||||
/**
|
||||
* While the gesture animation is finishing
|
||||
@@ -961,6 +1197,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
}
|
||||
this.currentBreakpoint = undefined;
|
||||
this.animation = undefined;
|
||||
// Reset safe-area state for potential re-presentation
|
||||
this.resetSafeAreaState();
|
||||
|
||||
unlock();
|
||||
|
||||
@@ -1116,6 +1354,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
}
|
||||
|
||||
private handleViewTransition() {
|
||||
// Only run view transitions when the modal is presented
|
||||
if (!this.presented) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPortrait = window.innerWidth < 768;
|
||||
|
||||
// Only transition if view state actually changed
|
||||
@@ -1380,7 +1623,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
part="backdrop"
|
||||
/>
|
||||
|
||||
{mode === 'ios' && <div class="modal-shadow"></div>}
|
||||
{mode === 'ios' && <div class="modal-shadow" ref={(el) => (this.shadowEl = el)}></div>}
|
||||
|
||||
<div
|
||||
/*
|
||||
|
||||
176
core/src/components/modal/test/card-viewport-resize/modal.e2e.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('card modal: viewport resize'), () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Start in portrait mode (mobile)
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-app>
|
||||
<div class="ion-page" id="main-page">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Card Viewport Resize Test</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<p>This page tests that viewport resize does not trigger card modal animation when modal is closed.</p>
|
||||
<ion-button id="open-modal">Open Card Modal</ion-button>
|
||||
<ion-modal id="card-modal">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Card Modal</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button id="close-modal">Close</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<p>Modal content</p>
|
||||
</ion-content>
|
||||
</ion-modal>
|
||||
</ion-content>
|
||||
</div>
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
const modal = document.querySelector('#card-modal');
|
||||
const mainPage = document.querySelector('#main-page');
|
||||
modal.presentingElement = mainPage;
|
||||
|
||||
document.querySelector('#open-modal').addEventListener('click', () => {
|
||||
modal.present();
|
||||
});
|
||||
|
||||
document.querySelector('#close-modal').addEventListener('click', () => {
|
||||
modal.dismiss();
|
||||
});
|
||||
</script>
|
||||
`,
|
||||
config
|
||||
);
|
||||
});
|
||||
|
||||
test('should not animate presenting element when viewport resizes and modal is closed', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30679',
|
||||
});
|
||||
|
||||
const mainPage = page.locator('#main-page');
|
||||
|
||||
// Verify the presenting element has no transform initially
|
||||
const initialTransform = await mainPage.evaluate((el) => {
|
||||
return window.getComputedStyle(el).transform;
|
||||
});
|
||||
expect(initialTransform).toBe('none');
|
||||
|
||||
// Resize from portrait to landscape (crossing the 768px threshold)
|
||||
await page.setViewportSize({ width: 900, height: 375 });
|
||||
|
||||
// Wait for the debounced resize handler (50ms) plus some buffer
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
// The presenting element should still have no transform
|
||||
// If the bug exists, it would have scale(0.93) or similar applied
|
||||
const afterResizeTransform = await mainPage.evaluate((el) => {
|
||||
return window.getComputedStyle(el).transform;
|
||||
});
|
||||
expect(afterResizeTransform).toBe('none');
|
||||
});
|
||||
|
||||
test('should not animate presenting element when resizing multiple times with modal closed', async ({ page }) => {
|
||||
const mainPage = page.locator('#main-page');
|
||||
|
||||
// Multiple resize cycles should not trigger the animation
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Portrait to landscape
|
||||
await page.setViewportSize({ width: 900, height: 375 });
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
let transform = await mainPage.evaluate((el) => {
|
||||
return window.getComputedStyle(el).transform;
|
||||
});
|
||||
expect(transform).toBe('none');
|
||||
|
||||
// Landscape to portrait
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
transform = await mainPage.evaluate((el) => {
|
||||
return window.getComputedStyle(el).transform;
|
||||
});
|
||||
expect(transform).toBe('none');
|
||||
}
|
||||
});
|
||||
|
||||
test('should still animate presenting element correctly when modal is open and viewport resizes', async ({
|
||||
page,
|
||||
}) => {
|
||||
const mainPage = page.locator('#main-page');
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
// Open the modal
|
||||
await page.click('#open-modal');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
// When modal is open in portrait, presenting element should be transformed
|
||||
let transform = await mainPage.evaluate((el) => {
|
||||
return window.getComputedStyle(el).transform;
|
||||
});
|
||||
// The presenting element should have a scale transform when modal is open
|
||||
expect(transform).not.toBe('none');
|
||||
|
||||
// Resize to landscape while modal is open
|
||||
await page.setViewportSize({ width: 900, height: 375 });
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
// The modal transitions correctly - in landscape mode the presenting element
|
||||
// should have different (or no) transform than portrait
|
||||
transform = await mainPage.evaluate((el) => {
|
||||
return window.getComputedStyle(el).transform;
|
||||
});
|
||||
|
||||
// Note: The exact transform depends on the landscape handling
|
||||
// The main point is that when modal IS open, the transition should work
|
||||
// This test just ensures we don't break existing functionality
|
||||
});
|
||||
|
||||
test('presenting element should return to normal after modal is dismissed', async ({ page }) => {
|
||||
const mainPage = page.locator('#main-page');
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
|
||||
|
||||
// Open the modal
|
||||
await page.click('#open-modal');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
// Close the modal
|
||||
await page.click('#close-modal');
|
||||
await ionModalDidDismiss.next();
|
||||
|
||||
// Wait for animations to complete
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// The presenting element should be back to normal
|
||||
const transform = await mainPage.evaluate((el) => {
|
||||
return window.getComputedStyle(el).transform;
|
||||
});
|
||||
expect(transform).toBe('none');
|
||||
|
||||
// Now resize the viewport - should not trigger animation
|
||||
await page.setViewportSize({ width: 900, height: 375 });
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const afterResizeTransform = await mainPage.evaluate((el) => {
|
||||
return window.getComputedStyle(el).transform;
|
||||
});
|
||||
expect(afterResizeTransform).toBe('none');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
97
core/src/components/modal/test/dismiss-behavior/index.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Modal - Dismiss Behavior</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
|
||||
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
|
||||
<script src="../../../../../scripts/testing/scripts.js"></script>
|
||||
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
|
||||
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<div class="ion-page">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Modal - Dismiss Behavior</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<button id="present-first-modal" onclick="presentFirstModal()">Present Modal</button>
|
||||
</ion-content>
|
||||
</div>
|
||||
</ion-app>
|
||||
|
||||
<script type="module">
|
||||
import { modalController } from '../../../../../dist/ionic/index.esm.js';
|
||||
|
||||
window.modalController = modalController;
|
||||
|
||||
const sharedId = 'shared-modal-id';
|
||||
const maxModals = 5;
|
||||
let modalCount = 0;
|
||||
|
||||
function createModalComponent(modalNumber) {
|
||||
const element = document.createElement('div');
|
||||
const canPresentNext = modalNumber < maxModals;
|
||||
const presentNextButton = canPresentNext
|
||||
? `<ion-button id="present-next-modal" onclick="presentNextModal(${modalNumber + 1})">Present Modal ${
|
||||
modalNumber + 1
|
||||
}</ion-button>`
|
||||
: '';
|
||||
element.innerHTML = `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Modal ${modalNumber}</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<p>This is modal number ${modalNumber}</p>
|
||||
${presentNextButton}
|
||||
<ion-button class="dismiss-by-id">Dismiss By ID</ion-button>
|
||||
<ion-button class="dismiss-default">Dismiss Default</ion-button>
|
||||
</ion-content>
|
||||
`;
|
||||
return element;
|
||||
}
|
||||
|
||||
window.presentFirstModal = async () => {
|
||||
modalCount = 0;
|
||||
await presentNextModal(1);
|
||||
};
|
||||
|
||||
window.presentNextModal = async (modalNumber) => {
|
||||
if (modalNumber > maxModals) {
|
||||
return;
|
||||
}
|
||||
modalCount = Math.max(modalCount, modalNumber);
|
||||
const element = createModalComponent(modalNumber);
|
||||
const modal = await modalController.create({
|
||||
component: element,
|
||||
htmlAttributes: {
|
||||
id: sharedId,
|
||||
'data-testid': `modal-${modalNumber}`,
|
||||
},
|
||||
});
|
||||
await modal.present();
|
||||
|
||||
const dismissByIdButton = element.querySelector('ion-button.dismiss-by-id');
|
||||
dismissByIdButton.addEventListener('click', () => {
|
||||
modalController.dismiss(undefined, undefined, sharedId);
|
||||
});
|
||||
|
||||
const dismissDefaultButton = element.querySelector('ion-button.dismiss-default');
|
||||
dismissDefaultButton.addEventListener('click', () => {
|
||||
modalController.dismiss();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
58
core/src/components/modal/test/dismiss-behavior/modal.e2e.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('modal: dismiss behavior'), () => {
|
||||
test.describe(title('modal: default dismiss'), () => {
|
||||
test('should dismiss the last presented modal when the default dismiss button is clicked', async ({ page }) => {
|
||||
await page.goto('/src/components/modal/test/dismiss-behavior', config);
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
|
||||
|
||||
await page.click('#present-first-modal');
|
||||
await ionModalDidPresent.next();
|
||||
const firstModal = page.locator('ion-modal[data-testid="modal-1"]');
|
||||
await expect(firstModal).toBeVisible();
|
||||
|
||||
await page.click('#present-next-modal');
|
||||
await ionModalDidPresent.next();
|
||||
const secondModal = page.locator('ion-modal[data-testid="modal-2"]');
|
||||
await expect(secondModal).toBeVisible();
|
||||
|
||||
await page.click('ion-modal[data-testid="modal-2"] ion-button.dismiss-default');
|
||||
await ionModalDidDismiss.next();
|
||||
await secondModal.waitFor({ state: 'detached' });
|
||||
|
||||
await expect(firstModal).toBeVisible();
|
||||
await expect(secondModal).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('modal: dismiss by id'), () => {
|
||||
test('should dismiss the last presented modal when the dismiss by id button is clicked', async ({ page }) => {
|
||||
await page.goto('/src/components/modal/test/dismiss-behavior', config);
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
|
||||
|
||||
await page.click('#present-first-modal');
|
||||
await ionModalDidPresent.next();
|
||||
const firstModal = page.locator('ion-modal[data-testid="modal-1"]');
|
||||
await expect(firstModal).toBeVisible();
|
||||
|
||||
await page.click('#present-next-modal');
|
||||
await ionModalDidPresent.next();
|
||||
const secondModal = page.locator('ion-modal[data-testid="modal-2"]');
|
||||
await expect(secondModal).toBeVisible();
|
||||
|
||||
await page.click('ion-modal[data-testid="modal-2"] ion-button.dismiss-by-id');
|
||||
await ionModalDidDismiss.next();
|
||||
await secondModal.waitFor({ state: 'detached' });
|
||||
|
||||
await expect(firstModal).toBeVisible();
|
||||
await expect(secondModal).toBeHidden();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { h } from '@stencil/core';
|
||||
import { newSpecPage } from '@stencil/core/testing';
|
||||
|
||||
import { Modal } from '../modal';
|
||||
import { h } from '@stencil/core';
|
||||
|
||||
describe('modal: id', () => {
|
||||
it('modal should be assigned an incrementing id', async () => {
|
||||
@@ -52,4 +52,21 @@ describe('modal: id', () => {
|
||||
const alert = page.body.querySelector('ion-modal')!;
|
||||
expect(alert.id).toBe(id);
|
||||
});
|
||||
|
||||
it('should allow multiple modals with the same id', async () => {
|
||||
const sharedId = 'shared-modal-id';
|
||||
|
||||
const page = await newSpecPage({
|
||||
components: [Modal],
|
||||
template: () => [
|
||||
<ion-modal id={sharedId} overlayIndex={1} is-open={true}></ion-modal>,
|
||||
<ion-modal id={sharedId} overlayIndex={2} is-open={true}></ion-modal>,
|
||||
],
|
||||
});
|
||||
|
||||
const modals = page.body.querySelectorAll('ion-modal');
|
||||
expect(modals.length).toBe(2);
|
||||
expect(modals[0].id).toBe(sharedId);
|
||||
expect(modals[1].id).toBe(sharedId);
|
||||
});
|
||||
});
|
||||
|
||||
310
core/src/components/modal/test/safe-area/index.html
Normal file
@@ -0,0 +1,310 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Modal - Safe Area</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
|
||||
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
|
||||
<script src="../../../../../scripts/testing/scripts.js"></script>
|
||||
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
|
||||
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
||||
<style>
|
||||
/**
|
||||
* Simulate safe-area insets for testing.
|
||||
* Values represent combined scenarios: top/bottom from portrait devices,
|
||||
* left/right from landscape orientation or devices with side notches.
|
||||
*/
|
||||
:root {
|
||||
--ion-safe-area-top: 44px;
|
||||
--ion-safe-area-bottom: 34px;
|
||||
--ion-safe-area-left: 44px;
|
||||
--ion-safe-area-right: 44px;
|
||||
}
|
||||
|
||||
.fullscreen-modal {
|
||||
--width: 100%;
|
||||
--height: 100%;
|
||||
}
|
||||
|
||||
/* Visual indicators for safe areas */
|
||||
.safe-area-indicator {
|
||||
position: fixed;
|
||||
background: rgba(255, 0, 0, 0.2);
|
||||
pointer-events: none;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.safe-area-top {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--ion-safe-area-top);
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--ion-safe-area-bottom);
|
||||
}
|
||||
|
||||
.safe-area-left {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: var(--ion-safe-area-left);
|
||||
}
|
||||
|
||||
.safe-area-right {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: var(--ion-safe-area-right);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Visual indicators for safe areas (red overlay) -->
|
||||
<div class="safe-area-indicator safe-area-top"></div>
|
||||
<div class="safe-area-indicator safe-area-bottom"></div>
|
||||
<div class="safe-area-indicator safe-area-left"></div>
|
||||
<div class="safe-area-indicator safe-area-right"></div>
|
||||
|
||||
<ion-app>
|
||||
<div class="ion-page" id="main-page">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Modal - Safe Area</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<p>Test safe-area handling in modals. Red overlays indicate safe areas (top, bottom, left, right).</p>
|
||||
<p>
|
||||
<strong>Landscape simulation:</strong> Left and right safe areas are set to 44px to test devices with side
|
||||
notches.
|
||||
</p>
|
||||
|
||||
<ion-list>
|
||||
<ion-item-group>
|
||||
<ion-item-divider>
|
||||
<ion-label>With Footer</ion-label>
|
||||
</ion-item-divider>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Default Modal</h2>
|
||||
<p>Centered dialog on tablet - should NOT have safe-area padding</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" id="default-modal" onclick="presentDefaultModal()">Present</ion-button>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Fullscreen Modal</h2>
|
||||
<p>Full screen - footer handles safe-area</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" id="fullscreen-modal" onclick="presentFullscreenModal()">Present</ion-button>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Sheet Modal (Partial)</h2>
|
||||
<p>At 0.5 breakpoint - should have bottom safe-area only</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" id="sheet-modal-partial" onclick="presentSheetModalPartial()"
|
||||
>Present</ion-button
|
||||
>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Sheet Modal (Full)</h2>
|
||||
<p>At 1.0 breakpoint - should have bottom safe-area</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" id="sheet-modal-full" onclick="presentSheetModalFull()">Present</ion-button>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Card Modal (iOS)</h2>
|
||||
<p>Card presentation with presentingElement</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" id="card-modal" onclick="presentCardModal()">Present</ion-button>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item-divider>
|
||||
<ion-label>Without Footer (wrapper padding)</ion-label>
|
||||
</ion-item-divider>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Fullscreen Modal (no footer)</h2>
|
||||
<p>Wrapper padding should prevent content overlap</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" id="fullscreen-no-footer" onclick="presentFullscreenNoFooter()"
|
||||
>Present</ion-button
|
||||
>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Card Modal (no footer)</h2>
|
||||
<p>On phones, wrapper padding should prevent content overlap</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" id="card-modal-no-footer" onclick="presentCardModalNoFooter()"
|
||||
>Present</ion-button
|
||||
>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Default Modal (no footer)</h2>
|
||||
<p>On phones, wrapper padding should prevent content overlap</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" id="default-no-footer" onclick="presentDefaultNoFooter()">Present</ion-button>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
</div>
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
function createModalContent(title, includeFooter = true) {
|
||||
const element = document.createElement('div');
|
||||
const footerHtml = includeFooter
|
||||
? `
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-title>Footer</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
`
|
||||
: '';
|
||||
|
||||
// Create multiple items to ensure scrollable content
|
||||
const items = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `
|
||||
<ion-item>
|
||||
<ion-label>Item ${i + 1}</ion-label>
|
||||
</ion-item>
|
||||
`
|
||||
).join('');
|
||||
|
||||
element.innerHTML = `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>${title}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button class="dismiss" onclick="dismissModal()">Close</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding">
|
||||
<h1>Modal Content</h1>
|
||||
<p>This modal tests safe-area handling.</p>
|
||||
<ion-list>
|
||||
${items}
|
||||
</ion-list>
|
||||
<p class="last-item">Last item - should not overlap safe area</p>
|
||||
</ion-content>
|
||||
${footerHtml}
|
||||
`;
|
||||
return element;
|
||||
}
|
||||
|
||||
let currentModal = null;
|
||||
|
||||
async function dismissModal() {
|
||||
if (currentModal) {
|
||||
await currentModal.dismiss();
|
||||
currentModal = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function presentDefaultModal() {
|
||||
currentModal = Object.assign(document.createElement('ion-modal'), {
|
||||
component: createModalContent('Default Modal'),
|
||||
});
|
||||
document.body.appendChild(currentModal);
|
||||
await currentModal.present();
|
||||
}
|
||||
|
||||
async function presentFullscreenModal() {
|
||||
currentModal = Object.assign(document.createElement('ion-modal'), {
|
||||
component: createModalContent('Fullscreen Modal'),
|
||||
});
|
||||
currentModal.classList.add('fullscreen-modal');
|
||||
document.body.appendChild(currentModal);
|
||||
await currentModal.present();
|
||||
}
|
||||
|
||||
async function presentSheetModalPartial() {
|
||||
currentModal = Object.assign(document.createElement('ion-modal'), {
|
||||
component: createModalContent('Sheet Modal (Partial)'),
|
||||
initialBreakpoint: 0.5,
|
||||
breakpoints: [0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
document.body.appendChild(currentModal);
|
||||
await currentModal.present();
|
||||
}
|
||||
|
||||
async function presentSheetModalFull() {
|
||||
currentModal = Object.assign(document.createElement('ion-modal'), {
|
||||
component: createModalContent('Sheet Modal (Full)'),
|
||||
initialBreakpoint: 1,
|
||||
breakpoints: [0, 0.5, 1],
|
||||
});
|
||||
document.body.appendChild(currentModal);
|
||||
await currentModal.present();
|
||||
}
|
||||
|
||||
async function presentCardModal() {
|
||||
const presentingElement = document.getElementById('main-page');
|
||||
currentModal = Object.assign(document.createElement('ion-modal'), {
|
||||
component: createModalContent('Card Modal'),
|
||||
presentingElement: presentingElement,
|
||||
});
|
||||
document.body.appendChild(currentModal);
|
||||
await currentModal.present();
|
||||
}
|
||||
|
||||
// Modals without footer - test wrapper padding
|
||||
async function presentFullscreenNoFooter() {
|
||||
currentModal = Object.assign(document.createElement('ion-modal'), {
|
||||
component: createModalContent('Fullscreen (No Footer)', false),
|
||||
});
|
||||
currentModal.classList.add('fullscreen-modal');
|
||||
document.body.appendChild(currentModal);
|
||||
await currentModal.present();
|
||||
}
|
||||
|
||||
async function presentCardModalNoFooter() {
|
||||
const presentingElement = document.getElementById('main-page');
|
||||
currentModal = Object.assign(document.createElement('ion-modal'), {
|
||||
component: createModalContent('Card Modal (No Footer)', false),
|
||||
presentingElement: presentingElement,
|
||||
});
|
||||
document.body.appendChild(currentModal);
|
||||
await currentModal.present();
|
||||
}
|
||||
|
||||
async function presentDefaultNoFooter() {
|
||||
currentModal = Object.assign(document.createElement('ion-modal'), {
|
||||
component: createModalContent('Default (No Footer)', false),
|
||||
});
|
||||
document.body.appendChild(currentModal);
|
||||
await currentModal.present();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
321
core/src/components/modal/test/safe-area/modal.e2e.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { E2EPage } from '@utils/test/playwright';
|
||||
import { configs, test, Viewports } from '@utils/test/playwright';
|
||||
|
||||
/**
|
||||
* Safe-area tests verify that modals correctly handle safe-area insets
|
||||
* based on modal type and screen size.
|
||||
*
|
||||
* These tests use simulated safe-area values set in index.html:
|
||||
* - Top: 44px, Bottom: 34px, Left: 44px, Right: 44px
|
||||
*
|
||||
* The test HTML includes red visual indicators for all safe areas to
|
||||
* verify modal content doesn't overlap unsafe regions.
|
||||
*/
|
||||
|
||||
// Helper to get the modal wrapper's computed padding-bottom
|
||||
async function getWrapperPaddingBottom(page: E2EPage): Promise<string> {
|
||||
const modal = page.locator('ion-modal');
|
||||
return modal.evaluate((el: HTMLIonModalElement) => {
|
||||
const wrapper = el.shadowRoot?.querySelector('.modal-wrapper');
|
||||
if (!wrapper) return '0px';
|
||||
return getComputedStyle(wrapper).paddingBottom;
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to check if modal has a footer
|
||||
async function modalHasFooter(page: E2EPage): Promise<boolean> {
|
||||
const modal = page.locator('ion-modal');
|
||||
return modal.evaluate((el: HTMLIonModalElement) => {
|
||||
return el.querySelector('ion-footer') !== null;
|
||||
});
|
||||
}
|
||||
|
||||
// Phone viewport (less than 768px width)
|
||||
const PhoneViewport = { width: 390, height: 844 };
|
||||
|
||||
// =============================================================================
|
||||
// Phone Tests - Fullscreen modals need wrapper padding when no footer
|
||||
// =============================================================================
|
||||
|
||||
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('modal: safe-area - phone'), () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize(PhoneViewport);
|
||||
await page.goto('/src/components/modal/test/safe-area', config);
|
||||
});
|
||||
|
||||
test('fullscreen modal without footer should have wrapper padding', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
});
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#fullscreen-no-footer');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const hasFooter = await modalHasFooter(page);
|
||||
expect(hasFooter).toBe(false);
|
||||
|
||||
const paddingBottom = await getWrapperPaddingBottom(page);
|
||||
// Should have safe-area padding (34px as set in test HTML)
|
||||
expect(paddingBottom).toBe('34px');
|
||||
});
|
||||
|
||||
test('fullscreen modal with footer should not have wrapper padding', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
});
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#fullscreen-modal');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const hasFooter = await modalHasFooter(page);
|
||||
expect(hasFooter).toBe(true);
|
||||
|
||||
const paddingBottom = await getWrapperPaddingBottom(page);
|
||||
// Footer handles safe-area, wrapper should have no padding
|
||||
expect(paddingBottom).toBe('0px');
|
||||
});
|
||||
|
||||
test('default modal without footer should have wrapper padding on phone', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
});
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#default-no-footer');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
// On phones, default modals are fullscreen
|
||||
const paddingBottom = await getWrapperPaddingBottom(page);
|
||||
expect(paddingBottom).toBe('34px');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('modal: safe-area - card modal on phone'), () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize(PhoneViewport);
|
||||
await page.goto('/src/components/modal/test/safe-area', config);
|
||||
});
|
||||
|
||||
test('card modal without footer should have wrapper padding on phone', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
});
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#card-modal-no-footer');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
// Card modals on phones still extend to bottom edge
|
||||
const paddingBottom = await getWrapperPaddingBottom(page);
|
||||
expect(paddingBottom).toBe('34px');
|
||||
});
|
||||
|
||||
test('card modal with footer should not have wrapper padding', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
});
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#card-modal');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const paddingBottom = await getWrapperPaddingBottom(page);
|
||||
expect(paddingBottom).toBe('0px');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Tablet Tests - Centered dialogs don't need safe-area, fullscreen does
|
||||
// =============================================================================
|
||||
|
||||
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('modal: safe-area - tablet'), () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize(Viewports.tablet.portrait);
|
||||
await page.goto('/src/components/modal/test/safe-area', config);
|
||||
});
|
||||
|
||||
test('default modal should not have wrapper padding on tablet', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
});
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#default-modal');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
// Centered dialog on tablet - inset from edges, no padding needed
|
||||
const paddingBottom = await getWrapperPaddingBottom(page);
|
||||
expect(paddingBottom).toBe('0px');
|
||||
});
|
||||
|
||||
test('fullscreen modal without footer should have wrapper padding on tablet', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
});
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#fullscreen-no-footer');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const paddingBottom = await getWrapperPaddingBottom(page);
|
||||
expect(paddingBottom).toBe('34px');
|
||||
});
|
||||
|
||||
test('fullscreen modal with footer should not have wrapper padding', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
});
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#fullscreen-modal');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const paddingBottom = await getWrapperPaddingBottom(page);
|
||||
expect(paddingBottom).toBe('0px');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('modal: safe-area - card modal on tablet'), () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize(Viewports.tablet.portrait);
|
||||
await page.goto('/src/components/modal/test/safe-area', config);
|
||||
});
|
||||
|
||||
test('card modal should not have wrapper padding on tablet', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
});
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#card-modal');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
// Card modals on tablets are inset from all edges
|
||||
const paddingBottom = await getWrapperPaddingBottom(page);
|
||||
expect(paddingBottom).toBe('0px');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Sheet Modal Tests - Always touch bottom edge
|
||||
// =============================================================================
|
||||
|
||||
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('modal: safe-area - sheet modal'), () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize(Viewports.tablet.portrait);
|
||||
await page.goto('/src/components/modal/test/safe-area', config);
|
||||
});
|
||||
|
||||
test('sheet modal should not have wrapper padding (footer handles safe-area)', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
|
||||
});
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#sheet-modal-full');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
// Sheet modals with footer - footer handles the safe area
|
||||
const paddingBottom = await getWrapperPaddingBottom(page);
|
||||
expect(paddingBottom).toBe('0px');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Landscape viewport simulates devices with side notches or landscape orientation
|
||||
const LandscapeViewport = { width: 844, height: 390 };
|
||||
|
||||
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('modal: safe-area screenshots'), () => {
|
||||
test('fullscreen modal should not overlap safe areas in landscape', async ({ page }) => {
|
||||
await page.setViewportSize(LandscapeViewport);
|
||||
await page.goto('/src/components/modal/test/safe-area', config);
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#fullscreen-modal');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
// Red overlays show safe areas - modal content should not overlap them
|
||||
await expect(page).toHaveScreenshot(screenshot('modal-safe-area-fullscreen-landscape'));
|
||||
});
|
||||
|
||||
test('fullscreen modal without footer should show wrapper padding in landscape', async ({ page }) => {
|
||||
await page.setViewportSize(LandscapeViewport);
|
||||
await page.goto('/src/components/modal/test/safe-area', config);
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#fullscreen-no-footer');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
// Without footer, wrapper padding prevents content from overlapping bottom safe area
|
||||
await expect(page).toHaveScreenshot(screenshot('modal-safe-area-fullscreen-no-footer-landscape'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('modal: safe-area screenshots - tablet'), () => {
|
||||
test('centered dialog should be inset from all safe areas', async ({ page }) => {
|
||||
await page.setViewportSize(Viewports.tablet.portrait);
|
||||
await page.goto('/src/components/modal/test/safe-area', config);
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#default-modal');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
// Centered dialog should not touch any edges or safe areas
|
||||
await expect(page).toHaveScreenshot(screenshot('modal-safe-area-centered-tablet'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('modal: safe-area screenshots - card modal'), () => {
|
||||
test('card modal should handle safe areas correctly in landscape', async ({ page }) => {
|
||||
await page.setViewportSize(LandscapeViewport);
|
||||
await page.goto('/src/components/modal/test/safe-area', config);
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await page.click('#card-modal');
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
await expect(page).toHaveScreenshot(screenshot('modal-safe-area-card-landscape'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -11,6 +11,12 @@ import {
|
||||
} from '../utils';
|
||||
|
||||
const POPOVER_IOS_BODY_PADDING = 5;
|
||||
/**
|
||||
* Extra margin around viewport edges for safe area detection.
|
||||
* When popover is within this distance of an edge, safe area
|
||||
* CSS variables will be applied to prevent overlap with system UI.
|
||||
*/
|
||||
const POPOVER_IOS_SAFE_AREA_MARGIN = 25;
|
||||
|
||||
/**
|
||||
* iOS Popover Enter Animation
|
||||
@@ -53,7 +59,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
|
||||
);
|
||||
|
||||
const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING;
|
||||
const margin = size === 'cover' ? 0 : 25;
|
||||
const margin = size === 'cover' ? 0 : POPOVER_IOS_SAFE_AREA_MARGIN;
|
||||
|
||||
const {
|
||||
originX,
|
||||
@@ -61,11 +67,14 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
|
||||
top,
|
||||
left,
|
||||
bottom,
|
||||
checkSafeAreaTop,
|
||||
checkSafeAreaBottom,
|
||||
checkSafeAreaLeft,
|
||||
checkSafeAreaRight,
|
||||
arrowTop,
|
||||
arrowLeft,
|
||||
addPopoverBottomClass,
|
||||
isFullyConstrained,
|
||||
} = calculateWindowAdjustment(
|
||||
side,
|
||||
results.top,
|
||||
@@ -84,8 +93,37 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
|
||||
arrowHeight
|
||||
);
|
||||
|
||||
/**
|
||||
* Safe area CSS variable adjustments.
|
||||
* When the popover is positioned near an edge, we add the corresponding
|
||||
* safe-area inset to ensure the popover doesn't overlap with system UI
|
||||
* (status bars, home indicators, navigation bars on Android API 36+, etc.)
|
||||
*/
|
||||
const safeAreaTop = ' + var(--ion-safe-area-top, 0px)';
|
||||
const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0px)';
|
||||
const safeAreaLeft = ' + var(--ion-safe-area-left, 0px)';
|
||||
const safeAreaRight = ' - var(--ion-safe-area-right, 0px)';
|
||||
|
||||
let topValue = `${top}px`;
|
||||
let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
|
||||
let leftValue = `${left}px`;
|
||||
|
||||
if (checkSafeAreaTop) {
|
||||
topValue = `${top}px${safeAreaTop}`;
|
||||
}
|
||||
if (checkSafeAreaBottom && bottomValue !== undefined) {
|
||||
bottomValue = `${bottom}px${safeAreaBottom}`;
|
||||
}
|
||||
if (checkSafeAreaLeft) {
|
||||
leftValue = `${left}px${safeAreaLeft}`;
|
||||
}
|
||||
if (checkSafeAreaRight) {
|
||||
leftValue = `${left}px${safeAreaRight}`;
|
||||
}
|
||||
|
||||
const baseAnimation = createAnimation();
|
||||
const backdropAnimation = createAnimation();
|
||||
const arrowAnimation = createAnimation();
|
||||
const contentAnimation = createAnimation();
|
||||
|
||||
backdropAnimation
|
||||
@@ -100,11 +138,42 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
|
||||
// The Chromium team stated that this behavior is expected and not a bug. The element animating opacity creates a backdrop root for the backdrop-filter.
|
||||
// To get around this, instead of animating the wrapper, animate both the arrow and content.
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1148826
|
||||
contentAnimation
|
||||
.addElement(root.querySelector('.popover-arrow')!)
|
||||
.addElement(root.querySelector('.popover-content')!)
|
||||
.fromTo('opacity', 0.01, 1);
|
||||
// TODO(FW-4376) Ensure that arrow also blurs when translucent
|
||||
if (arrowEl !== null) {
|
||||
arrowAnimation.addElement(arrowEl).fromTo('opacity', 0.01, 1);
|
||||
}
|
||||
|
||||
contentAnimation
|
||||
.addElement(contentEl)
|
||||
.beforeAddWrite(() => {
|
||||
contentEl.style.setProperty('top', `calc(${topValue} + var(--offset-y, 0px))`);
|
||||
contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0px))`);
|
||||
contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);
|
||||
|
||||
if (bottomValue !== undefined) {
|
||||
contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
|
||||
/**
|
||||
* When both top and bottom are explicitly constrained (isFullyConstrained),
|
||||
* we need to explicitly calculate the height to ensure the popover
|
||||
* fits within the safe area boundaries.
|
||||
*
|
||||
* Using CSS calc with 100vh minus top and bottom values ensures the
|
||||
* popover height respects both safe areas. We also override max-height
|
||||
* to prevent it from interfering with the calculated height.
|
||||
*/
|
||||
if (isFullyConstrained) {
|
||||
/**
|
||||
* Wrap topValue and bottomValue in parentheses to ensure correct
|
||||
* order of operations in the CSS calc. Without parentheses, the
|
||||
* safe-area additions would have wrong signs.
|
||||
*/
|
||||
const heightCalc = `calc(100vh - (${topValue}) - (${bottomValue}) - var(--offset-y, 0px))`;
|
||||
contentEl.style.setProperty('height', heightCalc);
|
||||
contentEl.style.setProperty('max-height', heightCalc);
|
||||
}
|
||||
}
|
||||
})
|
||||
.fromTo('opacity', 0.01, 1);
|
||||
|
||||
return baseAnimation
|
||||
.easing('ease')
|
||||
@@ -118,37 +187,21 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
|
||||
baseEl.classList.add('popover-bottom');
|
||||
}
|
||||
|
||||
if (bottom !== undefined) {
|
||||
contentEl.style.setProperty('bottom', `${bottom}px`);
|
||||
}
|
||||
|
||||
const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
|
||||
const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
|
||||
|
||||
let leftValue = `${left}px`;
|
||||
|
||||
if (checkSafeAreaLeft) {
|
||||
leftValue = `${left}px${safeAreaLeft}`;
|
||||
}
|
||||
if (checkSafeAreaRight) {
|
||||
leftValue = `${left}px${safeAreaRight}`;
|
||||
}
|
||||
|
||||
contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`);
|
||||
contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`);
|
||||
contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);
|
||||
|
||||
if (arrowEl !== null) {
|
||||
const didAdjustBounds = results.top !== top || results.left !== left;
|
||||
const showArrow = shouldShowArrow(side, didAdjustBounds, ev, trigger);
|
||||
/**
|
||||
* Hide the arrow when the popover is fully constrained to the viewport
|
||||
* because it cannot accurately point to the trigger in this case.
|
||||
*/
|
||||
const showArrow = shouldShowArrow(side, didAdjustBounds, ev, trigger) && !isFullyConstrained;
|
||||
|
||||
if (showArrow) {
|
||||
arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0))`);
|
||||
arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0))`);
|
||||
arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0px))`);
|
||||
arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0px))`);
|
||||
} else {
|
||||
arrowEl.style.setProperty('display', 'none');
|
||||
}
|
||||
}
|
||||
})
|
||||
.addAnimation([backdropAnimation, contentAnimation]);
|
||||
.addAnimation([backdropAnimation, arrowAnimation, contentAnimation]);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,12 @@ import type { Animation } from '../../../interface';
|
||||
import { calculateWindowAdjustment, getPopoverDimensions, getPopoverPosition } from '../utils';
|
||||
|
||||
const POPOVER_MD_BODY_PADDING = 12;
|
||||
/**
|
||||
* Extra margin around viewport edges for safe area detection.
|
||||
* When popover is within this distance of an edge, safe area
|
||||
* CSS variables will be applied to prevent overlap with system UI.
|
||||
*/
|
||||
const POPOVER_MD_SAFE_AREA_MARGIN = 25;
|
||||
|
||||
/**
|
||||
* Md Popover Enter Animation
|
||||
@@ -47,7 +53,20 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
|
||||
|
||||
const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING;
|
||||
|
||||
const { originX, originY, top, left, bottom } = calculateWindowAdjustment(
|
||||
const margin = size === 'cover' ? 0 : POPOVER_MD_SAFE_AREA_MARGIN;
|
||||
|
||||
const {
|
||||
originX,
|
||||
originY,
|
||||
top,
|
||||
left,
|
||||
bottom,
|
||||
checkSafeAreaTop,
|
||||
checkSafeAreaBottom,
|
||||
checkSafeAreaLeft,
|
||||
checkSafeAreaRight,
|
||||
isFullyConstrained,
|
||||
} = calculateWindowAdjustment(
|
||||
side,
|
||||
results.top,
|
||||
results.left,
|
||||
@@ -56,12 +75,40 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
|
||||
bodyHeight,
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
0,
|
||||
margin,
|
||||
results.originX,
|
||||
results.originY,
|
||||
results.referenceCoordinates
|
||||
);
|
||||
|
||||
/**
|
||||
* Safe area CSS variable adjustments.
|
||||
* When the popover is positioned near an edge, we add the corresponding
|
||||
* safe-area inset to ensure the popover doesn't overlap with system UI
|
||||
* (status bars, home indicators, navigation bars on Android API 36+, etc.)
|
||||
*/
|
||||
const safeAreaTop = ' + var(--ion-safe-area-top, 0)';
|
||||
const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0)';
|
||||
const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
|
||||
const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
|
||||
|
||||
let topValue = `${top}px`;
|
||||
let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
|
||||
let leftValue = `${left}px`;
|
||||
|
||||
if (checkSafeAreaTop) {
|
||||
topValue = `${top}px${safeAreaTop}`;
|
||||
}
|
||||
if (checkSafeAreaBottom && bottomValue !== undefined) {
|
||||
bottomValue = `${bottom}px${safeAreaBottom}`;
|
||||
}
|
||||
if (checkSafeAreaLeft) {
|
||||
leftValue = `${left}px${safeAreaLeft}`;
|
||||
}
|
||||
if (checkSafeAreaRight) {
|
||||
leftValue = `${left}px${safeAreaRight}`;
|
||||
}
|
||||
|
||||
const baseAnimation = createAnimation();
|
||||
const backdropAnimation = createAnimation();
|
||||
const wrapperAnimation = createAnimation();
|
||||
@@ -81,13 +128,32 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
|
||||
contentAnimation
|
||||
.addElement(contentEl)
|
||||
.beforeStyles({
|
||||
top: `calc(${top}px + var(--offset-y, 0px))`,
|
||||
left: `calc(${left}px + var(--offset-x, 0px))`,
|
||||
top: `calc(${topValue} + var(--offset-y, 0px))`,
|
||||
left: `calc(${leftValue} + var(--offset-x, 0px))`,
|
||||
'transform-origin': `${originY} ${originX}`,
|
||||
})
|
||||
.beforeAddWrite(() => {
|
||||
if (bottom !== undefined) {
|
||||
contentEl.style.setProperty('bottom', `${bottom}px`);
|
||||
if (bottomValue !== undefined) {
|
||||
contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
|
||||
/**
|
||||
* When both top and bottom are explicitly constrained (isFullyConstrained),
|
||||
* we need to explicitly calculate the height to ensure the popover
|
||||
* fits within the safe area boundaries.
|
||||
*
|
||||
* Using CSS calc with 100vh minus top and bottom values ensures the
|
||||
* popover height respects both safe areas. We also override max-height
|
||||
* to prevent it from interfering with the calculated height.
|
||||
*/
|
||||
if (isFullyConstrained) {
|
||||
/**
|
||||
* Wrap topValue and bottomValue in parentheses to ensure correct
|
||||
* order of operations in the CSS calc. Without parentheses, the
|
||||
* safe-area additions would have wrong signs.
|
||||
*/
|
||||
const heightCalc = `calc(100vh - (${topValue}) - (${bottomValue}) - var(--offset-y, 0px))`;
|
||||
contentEl.style.setProperty('height', heightCalc);
|
||||
contentEl.style.setProperty('max-height', heightCalc);
|
||||
}
|
||||
}
|
||||
})
|
||||
.fromTo('transform', 'scale(0.8)', 'scale(1)');
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
180
core/src/components/popover/test/safe-area/index.html
Normal file
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Popover - Safe Area</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
|
||||
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
|
||||
<script src="../../../../../scripts/testing/scripts.js"></script>
|
||||
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
|
||||
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
||||
<style>
|
||||
/**
|
||||
* Simulate safe-area insets for testing.
|
||||
* These values represent typical Android edge-to-edge safe areas.
|
||||
* Left/right values simulate landscape orientation or devices with side notches.
|
||||
*/
|
||||
:root {
|
||||
--ion-safe-area-top: 44px;
|
||||
--ion-safe-area-bottom: 34px;
|
||||
--ion-safe-area-left: 44px;
|
||||
--ion-safe-area-right: 44px;
|
||||
}
|
||||
|
||||
/* Visual indicator for safe areas */
|
||||
.safe-area-indicator {
|
||||
position: fixed;
|
||||
background: rgba(255, 0, 0, 0.2);
|
||||
pointer-events: none;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.safe-area-top {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--ion-safe-area-top);
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--ion-safe-area-bottom);
|
||||
}
|
||||
|
||||
.safe-area-left {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: var(--ion-safe-area-left);
|
||||
}
|
||||
|
||||
.safe-area-right {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: var(--ion-safe-area-right);
|
||||
}
|
||||
|
||||
/* Position triggers at different locations */
|
||||
.bottom-trigger {
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.near-bottom-trigger {
|
||||
position: fixed;
|
||||
bottom: 200px;
|
||||
right: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<!-- Visual indicators for safe areas -->
|
||||
<div class="safe-area-indicator safe-area-top"></div>
|
||||
<div class="safe-area-indicator safe-area-bottom"></div>
|
||||
<div class="safe-area-indicator safe-area-left"></div>
|
||||
<div class="safe-area-indicator safe-area-right"></div>
|
||||
|
||||
<div class="ion-page" id="main-page">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Popover - Safe Area Positioning</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<p>Test that popovers are <strong>positioned away from</strong> unsafe areas (shown in red).</p>
|
||||
<p>The popover should be moved up/down/left/right to avoid overlapping the safe-area zones.</p>
|
||||
<p>
|
||||
<strong>Landscape simulation:</strong> Left and right safe areas are set to 44px to test devices with side
|
||||
notches.
|
||||
</p>
|
||||
|
||||
<ion-list>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Small Popover (Center)</h2>
|
||||
<p>Floating popover - positioned in center, no adjustment needed</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" id="small-popover-trigger">Present</ion-button>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Large Popover</h2>
|
||||
<p>Tall content that may extend toward bottom safe area</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" id="large-popover-trigger">Present</ion-button>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<ion-button class="bottom-trigger" id="bottom-trigger"> Trigger Near Bottom </ion-button>
|
||||
|
||||
<ion-button class="near-bottom-trigger" id="near-bottom-trigger"> Near Bottom Right </ion-button>
|
||||
|
||||
<!-- Small popover -->
|
||||
<ion-popover trigger="small-popover-trigger" trigger-action="click">
|
||||
<ion-content class="ion-padding">
|
||||
<ion-list>
|
||||
<ion-item><ion-label>Option 1</ion-label></ion-item>
|
||||
<ion-item><ion-label>Option 2</ion-label></ion-item>
|
||||
<ion-item><ion-label>Option 3</ion-label></ion-item>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
</ion-popover>
|
||||
|
||||
<!-- Large popover with many items -->
|
||||
<ion-popover trigger="large-popover-trigger" trigger-action="click">
|
||||
<ion-content>
|
||||
<ion-list id="large-list"></ion-list>
|
||||
</ion-content>
|
||||
</ion-popover>
|
||||
|
||||
<!-- Popover triggered from near bottom -->
|
||||
<ion-popover trigger="bottom-trigger" trigger-action="click">
|
||||
<ion-content>
|
||||
<ion-list id="bottom-list"></ion-list>
|
||||
</ion-content>
|
||||
</ion-popover>
|
||||
|
||||
<!-- Popover triggered from near bottom right -->
|
||||
<ion-popover trigger="near-bottom-trigger" trigger-action="click">
|
||||
<ion-content>
|
||||
<ion-list id="near-bottom-list"></ion-list>
|
||||
</ion-content>
|
||||
</ion-popover>
|
||||
</ion-content>
|
||||
</div>
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
// Generate list items for popovers
|
||||
function generateItems(listId, count) {
|
||||
const list = document.getElementById(listId);
|
||||
if (!list) return;
|
||||
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const item = document.createElement('ion-item');
|
||||
const label = document.createElement('ion-label');
|
||||
label.textContent = `Item ${i}`;
|
||||
item.appendChild(label);
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
generateItems('large-list', 15);
|
||||
generateItems('bottom-list', 10);
|
||||
generateItems('near-bottom-list', 8);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
133
core/src/components/popover/test/safe-area/popover.e2e.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
/**
|
||||
* Safe-area tests verify that popovers are correctly positioned
|
||||
* to avoid overlapping with safe-area zones (status bars, navigation bars, etc.)
|
||||
*
|
||||
* This is especially important for Android API 36+ where edge-to-edge mode
|
||||
* is enforced and apps can no longer opt out.
|
||||
*
|
||||
* The test HTML includes safe-area values (44px top/left/right, 34px bottom)
|
||||
* and red visual indicators to verify popover positioning.
|
||||
*/
|
||||
|
||||
// Tests that apply to both iOS and MD modes
|
||||
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('popover: safe-area positioning'), () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/src/components/popover/test/safe-area', config);
|
||||
});
|
||||
|
||||
test('popover pinned to bottom should account for safe-area-bottom in position', async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/ionic-team/ionic-framework/issues/30900',
|
||||
});
|
||||
|
||||
/**
|
||||
* Use a small viewport to force the popover to be fully constrained.
|
||||
* The large popover has 15 items (~700px), which will exceed the available
|
||||
* space in this viewport, causing it to be constrained with both top and
|
||||
* bottom edges near the safe areas.
|
||||
*
|
||||
* A 300px viewport ensures there's not enough space above OR below the
|
||||
* trigger for the full popover content, triggering the fully constrained path.
|
||||
*/
|
||||
await page.setViewportSize({ width: 375, height: 300 });
|
||||
|
||||
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
|
||||
|
||||
// Click the large popover trigger which has enough content to extend toward the bottom
|
||||
await page.click('#large-popover-trigger');
|
||||
await ionPopoverDidPresent.next();
|
||||
|
||||
// Target the specific popover that was presented
|
||||
const popover = page.locator('ion-popover[trigger="large-popover-trigger"]');
|
||||
const popoverContent = popover.locator('.popover-content');
|
||||
|
||||
// Get the computed bottom style - should include safe-area calc
|
||||
const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom);
|
||||
|
||||
// The bottom should include the safe-area-bottom CSS variable
|
||||
// This ensures the popover is positioned above the unsafe area
|
||||
expect(bottomStyle).toContain('var(--ion-safe-area-bottom');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// iOS-specific tests
|
||||
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('popover: safe-area positioning - ios specific'), () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/src/components/popover/test/safe-area', config);
|
||||
});
|
||||
|
||||
test('floating popover should not have safe-area adjustments', async ({ page }) => {
|
||||
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
|
||||
|
||||
await page.click('#small-popover-trigger');
|
||||
await ionPopoverDidPresent.next();
|
||||
|
||||
// Target the specific popover
|
||||
const popover = page.locator('ion-popover[trigger="small-popover-trigger"]');
|
||||
const popoverContent = popover.locator('.popover-content');
|
||||
|
||||
// Get the computed top and bottom styles
|
||||
const topStyle = await popoverContent.evaluate((el) => el.style.top);
|
||||
const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom);
|
||||
|
||||
// A floating popover in the middle shouldn't have safe-area adjustments
|
||||
// The top should be a simple calc without safe-area
|
||||
expect(topStyle).not.toContain('var(--ion-safe-area-top');
|
||||
// The bottom should not be set for a floating popover
|
||||
expect(bottomStyle).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Landscape viewport simulates devices with side notches or landscape orientation
|
||||
const LandscapeViewport = { width: 844, height: 390 };
|
||||
|
||||
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('popover: safe-area screenshots'), () => {
|
||||
test('popover near bottom should avoid bottom safe area', async ({ page }) => {
|
||||
await page.setViewportSize(LandscapeViewport);
|
||||
await page.goto('/src/components/popover/test/safe-area', config);
|
||||
|
||||
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
|
||||
|
||||
await page.click('#bottom-trigger');
|
||||
await ionPopoverDidPresent.next();
|
||||
|
||||
// Red overlays show safe areas - popover should be positioned to avoid them
|
||||
await expect(page).toHaveScreenshot(screenshot('popover-safe-area-bottom-landscape'));
|
||||
});
|
||||
|
||||
test('popover near bottom right should avoid right safe area', async ({ page }) => {
|
||||
await page.setViewportSize(LandscapeViewport);
|
||||
await page.goto('/src/components/popover/test/safe-area', config);
|
||||
|
||||
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
|
||||
|
||||
await page.click('#near-bottom-trigger');
|
||||
await ionPopoverDidPresent.next();
|
||||
|
||||
// Popover triggered from near-right edge should account for right safe area
|
||||
await expect(page).toHaveScreenshot(screenshot('popover-safe-area-right-landscape'));
|
||||
});
|
||||
|
||||
test('large popover should avoid all safe areas', async ({ page }) => {
|
||||
await page.setViewportSize(LandscapeViewport);
|
||||
await page.goto('/src/components/popover/test/safe-area', config);
|
||||
|
||||
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
|
||||
|
||||
await page.click('#large-popover-trigger');
|
||||
await ionPopoverDidPresent.next();
|
||||
|
||||
// Large popover may extend toward edges - should respect safe areas
|
||||
await expect(page).toHaveScreenshot(screenshot('popover-safe-area-large-landscape'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 52 KiB |