diff --git a/.github/actions/publish-npm/action.yml b/.github/actions/publish-npm/action.yml
index 132b57f75b3..d3e06d5fb01 100644
--- a/.github/actions/publish-npm/action.yml
+++ b/.github/actions/publish-npm/action.yml
@@ -22,7 +22,7 @@ runs:
using: 'composite'
steps:
- name: 🟢 Configure Node for Publish
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ inputs.node-version }}
registry-url: 'https://registry.npmjs.org'
diff --git a/.github/workflows/actions/build-angular-server/action.yml b/.github/workflows/actions/build-angular-server/action.yml
index 3cab52b650a..b5d37c5a9ac 100644
--- a/.github/workflows/actions/build-angular-server/action.yml
+++ b/.github/workflows/actions/build-angular-server/action.yml
@@ -3,7 +3,7 @@ description: 'Build Ionic Angular Server'
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
diff --git a/.github/workflows/actions/build-core-stencil-prerelease/action.yml b/.github/workflows/actions/build-core-stencil-prerelease/action.yml
index 913e8f494ff..e23d9119831 100644
--- a/.github/workflows/actions/build-core-stencil-prerelease/action.yml
+++ b/.github/workflows/actions/build-core-stencil-prerelease/action.yml
@@ -9,7 +9,7 @@ runs:
using: 'composite'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
diff --git a/.github/workflows/actions/build-core/action.yml b/.github/workflows/actions/build-core/action.yml
index 2b5117cf7af..7524c8a97b3 100644
--- a/.github/workflows/actions/build-core/action.yml
+++ b/.github/workflows/actions/build-core/action.yml
@@ -9,7 +9,7 @@ runs:
using: 'composite'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- name: 🕸️ Install Dependencies
diff --git a/.github/workflows/actions/build-react-router/action.yml b/.github/workflows/actions/build-react-router/action.yml
index 568c835c42f..c8083494b0a 100644
--- a/.github/workflows/actions/build-react-router/action.yml
+++ b/.github/workflows/actions/build-react-router/action.yml
@@ -3,7 +3,7 @@ description: 'Build Ionic React Router'
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
diff --git a/.github/workflows/actions/build-react/action.yml b/.github/workflows/actions/build-react/action.yml
index 9b4a5995e9e..5899335ad3e 100644
--- a/.github/workflows/actions/build-react/action.yml
+++ b/.github/workflows/actions/build-react/action.yml
@@ -3,7 +3,7 @@ description: 'Build Ionic React'
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
diff --git a/.github/workflows/actions/build-vue-router/action.yml b/.github/workflows/actions/build-vue-router/action.yml
index efd4579f565..9b07ce64973 100644
--- a/.github/workflows/actions/build-vue-router/action.yml
+++ b/.github/workflows/actions/build-vue-router/action.yml
@@ -3,7 +3,7 @@ description: 'Builds Ionic Vue Router'
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
diff --git a/.github/workflows/actions/build-vue/action.yml b/.github/workflows/actions/build-vue/action.yml
index 170e889f968..5c7497ec359 100644
--- a/.github/workflows/actions/build-vue/action.yml
+++ b/.github/workflows/actions/build-vue/action.yml
@@ -3,7 +3,7 @@ description: 'Build Ionic Vue'
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
diff --git a/.github/workflows/actions/test-angular-e2e/action.yml b/.github/workflows/actions/test-angular-e2e/action.yml
index 11aa8eb789c..a4835a0210a 100644
--- a/.github/workflows/actions/test-angular-e2e/action.yml
+++ b/.github/workflows/actions/test-angular-e2e/action.yml
@@ -6,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
diff --git a/.github/workflows/actions/test-core-clean-build/action.yml b/.github/workflows/actions/test-core-clean-build/action.yml
index 92e3fed394b..96abc90121c 100644
--- a/.github/workflows/actions/test-core-clean-build/action.yml
+++ b/.github/workflows/actions/test-core-clean-build/action.yml
@@ -3,7 +3,7 @@ description: 'Test Core Clean Build'
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
diff --git a/.github/workflows/actions/test-core-lint/action.yml b/.github/workflows/actions/test-core-lint/action.yml
index 321a2d26304..f9f0011719a 100644
--- a/.github/workflows/actions/test-core-lint/action.yml
+++ b/.github/workflows/actions/test-core-lint/action.yml
@@ -3,7 +3,7 @@ description: 'Test Core Lint'
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- name: 🕸️ Install Dependencies
diff --git a/.github/workflows/actions/test-core-screenshot/action.yml b/.github/workflows/actions/test-core-screenshot/action.yml
index 7ffa40faf5c..1f8699e66d4 100644
--- a/.github/workflows/actions/test-core-screenshot/action.yml
+++ b/.github/workflows/actions/test-core-screenshot/action.yml
@@ -13,7 +13,7 @@ inputs:
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
diff --git a/.github/workflows/actions/test-core-spec/action.yml b/.github/workflows/actions/test-core-spec/action.yml
index f25207f6a49..2aab4b1be94 100644
--- a/.github/workflows/actions/test-core-spec/action.yml
+++ b/.github/workflows/actions/test-core-spec/action.yml
@@ -6,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- name: 🕸️ Install Dependencies
diff --git a/.github/workflows/actions/test-react-e2e/action.yml b/.github/workflows/actions/test-react-e2e/action.yml
index a6f1d42ba72..a1bcbf7a4db 100644
--- a/.github/workflows/actions/test-react-e2e/action.yml
+++ b/.github/workflows/actions/test-react-e2e/action.yml
@@ -6,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
diff --git a/.github/workflows/actions/test-react-router-e2e/action.yml b/.github/workflows/actions/test-react-router-e2e/action.yml
index eb2c3df2af8..b983878b661 100644
--- a/.github/workflows/actions/test-react-router-e2e/action.yml
+++ b/.github/workflows/actions/test-react-router-e2e/action.yml
@@ -6,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
diff --git a/.github/workflows/actions/test-vue-e2e/action.yml b/.github/workflows/actions/test-vue-e2e/action.yml
index 060e923bdf4..191cd193c8a 100644
--- a/.github/workflows/actions/test-vue-e2e/action.yml
+++ b/.github/workflows/actions/test-vue-e2e/action.yml
@@ -6,7 +6,7 @@ inputs:
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- uses: ./.github/workflows/actions/download-archive
diff --git a/.github/workflows/actions/update-reference-screenshots/action.yml b/.github/workflows/actions/update-reference-screenshots/action.yml
index 51d7bdce508..6ee56689b10 100644
--- a/.github/workflows/actions/update-reference-screenshots/action.yml
+++ b/.github/workflows/actions/update-reference-screenshots/action.yml
@@ -7,7 +7,7 @@ on:
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
- uses: actions/download-artifact@v8
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0fae4854106..64a717080be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,31 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
+## [8.8.5](https://github.com/ionic-team/ionic-framework/compare/v8.8.4...v8.8.5) (2026-04-29)
+
+
+### Bug Fixes
+
+* **modal:** remove safe-area gap and flash in fullscreen modals ([#31092](https://github.com/ionic-team/ionic-framework/issues/31092)) ([f3cd39b](https://github.com/ionic-team/ionic-framework/commit/f3cd39b7fb291286374285c4a326ec6b9a8ea237)), closes [#31015](https://github.com/ionic-team/ionic-framework/issues/31015)
+* **select:** select focused option on Enter in popover and modal interfaces ([#31093](https://github.com/ionic-team/ionic-framework/issues/31093)) ([fd79771](https://github.com/ionic-team/ionic-framework/commit/fd79771e5be77c9f38379a3a7b9ab44bb11ff325)), closes [#30561](https://github.com/ionic-team/ionic-framework/issues/30561)
+
+
+
+
+
+## [8.8.4](https://github.com/ionic-team/ionic-framework/compare/v8.8.3...v8.8.4) (2026-04-15)
+
+
+### Bug Fixes
+
+* **checkbox:** show labels after page navigation ([#31062](https://github.com/ionic-team/ionic-framework/issues/31062)) ([f4ac445](https://github.com/ionic-team/ionic-framework/commit/f4ac4459f8317bd5eeff7d4809f9cb0991c8efd9)), closes [#31052](https://github.com/ionic-team/ionic-framework/issues/31052)
+* **datetime:** multiple month selected and flakiness display ([#31053](https://github.com/ionic-team/ionic-framework/issues/31053)) ([308aef5](https://github.com/ionic-team/ionic-framework/commit/308aef569d8c6ebc3ad2186bca6969da8e4b2a8d))
+* **tab-button:** update dark palette focused background color ([#31050](https://github.com/ionic-team/ionic-framework/issues/31050)) ([dec46b5](https://github.com/ionic-team/ionic-framework/commit/dec46b5d317080dd5d97dc056f0d8e6d4c8c45ac))
+
+
+
+
+
## [8.8.3](https://github.com/ionic-team/ionic-framework/compare/v8.8.2...v8.8.3) (2026-04-01)
diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md
index 1fd2212efdf..851601359f5 100644
--- a/core/CHANGELOG.md
+++ b/core/CHANGELOG.md
@@ -3,6 +3,31 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
+## [8.8.5](https://github.com/ionic-team/ionic-framework/compare/v8.8.4...v8.8.5) (2026-04-29)
+
+
+### Bug Fixes
+
+* **modal:** remove safe-area gap and flash in fullscreen modals ([#31092](https://github.com/ionic-team/ionic-framework/issues/31092)) ([f3cd39b](https://github.com/ionic-team/ionic-framework/commit/f3cd39b7fb291286374285c4a326ec6b9a8ea237)), closes [#31015](https://github.com/ionic-team/ionic-framework/issues/31015)
+* **select:** select focused option on Enter in popover and modal interfaces ([#31093](https://github.com/ionic-team/ionic-framework/issues/31093)) ([fd79771](https://github.com/ionic-team/ionic-framework/commit/fd79771e5be77c9f38379a3a7b9ab44bb11ff325)), closes [#30561](https://github.com/ionic-team/ionic-framework/issues/30561)
+
+
+
+
+
+## [8.8.4](https://github.com/ionic-team/ionic-framework/compare/v8.8.3...v8.8.4) (2026-04-15)
+
+
+### Bug Fixes
+
+* **checkbox:** show labels after page navigation ([#31062](https://github.com/ionic-team/ionic-framework/issues/31062)) ([f4ac445](https://github.com/ionic-team/ionic-framework/commit/f4ac4459f8317bd5eeff7d4809f9cb0991c8efd9)), closes [#31052](https://github.com/ionic-team/ionic-framework/issues/31052)
+* **datetime:** multiple month selected and flakiness display ([#31053](https://github.com/ionic-team/ionic-framework/issues/31053)) ([308aef5](https://github.com/ionic-team/ionic-framework/commit/308aef569d8c6ebc3ad2186bca6969da8e4b2a8d))
+* **tab-button:** update dark palette focused background color ([#31050](https://github.com/ionic-team/ionic-framework/issues/31050)) ([dec46b5](https://github.com/ionic-team/ionic-framework/commit/dec46b5d317080dd5d97dc056f0d8e6d4c8c45ac))
+
+
+
+
+
## [8.8.3](https://github.com/ionic-team/ionic-framework/compare/v8.8.2...v8.8.3) (2026-04-01)
diff --git a/core/package-lock.json b/core/package-lock.json
index 2951570f198..1c77b44a322 100644
--- a/core/package-lock.json
+++ b/core/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@ionic/core",
- "version": "8.8.3",
+ "version": "8.8.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ionic/core",
- "version": "8.8.3",
+ "version": "8.8.5",
"license": "MIT",
"dependencies": {
"@stencil/core": "4.43.0",
@@ -629,9 +629,9 @@
"license": "MIT"
},
"node_modules/@capacitor/core": {
- "version": "8.3.0",
- "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.0.tgz",
- "integrity": "sha512-S4ajn4G/fS3VJj8salxqH/3LO5PPWv1VxGKQ27OCajnDcLJjEg9VXwgMPnlypgkIOqCJ2fmQLtk8GT+BlI9/rw==",
+ "version": "8.3.1",
+ "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.1.tgz",
+ "integrity": "sha512-UF8ItlHguU1Z6GXfPTeT2gakf+ctNI8pAS1kwSBQlsJMlfD4OPoto/SmKnOxKCQvnF4WRcdWeg6C0zREUNaAQg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -649,9 +649,9 @@
}
},
"node_modules/@capacitor/keyboard": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.2.tgz",
- "integrity": "sha512-he6xKmTBp5AhVrWJeEi6RYkJ25FjLLdNruBU2wafpITk3Nb7UdzOj96x3K6etFuEj8/rtn9WXBTs1o2XA86A1A==",
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.3.tgz",
+ "integrity": "sha512-27Bv5/2w1Ss2njguBgTS98O0Bb8DRJhAARyzXYib0JlT/n6BrJw/EZ0CokM4C8GFUjFDjJnEKF1Ie01buTMEXQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
diff --git a/core/package.json b/core/package.json
index 9f1fdcde22f..cded5c1330d 100644
--- a/core/package.json
+++ b/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@ionic/core",
- "version": "8.8.3",
+ "version": "8.8.5",
"description": "Base components for Ionic",
"engines": {
"node": ">= 16"
diff --git a/core/scripts/vercel-build.sh b/core/scripts/vercel-build.sh
new file mode 100755
index 00000000000..daf44142dce
--- /dev/null
+++ b/core/scripts/vercel-build.sh
@@ -0,0 +1,354 @@
+#!/bin/bash
+#
+# Vercel preview build script
+#
+# Builds core component tests (same as before) plus framework test apps
+# (Angular, React, Vue) so they're all accessible from a single preview URL.
+#
+# Core tests: /src/components/{name}/test/{scenario}
+# Angular test app: /angular/
+# React test app: /react/
+# Vue test app: /vue/
+#
+set -e
+
+# Vercel places core/ at /vercel/path1 (bind mount). The full repo clone
+# lives at /vercel/path0. We can't rely on `..` to reach it, so we search.
+CORE_DIR=$(pwd)
+OUTPUT_DIR="${CORE_DIR}/../_vercel_output"
+
+# Find the actual repo root (the directory containing packages/)
+REPO_ROOT=""
+for candidate in /vercel/path0 /vercel/path1 "${CORE_DIR}/.."; do
+ if [ -d "${candidate}/packages" ]; then
+ REPO_ROOT="${candidate}"
+ break
+ fi
+done
+
+echo "=== Ionic Framework Preview Build ==="
+echo "Core dir: ${CORE_DIR}"
+echo "Repo root: ${REPO_ROOT:-NOT FOUND}"
+if [ -z "${REPO_ROOT}" ]; then
+ echo "(This is expected in some Vercel configs -- framework test apps will be skipped)"
+fi
+
+rm -rf "${OUTPUT_DIR}"
+mkdir -p "${OUTPUT_DIR}"
+
+# Step 1 - Build Core (dependencies already installed by Vercel installCommand)
+echo ""
+echo "--- Step 1: Building Core ---"
+npm run build
+
+# Copy core files to output. The test HTML files use relative paths like
+# ../../../../../dist/ionic/ionic.esm.js so the directory structure must
+# be preserved exactly.
+echo "Copying core output..."
+cp -r "${CORE_DIR}/src" "${OUTPUT_DIR}/src"
+cp -r "${CORE_DIR}/dist" "${OUTPUT_DIR}/dist"
+cp -r "${CORE_DIR}/css" "${OUTPUT_DIR}/css"
+mkdir -p "${OUTPUT_DIR}/scripts"
+cp -r "${CORE_DIR}/scripts/testing" "${OUTPUT_DIR}/scripts/testing"
+
+# Generate directory index pages so users can browse core test pages.
+# Creates an index.html in every directory under src/components/ that
+# doesn't already have one. Only includes child directories that eventually
+# lead to a test page (an index.html). Prunes snapshot dirs and dead ends.
+echo "Generating directory indexes for core tests..."
+generate_dir_index() {
+ local dir="$1"
+ local url_path="$2"
+ # Skip if an index.html already exists (it's an actual test page)
+ [ -f "${dir}/index.html" ] && return
+
+ local entries=""
+ for child in "${dir}"/*/; do
+ [ -d "${child}" ] || continue
+ local name=$(basename "${child}")
+ # Skip snapshot directories and hidden dirs
+ case "${name}" in *-snapshots|.*) continue ;; esac
+ # Only include if there's at least one index.html somewhere underneath
+ find "${child}" -name "index.html" -print -quit | grep -q . || continue
+ entries="${entries}${name}/\n"
+ done
+
+ [ -z "${entries}" ] && return
+
+ cat > "${dir}/index.html" << IDXEOF
+
+
+
+
+
+ Index of ${url_path}
+
+
+
+
Index of ${url_path}
+ ../
+$(echo -e "${entries}")
+
+
+IDXEOF
+}
+
+# Walk all directories under src/ (bottom-up so parent indexes reflect pruned children)
+find "${OUTPUT_DIR}/src" -depth -type d | while IFS= read -r dir; do
+ url_path="${dir#${OUTPUT_DIR}}"
+ generate_dir_index "${dir}" "${url_path}/"
+done
+
+# Vercel mounts core/ at a separate path (path1) from the repo clone (path0).
+# Framework packages reference core via relative paths (../../core/css etc.),
+# which resolve to path0/core/ -- not path1/ where we just built.
+# Symlink path0/core -> path1 so those references find the build outputs.
+if [ -n "${REPO_ROOT}" ] && [ "${CORE_DIR}" != "${REPO_ROOT}/core" ] && [ -d "${REPO_ROOT}/core" ]; then
+ echo "Linking ${REPO_ROOT}/core -> ${CORE_DIR} (so framework builds find core outputs)"
+ rm -rf "${REPO_ROOT}/core"
+ ln -s "${CORE_DIR}" "${REPO_ROOT}/core"
+fi
+
+# Check if the full repo is available
+if [ -z "${REPO_ROOT}" ]; then
+ echo ""
+ echo "WARNING: Could not find repo root (no directory with packages/ found)"
+ echo "Only core tests will be deployed (framework test apps require the full repo)."
+
+ # Generate landing page and exit -- core tests are still useful
+ cat > "${OUTPUT_DIR}/index.html" << 'LANDING_EOF'
+
+Ionic Framework - Preview
+
Ionic Framework Preview
Core tests only. Browse to /src/components/{name}/test/{scenario}/
+
+LANDING_EOF
+
+ echo "=== Preview build complete (core only) ==="
+ exit 0
+fi
+
+# Step 2 - Build Framework Packages (parallel)
+echo ""
+echo "--- Step 2: Building Framework Packages ---"
+
+build_angular_pkgs() {
+ (cd "${REPO_ROOT}/packages/angular" && npm install && npm run sync && npm run build) || return 1
+ (cd "${REPO_ROOT}/packages/angular-server" && npm install && npm run build) || return 1
+}
+
+build_react_pkgs() {
+ (cd "${REPO_ROOT}/packages/react" && npm install && npm run sync && npm run build) || return 1
+ (cd "${REPO_ROOT}/packages/react-router" && npm install && npm run build) || return 1
+}
+
+build_vue_pkgs() {
+ (cd "${REPO_ROOT}/packages/vue" && npm install && npm run sync && npm run build) || return 1
+ (cd "${REPO_ROOT}/packages/vue-router" && npm install && npm run build) || return 1
+}
+
+build_angular_pkgs > /tmp/vercel-angular-pkg.log 2>&1 &
+PID_ANG=$!
+build_react_pkgs > /tmp/vercel-react-pkg.log 2>&1 &
+PID_REACT=$!
+build_vue_pkgs > /tmp/vercel-vue-pkg.log 2>&1 &
+PID_VUE=$!
+
+ANG_PKG_OK=true; REACT_PKG_OK=true; VUE_PKG_OK=true
+wait $PID_ANG || { echo "Angular packages failed:"; tail -30 /tmp/vercel-angular-pkg.log; ANG_PKG_OK=false; }
+wait $PID_REACT || { echo "React packages failed:"; tail -30 /tmp/vercel-react-pkg.log; REACT_PKG_OK=false; }
+wait $PID_VUE || { echo "Vue packages failed:"; tail -30 /tmp/vercel-vue-pkg.log; VUE_PKG_OK=false; }
+
+if ! $ANG_PKG_OK || ! $REACT_PKG_OK || ! $VUE_PKG_OK; then
+ echo "ERROR: Some framework package builds failed."
+ echo "Core tests will still be deployed. Skipping failed framework test apps."
+else
+ echo "All framework packages built."
+fi
+
+# Step 3 - Build Framework Test Apps (parallel)
+echo ""
+echo "--- Step 3: Building Framework Test Apps ---"
+
+# Find the best available app version for a given package.
+# Scans the apps/ directory and picks the newest version (reverse version sort).
+pick_app() {
+ local apps_dir="$1/apps"
+ [ -d "${apps_dir}" ] || return 1
+ local app
+ app=$(ls -1d "${apps_dir}"/*/ 2>/dev/null | xargs -n1 basename | sort -V -r | head -1)
+ [ -n "${app}" ] && echo "${app}" && return 0
+ return 1
+}
+
+build_angular_test() {
+ local APP
+ APP=$(pick_app "${REPO_ROOT}/packages/angular/test") || {
+ echo "[angular] No test app found, skipping."
+ return 0
+ }
+ echo "[angular] Building ${APP}..."
+
+ cd "${REPO_ROOT}/packages/angular/test"
+ ./build.sh "${APP}"
+ cd "build/${APP}"
+ npm install
+ npm run sync
+ # --base-href sets so Angular Router works under the sub-path
+ npm run build -- --base-href /angular/
+
+ # Output path assumes the 'browser' builder. If migrated to 'application' builder, update this.
+ if [ ! -d "dist/test-app/browser" ]; then
+ echo "[angular] ERROR: Expected output at dist/test-app/browser/ not found."
+ return 1
+ fi
+ mkdir -p "${OUTPUT_DIR}/angular"
+ cp -r dist/test-app/browser/* "${OUTPUT_DIR}/angular/"
+ echo "[angular] Done."
+}
+
+build_react_test() {
+ local APP
+ APP=$(pick_app "${REPO_ROOT}/packages/react/test") || {
+ echo "[react] No test app found, skipping."
+ return 0
+ }
+ echo "[react] Building ${APP}..."
+
+ cd "${REPO_ROOT}/packages/react/test"
+ ./build.sh "${APP}"
+ cd "build/${APP}"
+ npm install
+ npm run sync
+ # --base sets Vite's base URL; import.meta.env.BASE_URL is read by IonReactRouter basename
+ npx vite build --base /react/
+
+ mkdir -p "${OUTPUT_DIR}/react"
+ cp -r dist/* "${OUTPUT_DIR}/react/"
+ echo "[react] Done."
+}
+
+build_vue_test() {
+ local APP
+ APP=$(pick_app "${REPO_ROOT}/packages/vue/test") || {
+ echo "[vue] No test app found, skipping."
+ return 0
+ }
+ echo "[vue] Building ${APP}..."
+
+ cd "${REPO_ROOT}/packages/vue/test"
+ ./build.sh "${APP}"
+ cd "build/${APP}"
+ npm install
+ npm run sync
+ # Vue Router already reads import.meta.env.BASE_URL which Vite sets from --base
+ npx vite build --base /vue/
+
+ mkdir -p "${OUTPUT_DIR}/vue"
+ cp -r dist/* "${OUTPUT_DIR}/vue/"
+ echo "[vue] Done."
+}
+
+# TODO: Add build_react_router_test() when reactrouter6-* apps are added to
+# packages/react-router/test/apps/
+
+TEST_FAILED=""
+
+if $ANG_PKG_OK; then
+ build_angular_test > /tmp/vercel-angular-test.log 2>&1 &
+ PID_ANG_TEST=$!
+fi
+if $REACT_PKG_OK; then
+ build_react_test > /tmp/vercel-react-test.log 2>&1 &
+ PID_REACT_TEST=$!
+fi
+if $VUE_PKG_OK; then
+ build_vue_test > /tmp/vercel-vue-test.log 2>&1 &
+ PID_VUE_TEST=$!
+fi
+
+if $ANG_PKG_OK; then
+ wait $PID_ANG_TEST || { echo "Angular test app failed:"; tail -30 /tmp/vercel-angular-test.log; TEST_FAILED="${TEST_FAILED} angular"; }
+fi
+if $REACT_PKG_OK; then
+ wait $PID_REACT_TEST || { echo "React test app failed:"; tail -30 /tmp/vercel-react-test.log; TEST_FAILED="${TEST_FAILED} react"; }
+fi
+if $VUE_PKG_OK; then
+ wait $PID_VUE_TEST || { echo "Vue test app failed:"; tail -30 /tmp/vercel-vue-test.log; TEST_FAILED="${TEST_FAILED} vue"; }
+fi
+
+if [ -n "${TEST_FAILED}" ]; then
+ echo ""
+ echo "WARNING: Some test app builds failed:${TEST_FAILED}"
+ echo "Core tests and successful framework apps will still be deployed."
+fi
+
+# Step 4 - Landing Page
+echo ""
+echo "--- Step 4: Generating landing page ---"
+
+cat > "${OUTPUT_DIR}/index.html" << 'LANDING_EOF'
+
+
+
+
+
+ Ionic Framework - Preview
+
+
+
+
+
+
+LANDING_EOF
+
+echo ""
+echo "=== Preview build complete ==="
+ls -la "${OUTPUT_DIR}"
diff --git a/core/src/components/checkbox/checkbox.tsx b/core/src/components/checkbox/checkbox.tsx
index 8a25b9200d5..99cb13c474a 100644
--- a/core/src/components/checkbox/checkbox.tsx
+++ b/core/src/components/checkbox/checkbox.tsx
@@ -151,44 +151,54 @@ export class Checkbox implements ComponentInterface {
connectedCallback() {
const { el } = this;
- // Watch for class changes to update validation state.
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
- this.validationObserver = new MutationObserver(() => {
- const newIsInvalid = checkInvalidState(el);
- if (this.isInvalid !== newIsInvalid) {
- this.isInvalid = newIsInvalid;
- /**
- * Screen readers tend to announce changes
- * to `aria-describedby` when the attribute
- * is changed during a blur event for a
- * native form control.
- * However, the announcement can be spotty
- * when using a non-native form control
- * and `forceUpdate()`.
- * This is due to `forceUpdate()` internally
- * rescheduling the DOM update to a lower
- * priority queue regardless if it's called
- * inside a Promise or not, thus causing
- * the screen reader to potentially miss the
- * change.
- * By using a State variable inside a Promise,
- * it guarantees a re-render immediately at
- * a higher priority.
- */
- Promise.resolve().then(() => {
- this.hintTextId = this.getHintTextId();
- });
+ this.validationObserver = new MutationObserver((mutations) => {
+ // Watch for label content changes
+ if (mutations.some((mutation) => mutation.type === 'characterData' || mutation.type === 'childList')) {
+ this.hasLabelContent = this.el.textContent !== '';
+ }
+ // Watch for class changes to update validation state.
+ if (mutations.some((mutation) => mutation.type === 'attributes' && mutation.target === el)) {
+ const newIsInvalid = checkInvalidState(el);
+ if (this.isInvalid !== newIsInvalid) {
+ this.isInvalid = newIsInvalid;
+ /**
+ * Screen readers tend to announce changes
+ * to `aria-describedby` when the attribute
+ * is changed during a blur event for a
+ * native form control.
+ * However, the announcement can be spotty
+ * when using a non-native form control
+ * and `forceUpdate()`.
+ * This is due to `forceUpdate()` internally
+ * rescheduling the DOM update to a lower
+ * priority queue regardless if it's called
+ * inside a Promise or not, thus causing
+ * the screen reader to potentially miss the
+ * change.
+ * By using a State variable inside a Promise,
+ * it guarantees a re-render immediately at
+ * a higher priority.
+ */
+ Promise.resolve().then(() => {
+ this.hintTextId = this.getHintTextId();
+ });
+ }
}
});
this.validationObserver.observe(el, {
attributes: true,
attributeFilter: ['class'],
+ characterData: true,
+ childList: true,
+ subtree: true,
});
}
// Always set initial state
this.isInvalid = checkInvalidState(el);
+ this.hasLabelContent = this.el.textContent !== '';
}
componentWillLoad() {
@@ -267,10 +277,6 @@ export class Checkbox implements ComponentInterface {
ev.stopPropagation();
};
- private onSlotChange = () => {
- this.hasLabelContent = this.el.textContent !== '';
- };
-
private getHintTextId(): string | undefined {
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;
@@ -387,7 +393,7 @@ export class Checkbox implements ComponentInterface {
id={this.inputLabelId}
onClick={this.onDivLabelClick}
>
-
+
{this.renderHintText()}
diff --git a/core/src/components/content/content.scss b/core/src/components/content/content.scss
index 5f8b2afa831..89dae0aff94 100644
--- a/core/src/components/content/content.scss
+++ b/core/src/components/content/content.scss
@@ -64,7 +64,7 @@
.inner-scroll {
@include position(calc(var(--offset-top) * -1), 0px,calc(var(--offset-bottom) * -1), 0px);
- @include padding(calc(var(--padding-top) + var(--offset-top)), var(--padding-end), calc(var(--padding-bottom) + var(--keyboard-offset) + var(--offset-bottom)), var(--padding-start));
+ @include padding(calc(var(--padding-top) + var(--offset-top)), var(--padding-end), calc(var(--padding-bottom) + var(--keyboard-offset) + var(--offset-bottom) + var(--ion-content-safe-area-padding-bottom, 0px)), var(--padding-start));
position: absolute;
diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx
index c591972fd8d..e73cd55e0b8 100644
--- a/core/src/components/datetime/datetime.tsx
+++ b/core/src/components/datetime/datetime.tsx
@@ -1086,6 +1086,9 @@ export class Datetime implements ComponentInterface {
connectedCallback() {
this.clearFocusVisible = startFocusVisible(this.el).destroy;
+ this.loadTimeout = setTimeout(() => {
+ this.ensureReadyIfVisible();
+ }, 100);
}
disconnectedCallback() {
@@ -1093,9 +1096,7 @@ export class Datetime implements ComponentInterface {
this.clearFocusVisible();
this.clearFocusVisible = undefined;
}
- if (this.loadTimeout) {
- clearTimeout(this.loadTimeout);
- }
+ this.loadTimeoutCleanup();
}
/**
@@ -1146,6 +1147,13 @@ export class Datetime implements ComponentInterface {
});
};
+ private loadTimeoutCleanup = () => {
+ if (this.loadTimeout) {
+ clearTimeout(this.loadTimeout);
+ this.loadTimeout = undefined;
+ }
+ };
+
componentDidLoad() {
const { el, intersectionTrackerRef } = this;
@@ -1193,7 +1201,10 @@ export class Datetime implements ComponentInterface {
* we still initialize listeners and mark the component as ready.
*
* We schedule this after everything has had a chance to run.
+ *
+ * We also clean up the load timeout to ensure that we don't have multiple timeouts running.
*/
+ this.loadTimeoutCleanup();
this.loadTimeout = setTimeout(() => {
this.ensureReadyIfVisible();
}, 100);
diff --git a/core/src/components/datetime/test/basic/datetime.e2e.ts b/core/src/components/datetime/test/basic/datetime.e2e.ts
index 6104d0014cf..4865e243092 100644
--- a/core/src/components/datetime/test/basic/datetime.e2e.ts
+++ b/core/src/components/datetime/test/basic/datetime.e2e.ts
@@ -349,6 +349,43 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
});
});
+/**
+ * This behavior does not differ across
+ * modes/directions.
+ */
+
+configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
+ test.describe(title('datetime: month picker selection'), () => {
+ test('datetime: month picker selection', async ({ page }) => {
+ await page.setContent(
+ `
+
+ `,
+ config
+ );
+
+ await page.locator('.datetime-ready').waitFor();
+
+ const nextMonthButton = page.locator('ion-datetime .calendar-next-prev ion-button').nth(1);
+ const monthYearButton = page.locator('ion-datetime .calendar-month-year');
+
+ await expect(monthYearButton).toHaveText(/May 2022/);
+
+ await nextMonthButton.click();
+ await expect(monthYearButton).toHaveText(/June 2022/);
+
+ await nextMonthButton.click();
+ await expect(monthYearButton).toHaveText(/July 2022/);
+
+ await monthYearButton.click();
+ await page.waitForChanges();
+
+ const selectedMonthOptions = page.locator('.month-column ion-picker-column-option.option-active');
+ await expect(selectedMonthOptions).toHaveCount(1);
+ });
+ });
+});
+
/**
* This behavior does not differ across
* modes/directions.
@@ -403,7 +440,10 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('datetime: IO fallback'), () => {
- test('should become ready even if IntersectionObserver never reports visible', async ({ page }, testInfo) => {
+ test('should become ready even if IntersectionObserver never reports visible', async ({ page, skip }, testInfo) => {
+ // TODO(FW-7284): Re-enable on WebKit after determining why it fails
+ skip.browser('webkit', 'Wheel is not available in WebKit');
+
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30706',
diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx
index 15865b1fb8a..8d4719a48f5 100644
--- a/core/src/components/modal/modal.tsx
+++ b/core/src/components/modal/modal.tsx
@@ -51,6 +51,7 @@ import {
applySafeAreaOverrides,
clearSafeAreaOverrides,
getRootSafeAreaTop,
+ hasCustomModalDimensions,
type ModalSafeAreaContext,
} from './safe-area-utils';
import { setCardStatusBarDark, setCardStatusBarDefault } from './utils';
@@ -312,12 +313,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
if (!context.isSheetModal && !context.isCardModal) {
this.updateSafeAreaOverrides();
- // Re-evaluate fullscreen safe-area padding: clear first, then re-apply
- if (this.wrapperEl) {
- this.wrapperEl.style.removeProperty('height');
- this.wrapperEl.style.removeProperty('padding-bottom');
- }
- this.applyFullscreenSafeArea();
+ // Re-evaluate fullscreen safe-area padding: clear first, then re-apply.
+ const { contentEl, hasFooter } = this.findContentAndFooter();
+ this.clearContentSafeAreaPadding(contentEl);
+ this.applyFullscreenSafeAreaTo(contentEl, hasFooter);
}
}, 50); // Debounce to avoid excessive calls during active resizing
}
@@ -1435,6 +1434,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
/**
* Creates the context object for safe-area utilities.
+ *
+ * `hasCustomDimensions` is only set by `setInitialSafeAreaOverrides()`
+ * because it is only read by `getInitialSafeAreaConfig()`. Other callers
+ * (resize handler, post-animation update, fullscreen-padding apply) would
+ * pay a `getComputedStyle()` cost for a value they never consult.
*/
private getSafeAreaContext(): ModalSafeAreaContext {
return {
@@ -1457,7 +1461,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
* sheets to prevent header content from getting double-offset padding).
*/
private setInitialSafeAreaOverrides(): void {
- const context = this.getSafeAreaContext();
+ const context: ModalSafeAreaContext = {
+ ...this.getSafeAreaContext(),
+ hasCustomDimensions: hasCustomModalDimensions(this.el),
+ };
const safeAreaConfig = getInitialSafeAreaConfig(context);
applySafeAreaOverrides(this.el, safeAreaConfig);
@@ -1502,48 +1509,77 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
/**
- * Applies padding-bottom to fullscreen modal wrapper to prevent
- * content from overlapping system navigation bar.
+ * Applies safe-area-bottom scroll padding to ion-content inside
+ * fullscreen modals that have no ion-footer. This prevents content
+ * from being hidden behind the system navigation bar while keeping
+ * the modal background edge-to-edge (no visible gap).
*/
private applyFullscreenSafeArea(): void {
- const { wrapperEl, el } = this;
- if (!wrapperEl) return;
-
const context = this.getSafeAreaContext();
if (context.isSheetModal || context.isCardModal) return;
- // Check for standard Ionic layout children (ion-content, ion-footer),
- // searching one level deep for wrapped components (e.g.,
- // ...).
- // Note: uses a manual loop instead of querySelector(':scope > ...') because
- // Stencil's mock-doc (used in spec tests) does not support :scope.
- let hasContent = false;
+ const { contentEl, hasFooter } = this.findContentAndFooter();
+ this.applyFullscreenSafeAreaTo(contentEl, hasFooter);
+ }
+
+ /**
+ * Sets --ion-content-safe-area-padding-bottom on the given ion-content
+ * when no footer is present, so ion-content's .inner-scroll includes
+ * safe-area-bottom in its scroll padding. This keeps the modal background
+ * edge-to-edge while ensuring content scrolls clear of the system nav bar.
+ *
+ * --ion-content-safe-area-padding-bottom is an internal CSS property used
+ * only by this code path. It is not part of ion-content's public API and
+ * should not be set by consumers. The default of 0px makes it a no-op
+ * when unset, which is the expected state for ion-content used outside of
+ * a fullscreen modal without a footer.
+ */
+ private applyFullscreenSafeAreaTo(contentEl: HTMLElement | null, hasFooter: boolean): void {
+ // Only apply for standard Ionic layouts (has ion-content but no
+ // ion-footer). When a footer is present it handles its own safe-area
+ // padding. Custom modals with raw HTML are developer-controlled.
+ if (!contentEl || hasFooter) return;
+
+ contentEl.style.setProperty('--ion-content-safe-area-padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
+ }
+
+ /**
+ * Removes the internal --ion-content-safe-area-padding-bottom property
+ * from an already-located ion-content. Callers do their own
+ * findContentAndFooter() so they can also read hasFooter if needed.
+ */
+ private clearContentSafeAreaPadding(contentEl: HTMLElement | null): void {
+ if (!contentEl) return;
+ contentEl.style.removeProperty('--ion-content-safe-area-padding-bottom');
+ }
+
+ /**
+ * Finds ion-content and ion-footer among direct children and one level of
+ * grandchildren (for wrapped components like ).
+ *
+ * Intentionally does NOT use findIonContent() or querySelector() because
+ * those search the full subtree and would match ion-content inside nested
+ * routes/pages. We only want direct slot children (+ one wrapper level).
+ *
+ * Uses a manual loop instead of querySelector(':scope > ...') because
+ * Stencil's mock-doc (used in spec tests) does not support :scope.
+ */
+ private findContentAndFooter(): { contentEl: HTMLElement | null; hasFooter: boolean } {
+ let contentEl: HTMLElement | null = null;
let hasFooter = false;
- for (const child of Array.from(el.children)) {
- if (child.tagName === 'ION-CONTENT') hasContent = true;
+ for (const child of Array.from(this.el.children)) {
+ if (child.tagName === 'ION-CONTENT') contentEl = child as HTMLElement;
if (child.tagName === 'ION-FOOTER') hasFooter = true;
for (const grandchild of Array.from(child.children)) {
- if (grandchild.tagName === 'ION-CONTENT') hasContent = true;
+ if (grandchild.tagName === 'ION-CONTENT' && !contentEl) contentEl = grandchild as HTMLElement;
if (grandchild.tagName === 'ION-FOOTER') hasFooter = true;
}
}
-
- // Only apply wrapper padding for standard Ionic layouts (has ion-content
- // but no ion-footer). Custom modals with raw HTML are fully
- // developer-controlled and should not be modified.
- if (!hasContent || hasFooter) return;
-
- // Reduce wrapper height by safe-area and add equivalent padding so the
- // total visual size stays the same but the flex content area shrinks.
- // Using height + padding instead of box-sizing: border-box avoids
- // breaking custom modals that set --border-width (border-box would
- // include the border inside the height, changing the layout).
- wrapperEl.style.setProperty('height', 'calc(var(--height) - var(--ion-safe-area-bottom, 0px))');
- wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
+ return { contentEl, hasFooter };
}
/**
- * Clears all safe-area overrides and padding from wrapper.
+ * Clears all safe-area overrides and padding.
*/
private cleanupSafeAreaOverrides(): void {
clearSafeAreaOverrides(this.el);
@@ -1551,10 +1587,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
// Remove internal sheet offset property
this.el.style.removeProperty('--ion-modal-offset-top');
- if (this.wrapperEl) {
- this.wrapperEl.style.removeProperty('height');
- this.wrapperEl.style.removeProperty('padding-bottom');
- }
+ const { contentEl } = this.findContentAndFooter();
+ this.clearContentSafeAreaPadding(contentEl);
}
render() {
diff --git a/core/src/components/modal/safe-area-utils.ts b/core/src/components/modal/safe-area-utils.ts
index a13bf1770a0..59ae3a4fdb5 100644
--- a/core/src/components/modal/safe-area-utils.ts
+++ b/core/src/components/modal/safe-area-utils.ts
@@ -23,6 +23,11 @@ export interface ModalSafeAreaContext {
presentingElement?: HTMLElement;
breakpoints?: number[];
currentBreakpoint?: number;
+ /**
+ * Only consulted by `getInitialSafeAreaConfig()`. Callers that only use the
+ * context for non-initial paths can omit this. See `hasCustomModalDimensions()`.
+ */
+ hasCustomDimensions?: boolean;
}
/**
@@ -38,6 +43,13 @@ const MODAL_INSET_MIN_WIDTH = 768;
const MODAL_INSET_MIN_HEIGHT = 600;
const EDGE_THRESHOLD = 5;
+/**
+ * CSS values for `--width` / `--height` that are treated as fullscreen
+ * (modal touches the corresponding screen edges). Empty string means the
+ * property was not overridden. See `hasCustomModalDimensions()`.
+ */
+const FULLSCREEN_SIZE_VALUES = new Set(['', '100%', '100vw', '100vh', '100dvw', '100dvh', '100svw', '100svh']);
+
/**
* Cache for resolved root safe-area-top value, invalidated once per frame.
*/
@@ -92,6 +104,23 @@ export const getRootSafeAreaTop = (): number => {
return value;
};
+/**
+ * True when the modal host declares BOTH a non-fullscreen `--width` AND a
+ * non-fullscreen `--height` (i.e. a centered-dialog-like modal that doesn't
+ * touch any screen edge).
+ *
+ * The conservative "both axes" check avoids mis-zeroing safe-area for
+ * partial-custom modals where the modal still touches top/bottom edges
+ * (e.g. only `--width` overridden). Partial cases fall through to the
+ * existing position-based post-animation correction.
+ */
+export const hasCustomModalDimensions = (hostEl: HTMLElement): boolean => {
+ const styles = getComputedStyle(hostEl);
+ const width = styles.getPropertyValue('--width').trim();
+ const height = styles.getPropertyValue('--height').trim();
+ return !FULLSCREEN_SIZE_VALUES.has(width) && !FULLSCREEN_SIZE_VALUES.has(height);
+};
+
/**
* Returns the initial safe-area configuration based on modal type.
* This is called before animation starts and uses configuration-based prediction.
@@ -129,8 +158,11 @@ export const getInitialSafeAreaConfig = (context: ModalSafeAreaContext): SafeAre
// On viewports that meet the centered dialog media query breakpoints,
// regular modals render as centered dialogs (not fullscreen), so they
- // don't touch any screen edges and don't need safe-area insets.
- if (isCenteredDialogViewport()) {
+ // don't touch any screen edges and don't need safe-area insets. Also
+ // applies to phone viewports when the modal declares custom --width and
+ // --height; these don't touch screen edges either, so the initial
+ // prediction must be zero to avoid a post-animation correction flash.
+ if (isCenteredDialogViewport() || context.hasCustomDimensions) {
return {
top: '0px',
bottom: '0px',
diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts b/core/src/components/modal/test/safe-area/modal.e2e.ts
index af2a58c7699..a65b6f3fee8 100644
--- a/core/src/components/modal/test/safe-area/modal.e2e.ts
+++ b/core/src/components/modal/test/safe-area/modal.e2e.ts
@@ -13,7 +13,11 @@ import { configs, test, Viewports } from '@utils/test/playwright';
* The test page (index.html) sets these root safe-area values.
* Keep in sync with the :root block in test/safe-area/index.html.
*/
-const TEST_SAFE_AREA_TOP = '47px';
+const TEST_SAFE_AREA_TOP = 47;
+const TEST_SAFE_AREA_BOTTOM = 34;
+/** Default value of --ion-padding (16px), applied via the .ion-padding class on ion-content in the test modal. */
+const TEST_ION_PADDING = 16;
+
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area handling'), () => {
test.beforeEach(async ({ page }) => {
@@ -100,10 +104,12 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config
expect(safeAreaBottom).toBe('inherit');
});
- test('fullscreen modal without footer should have wrapper padding-bottom', async ({ page }, testInfo) => {
+ test('fullscreen modal without footer should set safe-area scroll padding on ion-content', async ({
+ page,
+ }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
- description: 'https://github.com/ionic-team/ionic-framework/issues/30900',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/31015',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
@@ -113,20 +119,83 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config
const modal = page.locator('ion-modal');
- // When no footer is present, the wrapper should have reduced height
- // and padding-bottom to prevent content from overlapping the system
- // navigation bar, without changing box-sizing (which would break
- // custom modals with --border-width).
+ // The wrapper should NOT have reduced height or padding-bottom.
+ // Safe-area compensation is handled by ion-content's scroll padding.
const wrapper = modal.locator('.modal-wrapper');
- const paddingBottom = await wrapper.evaluate((el: HTMLElement) => {
+ const wrapperPaddingBottom = await wrapper.evaluate((el: HTMLElement) => {
return el.style.getPropertyValue('padding-bottom');
});
- const height = await wrapper.evaluate((el: HTMLElement) => {
+ const wrapperHeight = await wrapper.evaluate((el: HTMLElement) => {
return el.style.getPropertyValue('height');
});
- expect(paddingBottom).toBe('var(--ion-safe-area-bottom, 0px)');
- expect(height).toBe('calc(var(--height) - var(--ion-safe-area-bottom, 0px))');
+ expect(wrapperPaddingBottom).toBe('');
+ expect(wrapperHeight).toBe('');
+
+ // ion-content should have --ion-content-safe-area-padding-bottom set so its
+ // .inner-scroll element includes safe-area in its bottom padding.
+ const content = modal.locator('ion-content');
+ const safeAreaPadding = await content.evaluate((el: HTMLElement) => {
+ return el.style.getPropertyValue('--ion-content-safe-area-padding-bottom');
+ });
+ expect(safeAreaPadding).toBe('var(--ion-safe-area-bottom, 0px)');
+ });
+
+ test('fullscreen modal with ion-content and no footer should not reduce wrapper content area', async ({
+ page,
+ }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/31015',
+ });
+
+ const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
+
+ await page.click('#fullscreen-modal-no-footer');
+ await ionModalDidPresent.next();
+
+ const modal = page.locator('ion-modal');
+ const wrapper = modal.locator('.modal-wrapper');
+
+ // The wrapper's content area should equal the full viewport height.
+ // Safe-area compensation is handled by ion-content's scroll padding,
+ // not by reducing the wrapper. This prevents the visible white gap
+ // reported in #31015.
+ const { contentHeight, paddingBottom } = await wrapper.evaluate((el: HTMLElement) => {
+ const computed = getComputedStyle(el);
+ return {
+ contentHeight: parseFloat(computed.height),
+ paddingBottom: parseFloat(computed.paddingBottom),
+ };
+ });
+ const viewportHeight = await page.evaluate(() => window.innerHeight);
+
+ expect(paddingBottom).toBeCloseTo(0, 0);
+ expect(contentHeight).toBeCloseTo(viewportHeight, 0);
+ });
+
+ test('fullscreen modal ion-content scroll padding should include safe-area-bottom', async ({ page }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/31015',
+ });
+
+ const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
+
+ await page.click('#fullscreen-modal-no-footer');
+ await ionModalDidPresent.next();
+
+ const modal = page.locator('ion-modal');
+ const content = modal.locator('ion-content');
+
+ // The .inner-scroll element inside ion-content's shadow DOM should
+ // have padding-bottom that includes the safe-area-bottom value.
+ const innerScroll = content.locator('.inner-scroll');
+ const scrollPaddingBottom = await innerScroll.evaluate((el: Element) => {
+ return parseFloat(getComputedStyle(el).paddingBottom);
+ });
+
+ expect(scrollPaddingBottom).toBe(TEST_ION_PADDING + TEST_SAFE_AREA_BOTTOM);
});
test('sheet modal at breakpoint 1 should keep top safe-area zeroed', async ({ page }, testInfo) => {
@@ -185,7 +254,7 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config
const offsetTop = await modal.evaluate((el: HTMLIonModalElement) => {
return el.style.getPropertyValue('--ion-modal-offset-top');
});
- expect(offsetTop).toBe(TEST_SAFE_AREA_TOP);
+ expect(offsetTop).toBe(`${TEST_SAFE_AREA_TOP}px`);
});
test('fullscreen modal safe-area should update on resize from phone to tablet', async ({ page }, testInfo) => {
@@ -251,6 +320,35 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config
expect(safeAreaBottom).toBe('0px');
});
+ test('centered dialog with custom dimensions on phone should zero safe-area from initial prediction', async ({
+ page,
+ }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/31015',
+ });
+
+ // Stay on phone viewport. This is the path where the centered-dialog
+ // media query does NOT match but the modal still doesn't touch screen
+ // edges because cssClass sets --width/--height. Without the initial
+ // prediction catching this, safe-area flashes inherited values and
+ // then snaps to 0px after animation.
+ const ionModalWillPresent = await page.spyOnEvent('ionModalWillPresent');
+ await page.click('#centered-dialog');
+ await ionModalWillPresent.next();
+
+ // Read inline style IMMEDIATELY after will-present fires, before the
+ // animation finishes. This captures the initial prediction value.
+ const modal = page.locator('ion-modal');
+ const initial = await modal.evaluate((el: HTMLIonModalElement) => ({
+ top: el.style.getPropertyValue('--ion-safe-area-top'),
+ bottom: el.style.getPropertyValue('--ion-safe-area-bottom'),
+ }));
+
+ expect(initial.top).toBe('0px');
+ expect(initial.bottom).toBe('0px');
+ });
+
test('safe-area overrides should be cleared on dismiss', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
diff --git a/core/src/components/picker-column/picker-column.tsx b/core/src/components/picker-column/picker-column.tsx
index 905ba8cf81e..53d6ca90d17 100644
--- a/core/src/components/picker-column/picker-column.tsx
+++ b/core/src/components/picker-column/picker-column.tsx
@@ -1,7 +1,7 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core';
import { doc } from '@utils/browser';
-import { getElementRoot, raf } from '@utils/helpers';
+import { raf } from '@utils/helpers';
import { hapticSelectionChanged, hapticSelectionEnd, hapticSelectionStart } from '@utils/native/haptic';
import { isPlatform } from '@utils/platform';
import { createColorClasses } from '@utils/theme';
@@ -122,9 +122,7 @@ export class PickerColumn implements ComponentInterface {
* Because this initial call to scrollActiveItemIntoView has to fire before
* the scroll listener is set up, we need to manage the active class manually.
*/
- const oldActive = getElementRoot(el).querySelector(
- `.${PICKER_ITEM_ACTIVE_CLASS}`
- );
+ const oldActive = el.querySelector(`.${PICKER_ITEM_ACTIVE_CLASS}`);
if (oldActive) {
this.setPickerItemActiveState(oldActive, false);
}
diff --git a/core/src/components/radio-group/radio-group.tsx b/core/src/components/radio-group/radio-group.tsx
index 88ff48e2c4c..37e0f0da0c9 100644
--- a/core/src/components/radio-group/radio-group.tsx
+++ b/core/src/components/radio-group/radio-group.tsx
@@ -290,6 +290,19 @@ export class RadioGroup implements ComponentInterface {
// to the bottom of the screen
ev.preventDefault();
}
+
+ // Inside a select interface, Enter commits the focused radio
+ // value (matching native