Compare commits

...

54 Commits

Author SHA1 Message Date
ionitron
1e4e9b9ff8 chore(): add updated snapshots 2026-02-03 21:23:00 +00:00
ShaneK
9097a1d146 Trying to improve accuracy 2026-02-03 13:11:38 -08:00
ShaneK
edc202db34 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2026-02-03 12:07:20 -08:00
ShaneK
1d232c8202 fix(modal): attempting to fix scenarios where the modal would become fully constrained 2026-02-03 10:31:00 -08:00
ShaneK
7584e617f1 chore(test): resetting screenshot test for irrelevant issue 2026-02-03 07:52:50 -08:00
Brandy Smith
cc75ff42e1 chore(scripts): remove no longer used test.e2e.script (#30943)
Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
2026-02-03 14:51:30 +00:00
ShaneK
fdb24e4f5f Merge branch 'FW-6830' of github.com:ionic-team/ionic-framework into FW-6830 2026-02-02 06:00:49 -08:00
ShaneK
bc5b3a3a84 fix(popover): apply safe area adjustments for edge positioning in md mode 2026-02-02 06:00:40 -08:00
ionitron
b943db479e chore(): add updated snapshots 2026-01-30 16:58:29 +00:00
ShaneK
04c10985b1 Merge branch 'FW-6830' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-30 08:47:04 -08:00
ShaneK
ab7c863e36 test(safe-area): adding left/right safe area tests 2026-01-30 08:46:49 -08:00
ionitron
1895f8fb20 chore(): add updated snapshots 2026-01-30 16:28:49 +00:00
ShaneK
d4f646104b chore(tests): resetting screenshots to remove pointless diffs 2026-01-30 08:17:34 -08:00
ionitron
82584aaf83 chore(): add updated snapshots 2026-01-30 16:12:27 +00:00
ShaneK
3f1e2c0644 Merge branch 'FW-6830' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-30 08:00:45 -08:00
ShaneK
fcc50d6d16 fix(modal): fixing safe area in certain situations, adding some tests 2026-01-30 07:01:34 -08:00
renovate[bot]
893d523997 chore(deps): update dependency @capacitor/core to v8.0.2 (#30938)
This PR contains the following updates:

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

---

### Release Notes

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

###
[`v8.0.2`](https://redirect.github.com/ionic-team/capacitor/blob/HEAD/CHANGELOG.md#802-2026-01-27)

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

##### Bug Fixes

- **android:** AGP 9.0 no longer supports `proguard-android.txt`
([#&#8203;8315](https://redirect.github.com/ionic-team/capacitor/issues/8315))
([dcc76c3](dcc76c3750))
- **cli:** Update tar package
([#&#8203;8311](https://redirect.github.com/ionic-team/capacitor/issues/8311))
([0969c5c](0969c5cd0b))
- **core:** make SystemBars hide and show options optional
([#&#8203;8305](https://redirect.github.com/ionic-team/capacitor/issues/8305))
([95dc7d8](95dc7d8ace))
- **SystemBars:** get correct style on handleOnConfigurationChanged
([#&#8203;8295](https://redirect.github.com/ionic-team/capacitor/issues/8295))
([2a66b44](2a66b44915))
- **SystemBars:** Set window background color according to theme
([#&#8203;8306](https://redirect.github.com/ionic-team/capacitor/issues/8306))
([6037e38](6037e3836e))
- **SystemBars:** Skipping margin manipulation when on a fixed WebView
([#&#8203;8309](https://redirect.github.com/ionic-team/capacitor/issues/8309))
([53c33b6](53c33b6142))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Never, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/ionic-team/ionic-framework).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi45Mi4xIiwidXBkYXRlZEluVmVyIjoiNDIuOTIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-30 14:18:12 +00:00
ShaneK
1f68ad48f2 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-29 10:09:29 -08:00
OS-jacobbell
be14dc4bb8 chore(ci): persist updates to core/package.json in stencil nightly build (#30937)
<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->
The Stencil Nightly Build workflow tests Ionic with the latest nightly
build of Stencil. The first step of the workflow updates Stencil, builds
Ionic core, and uploads the build files. Later steps download these
build files. Core's updated package.json is not uploaded with the build
files, so later steps are installing an old Stencil version, leading to
conflicts.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->
Add core's package.json to the artifact upload. This will make all later
steps use the correct Stencil version.

Seven of the tests run `git diff` to ensure tests did not cause changes
in tracked files. Core's package.json would register as a change, so a
new step reverts package.json before running `git diff`.

## Does this introduce a breaking change?

- [ ] Yes
- [X] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->
2026-01-27 21:57:29 +00:00
renovate[bot]
364faced75 chore(deps): update actions/checkout action to v6.0.2 (#30935)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/checkout](https://redirect.github.com/actions/checkout) |
action | patch | `v6.0.1` → `v6.0.2` |

---

### Release Notes

<details>
<summary>actions/checkout (actions/checkout)</summary>

###
[`v6.0.2`](https://redirect.github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v602)

[Compare
Source](https://redirect.github.com/actions/checkout/compare/v6.0.1...v6.0.2)

- Fix tag handling: preserve annotations and explicit fetch-tags by
[@&#8203;ericsciple](https://redirect.github.com/ericsciple) in
[#&#8203;2356](https://redirect.github.com/actions/checkout/pull/2356)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Never, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/ionic-team/ionic-framework).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi44NS4xIiwidXBkYXRlZEluVmVyIjoiNDIuODUuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 20:43:30 +00:00
ionitron
3bbb0a78f3 chore(): add updated snapshots 2026-01-14 18:19:42 +00:00
ShaneK
4d81e2d820 Resetting unnecessary screenshot changes 2026-01-14 10:08:14 -08:00
ShaneK
e1388e646a Merge branch 'FW-6830' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-14 10:04:54 -08:00
ShaneK
56190b2c79 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-14 10:04:39 -08:00
ionitron
d8abf4ce35 chore(): add updated snapshots 2026-01-12 17:43:04 +00:00
ShaneK
8ee1069b93 Reverting changes to be focused on modal and popover safe area support 2026-01-12 09:28:49 -08:00
ShaneK
3b7beca8d0 chore(lint): ignore disallowed property in content.scss 2026-01-09 11:31:51 -08:00
ionitron
0174a3938c chore(): add updated snapshots 2026-01-09 19:08:34 +00:00
ShaneK
f9159e1e90 fix(content): support side safe area content 2026-01-09 10:40:57 -08:00
ShaneK
095b72ef30 fix(content): detect dynamic tab bar changes for safe-area handling 2026-01-09 09:26:46 -08:00
ShaneK
e953f7b506 chore(tests): fix safe-area tests for Mobile Firefox 2026-01-08 10:14:21 -08:00
ShaneK
a63afa3db6 fix(modal): addressing edge cases, cleaning up 2026-01-08 09:02:51 -08:00
ShaneK
26b6b7bb02 fix(content): exclude nested content from safe-area handling 2026-01-08 06:59:16 -08:00
ShaneK
553aa65376 chore(tests): fixing tests having issues with mutation observers 2026-01-07 09:28:53 -08:00
ionitron
a5bd1dd518 chore(): add updated snapshots 2026-01-07 16:24:35 +00:00
ShaneK
48e4bc4776 fix(content): detect header/footer wrapped in custom components 2026-01-07 06:33:33 -08:00
ShaneK
fc496043d8 chore(test): zero out safe-area insets in test environments 2026-01-06 09:48:49 -08:00
ShaneK
4fe98a42ff fix(content): apply safe-area insets when header/footer absent 2026-01-06 08:41:31 -08:00
ShaneK
7c197c2c99 fix(popover): extending safe are protections to top/bottom overlap 2026-01-05 13:17:55 -08:00
ShaneK
4a165bc26c fix(modal): correct safe-area handling for MD mode and edge detection 2026-01-05 11:25:45 -08:00
ShaneK
9c404a6839 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-05 10:33:41 -08:00
ShaneK
39b15cb3b0 chore: fixing phone viewport tests 2026-01-02 08:15:39 -08:00
ShaneK
fa16c3a7bd chore: test fix 2026-01-02 07:00:32 -08:00
ShaneK
d6eb8ce8e9 fix(modal): apply safe-area padding to card modals on phones 2026-01-02 06:47:40 -08:00
ionitron
3fac5ccbf8 chore(): add updated snapshots 2025-12-31 21:05:04 +00:00
ShaneK
35579250d5 fix(modal): dynamically handle safe-area insets for edge-to-edge mode 2025-12-31 10:28:09 -08:00
ShaneK
61b588c6b9 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2025-12-31 05:31:53 -08:00
ShaneK
4b7f2fadef Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2025-12-26 13:51:44 -08:00
ShaneK
415245b9b4 resetting unchanged snapshot 2025-12-26 11:01:53 -08:00
ionitron
61dc7eb4f0 chore(): add updated snapshots 2025-12-26 18:57:09 +00:00
ionitron
b87cd07e91 chore(): add updated snapshots 2025-12-26 18:25:42 +00:00
ShaneK
fea0a3da0f fix(modal): dynamically handle safe-area insets based on modal type and position 2025-12-26 10:15:02 -08:00
ShaneK
f66c84a9b9 fix(modal): dynamically apply safe-area insets based on viewport edge contact 2025-12-26 09:57:44 -08:00
ShaneK
c54f257633 fix(modal): respect safe area insets on tablet-sized screens 2025-12-23 14:28:12 -08:00
95 changed files with 1557 additions and 414 deletions

View File

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

View File

@@ -8,7 +8,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- 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

View File

@@ -8,7 +8,7 @@ inputs:
runs:
using: 'composite'
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 24.x

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,10 @@ runs:
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

26
core/package-lock.json generated
View File

@@ -19,7 +19,6 @@
"@capacitor/haptics": "^8.0.0",
"@capacitor/keyboard": "^8.0.0",
"@capacitor/status-bar": "^8.0.0",
"@clack/prompts": "^0.11.0",
"@ionic/eslint-config": "^0.3.0",
"@ionic/prettier-config": "^2.0.0",
"@playwright/test": "^1.56.1",
@@ -629,9 +628,9 @@
"license": "MIT"
},
"node_modules/@capacitor/core": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.0.1.tgz",
"integrity": "sha512-5UqSWxGMp/B8KhYu7rAijqNtYslhcLh+TrbfU48PfdMDsPfaU/VY48sMNzC22xL8BmoFoql/3SKyP+pavTOvOA==",
"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,
@@ -669,25 +668,6 @@
"@capacitor/core": ">=8.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"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.4.0",
"dev": true,

View File

@@ -44,7 +44,6 @@
"@capacitor/haptics": "^8.0.0",
"@capacitor/keyboard": "^8.0.0",
"@capacitor/status-bar": "^8.0.0",
"@clack/prompts": "^0.11.0",
"@ionic/eslint-config": "^0.3.0",
"@ionic/prettier-config": "^2.0.0",
"@playwright/test": "^1.56.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",

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

@@ -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();
@@ -1385,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
/*

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

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

View 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'));
});
});
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -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]);
};

View File

@@ -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)');

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

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

View 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'));
});
});
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -30,11 +30,20 @@ export interface PopoverStyles {
bottom?: number;
originX: string;
originY: string;
checkSafeAreaTop: boolean;
checkSafeAreaBottom: boolean;
checkSafeAreaLeft: boolean;
checkSafeAreaRight: boolean;
arrowTop: number;
arrowLeft: number;
addPopoverBottomClass: boolean;
/**
* When true, the popover content was too tall to fit above or below
* the trigger, so it was constrained to the full viewport height.
* In this case, the arrow should be hidden as it cannot accurately
* point to the trigger.
*/
isFullyConstrained: boolean;
}
/**
@@ -829,8 +838,11 @@ export const calculateWindowAdjustment = (
let bottom;
let originX = contentOriginX;
let originY = contentOriginY;
let checkSafeAreaTop = false;
let checkSafeAreaBottom = false;
let checkSafeAreaLeft = false;
let checkSafeAreaRight = false;
let isFullyConstrained = false;
const triggerTop = triggerCoordinates
? triggerCoordinates.top + triggerCoordinates.height
: bodyHeight / 2 - contentHeight / 2;
@@ -841,20 +853,29 @@ export const calculateWindowAdjustment = (
* Adjust popover so it does not
* go off the left of the screen.
*/
if (left < bodyPadding + safeAreaMargin) {
if (left < bodyPadding) {
left = bodyPadding;
checkSafeAreaLeft = true;
originX = 'left';
/**
* Adjust popover so it does not
* go off the right of the screen.
*/
} else if (contentWidth + bodyPadding + left + safeAreaMargin > bodyWidth) {
checkSafeAreaRight = true;
} else if (contentWidth + bodyPadding + left > bodyWidth) {
left = bodyWidth - contentWidth - bodyPadding;
originX = 'right';
}
/**
* After position adjustment, check if popover is near edges
* and needs safe-area CSS variable adjustments.
*/
if (left <= safeAreaMargin) {
checkSafeAreaLeft = true;
}
if (left + contentWidth >= bodyWidth - safeAreaMargin) {
checkSafeAreaRight = true;
}
/**
* Adjust popover so it does not
* go off the top of the screen.
@@ -863,7 +884,19 @@ export const calculateWindowAdjustment = (
* margins.
*/
if (triggerTop + triggerHeight + contentHeight > bodyHeight && (side === 'top' || side === 'bottom')) {
if (triggerTop - contentHeight > 0) {
/**
* Calculate available space above and below, accounting for safe areas.
* This ensures we flip to whichever side has more usable space.
*/
const spaceAbove = (triggerCoordinates?.top ?? triggerTop) - bodyPadding - safeAreaMargin;
const spaceBelow = bodyHeight - triggerTop - triggerHeight - bodyPadding - safeAreaMargin;
/**
* Flip above if:
* 1. Content fits entirely above the trigger, OR
* 2. There's more usable space above than below (accounting for safe areas)
*/
if (triggerTop - contentHeight > 0 || spaceAbove > spaceBelow) {
/**
* While we strive to align the popover with the trigger
* on smaller screens this is not always possible. As a result,
@@ -874,31 +907,90 @@ export const calculateWindowAdjustment = (
* We chose 12 here so that the popover position looks a bit nicer as
* it is not right up against the edge of the screen.
*/
top = Math.max(12, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1));
top = Math.max(bodyPadding, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1));
arrowTop = top + contentHeight;
originY = 'bottom';
addPopoverBottomClass = true;
/**
* If not enough room for popover to appear
* above trigger, then cut it off.
* If the popover is positioned near the top edge, account for safe area.
* This ensures the popover doesn't overlap with status bars or notches.
*/
if (top <= bodyPadding + safeAreaMargin) {
checkSafeAreaTop = true;
top = bodyPadding;
}
/**
* After flipping above, check if popover will likely overflow the viewport.
* This can happen when the popover is taller than the available space.
*
* When checkSafeAreaTop is true, the CSS will add safe-area-top to the
* top position, pushing the popover down. Since we don't know the exact
* CSS safe-area value at runtime, we use a conservative threshold that
* accounts for typical safe-area sizes (usually 40-50px). By checking
* against (safeAreaMargin * 2), we ensure that:
* 1. Any popover close to the viewport boundary gets constrained
* 2. The safe-area CSS variables have room to be applied without overflow
*/
if (checkSafeAreaTop && top + contentHeight > bodyHeight - safeAreaMargin * 2 - bodyPadding) {
bottom = bodyPadding;
checkSafeAreaBottom = true;
isFullyConstrained = true;
}
/**
* If not enough room for popover to appear above trigger
* (i.e., content is taller than space above), then constrain
* the popover to fill the entire viewport from top to bottom.
*/
} else {
top = bodyPadding;
bottom = bodyPadding;
checkSafeAreaTop = true;
checkSafeAreaBottom = true;
isFullyConstrained = true;
}
}
/**
* Check if popover is near edges and needs safe-area adjustments.
* When the popover extends into the safe-area zone, set a bottom constraint
* to push it up and out of the unsafe area. This is essential for
* edge-to-edge displays on Android API 36+ and iOS devices with home indicators.
*/
const popoverBottom = bottom !== undefined ? bodyHeight - bottom : top + contentHeight;
if (popoverBottom > bodyHeight - safeAreaMargin && bottom === undefined) {
checkSafeAreaBottom = true;
/**
* Set a bottom constraint to push the popover up out of the safe-area zone.
* The animation will add the safe-area CSS variable to this value.
*
* We also set isFullyConstrained so that height: unset is applied,
* allowing the bottom constraint to actually take effect (otherwise
* the explicit height would override the bottom constraint).
*/
bottom = bodyPadding;
isFullyConstrained = true;
}
if (top < safeAreaMargin) {
checkSafeAreaTop = true;
}
return {
top,
left,
bottom,
originX,
originY,
checkSafeAreaTop,
checkSafeAreaBottom,
checkSafeAreaLeft,
checkSafeAreaRight,
arrowTop,
arrowLeft,
addPopoverBottomClass,
isFullyConstrained,
};
};

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB