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 70dff8db874..034cfdce747 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 84abbbee409..64a717080be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ 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) diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index ee45ffd629a..851601359f5 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -3,6 +3,18 @@ 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) diff --git a/core/package-lock.json b/core/package-lock.json index 007fed4a238..9f0ef9cadb1 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/core", - "version": "8.8.4", + "version": "8.8.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/core", - "version": "8.8.4", + "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 4321a90686f..fbdf88d9ecb 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/core", - "version": "8.8.4", + "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 + + + +
+

Ionic Framework Preview

+

Component test apps for manual validation

+
+ +

Core Components

+

Browse to /src/components/{name}/test/{scenario}/

+
+
+ +

Angular

+

@ionic/angular standalone + lazy-loaded component tests

+
+ +

React

+

@ionic/react overlays, hooks, tabs, form controls

+
+ +

Vue

+

@ionic/vue overlays, router, tabs, lifecycle

+
+
+
+ + +LANDING_EOF + +echo "" +echo "=== Preview build complete ===" +ls -la "${OUTPUT_DIR}" 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/modal/modal.tsx b/core/src/components/modal/modal.tsx index 9ba3ecbf009..5bbb67d3d61 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -50,6 +50,7 @@ import { applySafeAreaOverrides, clearSafeAreaOverrides, getRootSafeAreaTop, + hasCustomModalDimensions, type ModalSafeAreaContext, } from './safe-area-utils'; import { setCardStatusBarDark, setCardStatusBarDefault } from './utils'; @@ -311,12 +312,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 } @@ -1429,6 +1428,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 { @@ -1451,7 +1455,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); @@ -1496,48 +1503,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); @@ -1545,10 +1581,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/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