diff --git a/.github/workflows/actions/build-core-stencil-prerelease/action.yml b/.github/workflows/actions/build-core-stencil-prerelease/action.yml index 742c45a9b68..c5704075859 100644 --- a/.github/workflows/actions/build-core-stencil-prerelease/action.yml +++ b/.github/workflows/actions/build-core-stencil-prerelease/action.yml @@ -8,7 +8,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - 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 0f712bda8f4..b2e79a0387c 100644 --- a/.github/workflows/actions/build-core/action.yml +++ b/.github/workflows/actions/build-core/action.yml @@ -8,7 +8,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24.x diff --git a/.github/workflows/assign-issues.yml b/.github/workflows/assign-issues.yml index d79a8c17c2c..fc254b78976 100644 --- a/.github/workflows/assign-issues.yml +++ b/.github/workflows/assign-issues.yml @@ -11,7 +11,7 @@ jobs: issues: write steps: - name: 'Auto-assign issue' - uses: pozil/auto-assign-issue@70adb98ca8b3941524e9ecde48e89067c4f96736 # v3.0.0 + uses: pozil/auto-assign-issue@af6beea6bdf1e8eb373f061c5bc168681fc6d011 # v4.0.1 with: assignees: brandyscarney, thetaPC, ShaneK numOfAssignee: 1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7772b2c432f..97abe2fedf8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: build-core: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-core with: ionicons-version: ${{ inputs.ionicons_npm_release_tag }} @@ -31,21 +31,21 @@ jobs: needs: [build-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-core-clean-build test-core-lint: needs: [build-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-core-lint test-core-spec: needs: [build-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-core-spec test-core-screenshot: @@ -62,7 +62,7 @@ jobs: needs: [build-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-core-screenshot with: shard: ${{ matrix.shard }} @@ -90,14 +90,14 @@ jobs: needs: [build-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-vue build-vue-router: needs: [build-vue] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-vue-router test-vue-e2e: @@ -108,7 +108,7 @@ jobs: needs: [build-vue, build-vue-router] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-vue-e2e with: app: ${{ matrix.apps }} @@ -126,14 +126,14 @@ jobs: needs: [build-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-angular build-angular-server: needs: [build-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-angular-server test-angular-e2e: @@ -144,7 +144,7 @@ jobs: needs: [build-angular, build-angular-server] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-angular-e2e with: app: ${{ matrix.apps }} @@ -162,14 +162,14 @@ jobs: needs: [build-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-react build-react-router: needs: [build-react] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-react-router test-react-router-e2e: @@ -180,7 +180,7 @@ jobs: needs: [build-react, build-react-router] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-react-router-e2e with: app: ${{ matrix.apps }} @@ -202,7 +202,7 @@ jobs: needs: [build-react, build-react-router] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-react-e2e with: app: ${{ matrix.apps }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 5e6715d02b1..6d0aea91073 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -14,7 +14,7 @@ jobs: permissions: security-events: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: github/codeql-action/init@v4 with: languages: javascript diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index 51806128a8e..fec61782048 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -13,7 +13,7 @@ jobs: outputs: dev-hash: ${{ steps.create-dev-hash.outputs.DEV_HASH }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # A 1 is required before the timestamp # as lerna will fail when there is a leading 0 # See https://github.com/lerna/lerna/issues/2840 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2f64d9f9bd5..ac48da4ab94 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -13,7 +13,7 @@ jobs: outputs: nightly-hash: ${{ steps.create-nightly-hash.outputs.NIGHTLY_HASH }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # A 1 is required before the timestamp # as lerna will fail when there is a leading 0 # See https://github.com/lerna/lerna/issues/2840 diff --git a/.github/workflows/release-ionic.yml b/.github/workflows/release-ionic.yml index 70f1fb1956b..f430d6573f8 100644 --- a/.github/workflows/release-ionic.yml +++ b/.github/workflows/release-ionic.yml @@ -23,7 +23,7 @@ jobs: release-core: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/actions/publish-npm with: scope: '@ionic/core' @@ -48,7 +48,7 @@ jobs: needs: [release-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore @ionic/docs built cache uses: ./.github/workflows/actions/download-archive with: @@ -67,7 +67,7 @@ jobs: needs: [release-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore @ionic/core built cache uses: ./.github/workflows/actions/download-archive with: @@ -93,7 +93,7 @@ jobs: needs: [release-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore @ionic/core built cache uses: ./.github/workflows/actions/download-archive with: @@ -118,7 +118,7 @@ jobs: needs: [release-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore @ionic/core built cache uses: ./.github/workflows/actions/download-archive with: @@ -143,7 +143,7 @@ jobs: needs: [release-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore @ionic/core built cache uses: ./.github/workflows/actions/download-archive with: @@ -163,7 +163,7 @@ jobs: needs: [release-react] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore @ionic/core built cache uses: ./.github/workflows/actions/download-archive with: @@ -188,7 +188,7 @@ jobs: needs: [release-vue] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Restore @ionic/core built cache uses: ./.github/workflows/actions/download-archive with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df4429a806b..a28539f6f73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: contents: write id-token: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: token: ${{ secrets.IONITRON_TOKEN }} fetch-depth: 0 @@ -89,7 +89,7 @@ jobs: contents: write id-token: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # Pull the latest version of the reference # branch instead of the revision that triggered # the workflow otherwise we won't get the commit diff --git a/.github/workflows/stencil-nightly.yml b/.github/workflows/stencil-nightly.yml index 3e8d714ccc1..764a6d1c823 100644 --- a/.github/workflows/stencil-nightly.yml +++ b/.github/workflows/stencil-nightly.yml @@ -26,7 +26,7 @@ jobs: build-core-with-stencil-nightly: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-core-stencil-prerelease with: stencil-version: ${{ inputs.npm_release_tag || 'nightly' }} @@ -35,21 +35,21 @@ jobs: needs: [build-core-with-stencil-nightly] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-core-clean-build test-core-lint: needs: [build-core-with-stencil-nightly] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-core-lint test-core-spec: needs: [build-core-with-stencil-nightly] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-core-spec with: stencil-version: ${{ inputs.npm_release_tag || 'nightly' }} @@ -72,7 +72,7 @@ jobs: needs: [build-core-with-stencil-nightly] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-core-screenshot with: shard: ${{ matrix.shard }} @@ -100,14 +100,14 @@ jobs: needs: [build-core-with-stencil-nightly] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-vue build-vue-router: needs: [build-vue] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-vue-router test-vue-e2e: @@ -118,7 +118,7 @@ jobs: needs: [build-vue, build-vue-router] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-vue-e2e with: app: ${{ matrix.apps }} @@ -136,14 +136,14 @@ jobs: needs: [build-core-with-stencil-nightly] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-angular build-angular-server: needs: [build-core-with-stencil-nightly] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-angular-server test-angular-e2e: @@ -154,7 +154,7 @@ jobs: needs: [build-angular, build-angular-server] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-angular-e2e with: app: ${{ matrix.apps }} @@ -172,14 +172,14 @@ jobs: needs: [build-core-with-stencil-nightly] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-react build-react-router: needs: [build-react] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-react-router test-react-router-e2e: @@ -190,7 +190,7 @@ jobs: needs: [build-react, build-react-router] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-react-router-e2e with: app: ${{ matrix.apps }} @@ -212,7 +212,7 @@ jobs: needs: [build-react, build-react-router] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-react-e2e with: app: ${{ matrix.apps }} diff --git a/.github/workflows/update-screenshots.yml b/.github/workflows/update-screenshots.yml index 6efecbd8f95..aeab13d9fb0 100644 --- a/.github/workflows/update-screenshots.yml +++ b/.github/workflows/update-screenshots.yml @@ -26,7 +26,7 @@ jobs: build-core: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/build-core test-core-screenshot: @@ -47,7 +47,7 @@ jobs: needs: [build-core] runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: ./.github/workflows/actions/test-core-screenshot with: shard: ${{ matrix.shard }} @@ -59,7 +59,7 @@ jobs: runs-on: ubuntu-latest needs: [test-core-screenshot] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # Normally, we could just push with the # default GITHUB_TOKEN, but that will # not cause the build workflow diff --git a/CHANGELOG.md b/CHANGELOG.md index 508426b3d07..538f88377e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,40 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [8.8.11](https://github.com/ionic-team/ionic-framework/compare/v8.8.10...v8.8.11) (2026-06-17) + + +### Bug Fixes + +* **react:** remove relocated inline overlays orphaned on unmount ([#31223](https://github.com/ionic-team/ionic-framework/issues/31223)) ([d28d25b](https://github.com/ionic-team/ionic-framework/commit/d28d25b798d32db6148155bb34fdbd13243286d9)) +* **searchbar:** fix search icon sometimes being offset incorrectly ([#31212](https://github.com/ionic-team/ionic-framework/issues/31212)) ([3394c30](https://github.com/ionic-team/ionic-framework/commit/3394c305b5141326cc045132507dff62dd0e4dd6)), closes [#30434](https://github.com/ionic-team/ionic-framework/issues/30434) + + + + + +## [8.8.10](https://github.com/ionic-team/ionic-framework/compare/v8.8.9...v8.8.10) (2026-06-10) + + +### Bug Fixes + +* **modal:** prevent sheet gesture crash with late-bound breakpoints ([#31202](https://github.com/ionic-team/ionic-framework/issues/31202)) ([c61265a](https://github.com/ionic-team/ionic-framework/commit/c61265adb5891b24d4d413245244ee8dcf61757e)) + + + + + +## [8.8.9](https://github.com/ionic-team/ionic-framework/compare/v8.8.8...v8.8.9) (2026-06-03) + + +### Bug Fixes + +* **router:** support anchor fragments in href ([#31172](https://github.com/ionic-team/ionic-framework/issues/31172)) ([a982516](https://github.com/ionic-team/ionic-framework/commit/a982516afad1e848534db702694ef47f5e49c78d)), closes [#19566](https://github.com/ionic-team/ionic-framework/issues/19566) [#19365](https://github.com/ionic-team/ionic-framework/issues/19365) + + + + + ## [8.8.8](https://github.com/ionic-team/ionic-framework/compare/v8.8.7...v8.8.8) (2026-05-20) diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index e9b40761785..3a594bdaad0 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -3,6 +3,39 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [8.8.11](https://github.com/ionic-team/ionic-framework/compare/v8.8.10...v8.8.11) (2026-06-17) + + +### Bug Fixes + +* **searchbar:** fix search icon sometimes being offset incorrectly ([#31212](https://github.com/ionic-team/ionic-framework/issues/31212)) ([3394c30](https://github.com/ionic-team/ionic-framework/commit/3394c305b5141326cc045132507dff62dd0e4dd6)), closes [#30434](https://github.com/ionic-team/ionic-framework/issues/30434) + + + + + +## [8.8.10](https://github.com/ionic-team/ionic-framework/compare/v8.8.9...v8.8.10) (2026-06-10) + + +### Bug Fixes + +* **modal:** prevent sheet gesture crash with late-bound breakpoints ([#31202](https://github.com/ionic-team/ionic-framework/issues/31202)) ([c61265a](https://github.com/ionic-team/ionic-framework/commit/c61265adb5891b24d4d413245244ee8dcf61757e)) + + + + + +## [8.8.9](https://github.com/ionic-team/ionic-framework/compare/v8.8.8...v8.8.9) (2026-06-03) + + +### Bug Fixes + +* **router:** support anchor fragments in href ([#31172](https://github.com/ionic-team/ionic-framework/issues/31172)) ([a982516](https://github.com/ionic-team/ionic-framework/commit/a982516afad1e848534db702694ef47f5e49c78d)), closes [#19566](https://github.com/ionic-team/ionic-framework/issues/19566) [#19365](https://github.com/ionic-team/ionic-framework/issues/19365) + + + + + ## [8.8.8](https://github.com/ionic-team/ionic-framework/compare/v8.8.7...v8.8.8) (2026-05-20) **Note:** Version bump only for package @ionic/core diff --git a/core/Dockerfile b/core/Dockerfile index b5a20843d1b..380dcab8f91 100644 --- a/core/Dockerfile +++ b/core/Dockerfile @@ -1,5 +1,5 @@ # Get Playwright -FROM mcr.microsoft.com/playwright:v1.59.1 +FROM mcr.microsoft.com/playwright:v1.61.0 # Set the working directory WORKDIR /ionic diff --git a/core/api.txt b/core/api.txt index b936224df2d..e85dc69c312 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1044,13 +1044,24 @@ ion-footer,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-footer,prop,translucent,boolean,false,false,false ion-gallery,shadow -ion-gallery,prop,columns,GalleryBreakpoints | number | string,DEFAULT_COLUMNS,false,false -ion-gallery,prop,gap,GalleryBreakpoints | number | string,DEFAULT_GAP,false,false +ion-gallery,prop,columns,GalleryBreakpoints | number | string,{ + xs: 2, + sm: 3, + md: 4, + lg: 6, + xl: 8, + xxl: 10, +},false,false +ion-gallery,prop,gap,GalleryBreakpoints | number | string,'16px',false,false ion-gallery,prop,layout,"masonry" | "uniform",'uniform',false,false ion-gallery,prop,mode,"ios" | "md",undefined,false,false ion-gallery,prop,order,"best-fit" | "sequential" | undefined,undefined,false,false ion-gallery,prop,theme,"ios" | "md" | "ionic",undefined,false,false +ion-gallery-item,shadow +ion-gallery-item,prop,mode,"ios" | "md",undefined,false,false +ion-gallery-item,prop,theme,"ios" | "md" | "ionic",undefined,false,false + ion-grid,shadow ion-grid,prop,fixed,boolean,false,false,false ion-grid,prop,mode,"ios" | "md",undefined,false,false @@ -2588,6 +2599,8 @@ ion-select-modal,prop,options,SelectModalOption[],[],false,false ion-select-option,shadow ion-select-option,prop,description,string | undefined,undefined,false,false ion-select-option,prop,disabled,boolean,false,false,false +ion-select-option,prop,justify,"end" | "space-between" | "start" | undefined,undefined,false,false +ion-select-option,prop,labelPlacement,"end" | "start" | undefined,undefined,false,false ion-select-option,prop,mode,"ios" | "md",undefined,false,false ion-select-option,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-select-option,prop,value,any,undefined,false,false @@ -2635,7 +2648,7 @@ ion-split-pane,prop,contentId,string | undefined,undefined,false,true ion-split-pane,prop,disabled,boolean,false,false,false ion-split-pane,prop,mode,"ios" | "md",undefined,false,false ion-split-pane,prop,theme,"ios" | "md" | "ionic",undefined,false,false -ion-split-pane,prop,when,boolean | string,QUERY['lg'],false,false +ion-split-pane,prop,when,boolean | string,'(min-width: 992px)',false,false ion-split-pane,event,ionSplitPaneVisible,{ visible: boolean; },true ion-split-pane,css-prop,--border,ionic ion-split-pane,css-prop,--border,ios diff --git a/core/package-lock.json b/core/package-lock.json index 972c0b16f25..32f7109923a 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,16 +1,16 @@ { "name": "@ionic/core", - "version": "8.8.8", + "version": "8.8.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/core", - "version": "8.8.8", + "version": "8.8.11", "license": "MIT", "dependencies": { "@phosphor-icons/core": "^2.1.1", - "@stencil/core": "4.43.0", + "@stencil/core": "4.43.5", "ionicons": "^8.0.13", "tslib": "^2.1.0" }, @@ -22,7 +22,7 @@ "@capacitor/status-bar": "^8.0.0", "@ionic/eslint-config": "^0.3.0", "@ionic/prettier-config": "^2.0.0", - "@playwright/test": "^1.59.1", + "@playwright/test": "^1.61.0", "@rollup/plugin-node-resolve": "^8.4.0", "@rollup/plugin-virtual": "^2.0.3", "@stencil/angular-output-target": "^0.10.0", @@ -45,7 +45,7 @@ "jest": "^29.7.0", "jest-cli": "^29.7.0", "outsystems-design-tokens": "^1.3.9", - "playwright-core": "^1.59.1", + "playwright-core": "^1.60.0", "prettier": "^2.8.8", "rollup": "^2.26.4", "sass": "^1.33.0", @@ -736,9 +736,9 @@ } }, "node_modules/@capacitor/keyboard": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.3.tgz", - "integrity": "sha512-27Bv5/2w1Ss2njguBgTS98O0Bb8DRJhAARyzXYib0JlT/n6BrJw/EZ0CokM4C8GFUjFDjJnEKF1Ie01buTMEXQ==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.5.tgz", + "integrity": "sha512-oFXygC4eKYA5l2MdpTR06L2M/4x6e2SLD5yS1T9+UBDKTkzyvhWKEhbYLUaTIBPpLKqlfGudJw1X73S1H9eUzQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2342,13 +2342,13 @@ "license": "MIT" }, "node_modules/@playwright/test": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", - "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.59.1" + "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -2402,7 +2402,9 @@ } }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.34.9", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz", + "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==", "cpu": [ "arm64" ], @@ -2413,9 +2415,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz", - "integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz", + "integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==", "cpu": [ "x64" ], @@ -2426,9 +2428,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz", - "integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz", + "integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==", "cpu": [ "arm64" ], @@ -2439,9 +2441,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz", - "integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz", + "integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==", "cpu": [ "arm64" ], @@ -2452,9 +2454,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz", - "integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz", + "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==", "cpu": [ "x64" ], @@ -2465,9 +2467,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz", - "integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz", + "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==", "cpu": [ "x64" ], @@ -2478,9 +2480,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz", - "integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz", + "integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==", "cpu": [ "arm64" ], @@ -2491,9 +2493,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz", - "integrity": "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz", + "integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==", "cpu": [ "x64" ], @@ -2533,9 +2535,9 @@ } }, "node_modules/@stencil/core": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.43.0.tgz", - "integrity": "sha512-6Uj2Z3lzLuufYAE7asZ6NLKgSwsB9uxl84Eh34PASnUjfj32GkrP4DtKK7fNeh1WFGGyffsTDka3gwtl+4reUg==", + "version": "4.43.5", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.43.5.tgz", + "integrity": "sha512-cgWD+GeuvJpTe1WQn40p02+BJ2j0j1YJ17GdkF2qKIQ23s2e3Zivq5yISXS3dcuV6oUJFN93jprdk+nk/sq99Q==", "license": "MIT", "peer": true, "bin": { @@ -2546,14 +2548,14 @@ "npm": ">=7.10.0" }, "optionalDependencies": { - "@rollup/rollup-darwin-arm64": "4.34.9", - "@rollup/rollup-darwin-x64": "4.34.9", - "@rollup/rollup-linux-arm64-gnu": "4.34.9", - "@rollup/rollup-linux-arm64-musl": "4.34.9", - "@rollup/rollup-linux-x64-gnu": "4.34.9", - "@rollup/rollup-linux-x64-musl": "4.34.9", - "@rollup/rollup-win32-arm64-msvc": "4.34.9", - "@rollup/rollup-win32-x64-msvc": "4.34.9" + "@rollup/rollup-darwin-arm64": "4.44.0", + "@rollup/rollup-darwin-x64": "4.44.0", + "@rollup/rollup-linux-arm64-gnu": "4.44.0", + "@rollup/rollup-linux-arm64-musl": "4.44.0", + "@rollup/rollup-linux-x64-gnu": "4.44.0", + "@rollup/rollup-linux-x64-musl": "4.44.0", + "@rollup/rollup-win32-arm64-msvc": "4.44.0", + "@rollup/rollup-win32-x64-msvc": "4.44.0" } }, "node_modules/@stencil/react-output-target": { @@ -9420,13 +9422,13 @@ } }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -9439,9 +9441,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", "dev": true, "license": "Apache-2.0", "peer": true, diff --git a/core/package.json b/core/package.json index a5640b43851..c405d715c73 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/core", - "version": "8.8.8", + "version": "8.8.11", "description": "Base components for Ionic", "engines": { "node": ">= 16" @@ -35,7 +35,7 @@ ], "dependencies": { "@phosphor-icons/core": "^2.1.1", - "@stencil/core": "4.43.0", + "@stencil/core": "4.43.5", "ionicons": "^8.0.13", "tslib": "^2.1.0" }, @@ -47,7 +47,7 @@ "@capacitor/status-bar": "^8.0.0", "@ionic/eslint-config": "^0.3.0", "@ionic/prettier-config": "^2.0.0", - "@playwright/test": "^1.59.1", + "@playwright/test": "^1.61.0", "@rollup/plugin-node-resolve": "^8.4.0", "@rollup/plugin-virtual": "^2.0.3", "@stencil/angular-output-target": "^0.10.0", @@ -70,7 +70,7 @@ "jest": "^29.7.0", "jest-cli": "^29.7.0", "outsystems-design-tokens": "^1.3.9", - "playwright-core": "^1.59.1", + "playwright-core": "^1.60.0", "prettier": "^2.8.8", "rollup": "^2.26.4", "sass": "^1.33.0", diff --git a/core/scripts/testing/scripts.js b/core/scripts/testing/scripts.js index 1e0d7418c41..cb8c6d604db 100644 --- a/core/scripts/testing/scripts.js +++ b/core/scripts/testing/scripts.js @@ -118,14 +118,27 @@ const DEFAULT_PALETTE = 'light'; paletteName = DEFAULT_PALETTE; } + /* + * Tracks when the asynchronous theme token loading has finished injecting + * the component theme CSS. Tests wait on this (see the Playwright `goto` + * helper) so screenshots are not taken before the themed CSS variables are + * available, which would render components unstyled. + */ + window.__ionicTestThemeReady = false; + // Load theme tokens if the theme is valid const validThemes = ['ionic', 'ios', 'md']; if (themeName && validThemes.includes(themeName)) { - loadThemeTokens(themeName, paletteName); - } else if(themeName) { - console.warn( - `Unsupported theme "${themeName}". Supported themes are: ${validThemes.join(', ')}. Defaulting to ${DEFAULT_THEME}.` - ); + loadThemeTokens(themeName, paletteName).finally(() => { + window.__ionicTestThemeReady = true; + }); + } else { + if (themeName) { + console.warn( + `Unsupported theme "${themeName}". Supported themes are: ${validThemes.join(', ')}. Defaulting to ${DEFAULT_THEME}.` + ); + } + window.__ionicTestThemeReady = true; } /** @@ -148,6 +161,25 @@ const DEFAULT_PALETTE = 'light'; return result; }; + /* + * Resolves once the Ionic Config instance (created by `initialize()` in + * ionic-global.ts) is available. If the app has already loaded we resolve + * immediately; otherwise we wait for the `appload` event, which fires after + * `initialize()` has set up the config. This is event-driven rather than + * polled, so there's no arbitrary timeout. JavaScript's single-threaded + * execution guarantees `appload` cannot fire between the synchronous check + * and `addEventListener`, so there is no missed-event race. + */ + function whenConfigReady() { + return new Promise((resolve) => { + if (window.testAppLoaded === true || window.Ionic?.config?.set) { + resolve(); + } else { + window.addEventListener('appload', () => resolve(), { once: true }); + } + }); + } + // TODO(FW-6750): Determine if this function can be removed once the theme tokens can be imported directly into the test pages async function loadThemeTokens(themeName, paletteName) { try { @@ -156,7 +188,6 @@ const DEFAULT_PALETTE = 'light'; // Load the default tokens for the theme const defaultTokens = await import(`/themes/${themeName}/default.tokens.js`); let theme = defaultTokens.defaultTheme; - // Merge with existing theme to preserve any customizations if (customTheme) { theme = deepMerge(theme, customTheme); @@ -175,39 +206,36 @@ const DEFAULT_PALETTE = 'light'; theme.palette.highContrastDark.enabled = 'always'; } - if (window.Ionic?.config?.set) { - /** - * New Page Load after Initial App Load or Playwright Test: - * - * If the Config instance exists, we must use the - * `set()` method. This ensures the internal private Map inside - * the `Config` class is updated with the loaded theme tokens. - * Without this, components would read 'undefined' or 'base' - * values from the stale Map when trying to access them through - * methods like `config.get()`. - */ - window.Ionic.config.set('customTheme', theme); - } else { - /** - * App Initialization or Browser Refresh: - * - * If the Config instance doesn't exist yet, - * we attach the theme to the global Ionic object. The `initialize()` - * method in `ionic-global.ts` will later merge this into the new - * `Config` instance via `config.reset()`. - */ - window.Ionic = window.Ionic || {}; - window.Ionic.config = window.Ionic.config || {}; + window.Ionic = window.Ionic || {}; + window.Ionic.config = window.Ionic.config || {}; + + /** + * App Initialization or Browser Refresh: + * + * If the Config instance doesn't exist yet, stash the theme on the + * global Ionic object so `initialize()` in ionic-global.ts can merge it + * into the new Config instance via `config.reset()`. + */ + if (!window.Ionic.config.set) { window.Ionic.config.customTheme = theme; } /** - * Re-applying the global theme is critical for Playwright tests. - * Even if the config is set, the CSS variables for the specific theme - * (e.g., md or ios) must be force-injected into the document head to - * ensure visual assertions pass correctly. + * Wait for the Config instance to be created by `initialize()`, then set + * the theme and force-inject the global and component CSS ourselves. + * + * This avoids a race: if `initialize()` runs before this async import + * resolves, it applies the base theme (which has no component tokens) and + * replaces the global Ionic.config, orphaning the stash above. By always + * applying here once the Config instance exists, the component CSS + * variables (e.g. --ion-badge-*) are reliably injected regardless of + * ordering, instead of flaky unstyled renders. */ - if (window.Ionic?.config?.get && window.Ionic?.config?.set) { + await whenConfigReady(); + + if (window.Ionic?.config?.set) { + window.Ionic.config.set('customTheme', theme); + const themeModule = await import('/themes/utils/theme.js'); themeModule.applyGlobalTheme(theme); themeModule.applyComponentsTheme(theme); diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 8595d453044..661ab287668 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1474,12 +1474,12 @@ export namespace Components { interface IonGallery { /** * The number of columns to display. Can be set as a number or an object of breakpoint values (e.g. `{ xs: 2, sm: 3, md: 4 }`). - * @default DEFAULT_COLUMNS + * @default { xs: 2, sm: 3, md: 4, lg: 6, xl: 8, xxl: 10, } */ "columns": GalleryColumns; /** - * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) values like `16px`, `1rem`, `20%`, math functions like `calc(10px + 20%)`, or numbers (treated as pixel values). Can also be set as a breakpoint map (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept space-separated values or CSS keyword values like `inherit`, `auto`, etc. - * @default DEFAULT_GAP + * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) values like `16px`, `1rem`, `20%`, math functions like `calc(10px + 20%)`, CSS variables like `var(--app-gallery-gap)`, or numbers (treated as pixel values). Can also be set as a breakpoint map (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept space-separated values or CSS keyword values like `inherit`, `auto`, etc. + * @default '16px' */ "gap": GalleryGap; /** @@ -1500,6 +1500,20 @@ export namespace Components { */ "theme"?: "ios" | "md" | "ionic"; } + interface IonGalleryItem { + /** + * The mode determines the platform behaviors of the component. + */ + "mode"?: "ios" | "md"; + /** + * Resolve the layout from the parent `ion-gallery`. Called internally on load and connect, and by the gallery when its layout changes. + */ + "syncGalleryLayout": () => Promise; + /** + * The theme determines the visual appearance of the component. + */ + "theme"?: "ios" | "md" | "ionic"; + } interface IonGrid { /** * If `true`, the grid will have a fixed width based on the screen size. @@ -3835,6 +3849,14 @@ export namespace Components { * @default false */ "disabled": boolean; + /** + * How to pack the label and the option's selection control within a line. `"start"`: The label and radio will appear on the left in LTR and on the right in RTL. `"end"`: The label and radio will appear on the right in LTR and on the left in RTL. `"space-between"`: The label and radio will appear on opposite ends of the line with space between the two elements. Applies to the `alert`, `popover`, and `modal` interfaces, but has no visible effect on radio options in `popover` or `modal` on the `md` and `ionic` themes (the radio control is hidden there). When unset, the interface picks a default based on theme and control type. + */ + "justify"?: 'start' | 'end' | 'space-between'; + /** + * Where the label is placed relative to the option's selection control (radio circle or checkbox box) when the option is rendered in an `alert`, `popover`, or `modal` interface. `"start"`: The label will appear to the left of the radio in LTR and to the right in RTL. `"end"`: The label will appear to the right of the radio in LTR and to the left in RTL. Applies to the `alert`, `popover`, and `modal` interfaces, but has no visible effect on radio options in `popover` or `modal` on the `md` and `ionic` themes (the radio control is hidden there). When unset, the interface picks a default based on theme and control type. + */ + "labelPlacement"?: 'start' | 'end'; /** * The mode determines the platform behaviors of the component. */ @@ -3938,7 +3960,7 @@ export namespace Components { "theme"?: "ios" | "md" | "ionic"; /** * When the split-pane should be shown. Can be a CSS media query expression, or a shortcut expression. Can also be a boolean expression. - * @default QUERY['lg'] + * @default '(min-width: 992px)' */ "when": string | boolean; } @@ -4998,6 +5020,12 @@ declare global { prototype: HTMLIonGalleryElement; new (): HTMLIonGalleryElement; }; + interface HTMLIonGalleryItemElement extends Components.IonGalleryItem, HTMLStencilElement { + } + var HTMLIonGalleryItemElement: { + prototype: HTMLIonGalleryItemElement; + new (): HTMLIonGalleryItemElement; + }; interface HTMLIonGridElement extends Components.IonGrid, HTMLStencilElement { } var HTMLIonGridElement: { @@ -5956,6 +5984,7 @@ declare global { "ion-fab-list": HTMLIonFabListElement; "ion-footer": HTMLIonFooterElement; "ion-gallery": HTMLIonGalleryElement; + "ion-gallery-item": HTMLIonGalleryItemElement; "ion-grid": HTMLIonGridElement; "ion-header": HTMLIonHeaderElement; "ion-img": HTMLIonImgElement; @@ -7462,12 +7491,12 @@ declare namespace LocalJSX { interface IonGallery { /** * The number of columns to display. Can be set as a number or an object of breakpoint values (e.g. `{ xs: 2, sm: 3, md: 4 }`). - * @default DEFAULT_COLUMNS + * @default { xs: 2, sm: 3, md: 4, lg: 6, xl: 8, xxl: 10, } */ "columns"?: GalleryColumns; /** - * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) values like `16px`, `1rem`, `20%`, math functions like `calc(10px + 20%)`, or numbers (treated as pixel values). Can also be set as a breakpoint map (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept space-separated values or CSS keyword values like `inherit`, `auto`, etc. - * @default DEFAULT_GAP + * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) values like `16px`, `1rem`, `20%`, math functions like `calc(10px + 20%)`, CSS variables like `var(--app-gallery-gap)`, or numbers (treated as pixel values). Can also be set as a breakpoint map (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept space-separated values or CSS keyword values like `inherit`, `auto`, etc. + * @default '16px' */ "gap"?: GalleryGap; /** @@ -7488,6 +7517,16 @@ declare namespace LocalJSX { */ "theme"?: "ios" | "md" | "ionic"; } + interface IonGalleryItem { + /** + * The mode determines the platform behaviors of the component. + */ + "mode"?: "ios" | "md"; + /** + * The theme determines the visual appearance of the component. + */ + "theme"?: "ios" | "md" | "ionic"; + } interface IonGrid { /** * If `true`, the grid will have a fixed width based on the screen size. @@ -9914,6 +9953,14 @@ declare namespace LocalJSX { * @default false */ "disabled"?: boolean; + /** + * How to pack the label and the option's selection control within a line. `"start"`: The label and radio will appear on the left in LTR and on the right in RTL. `"end"`: The label and radio will appear on the right in LTR and on the left in RTL. `"space-between"`: The label and radio will appear on opposite ends of the line with space between the two elements. Applies to the `alert`, `popover`, and `modal` interfaces, but has no visible effect on radio options in `popover` or `modal` on the `md` and `ionic` themes (the radio control is hidden there). When unset, the interface picks a default based on theme and control type. + */ + "justify"?: 'start' | 'end' | 'space-between'; + /** + * Where the label is placed relative to the option's selection control (radio circle or checkbox box) when the option is rendered in an `alert`, `popover`, or `modal` interface. `"start"`: The label will appear to the left of the radio in LTR and to the right in RTL. `"end"`: The label will appear to the right of the radio in LTR and to the left in RTL. Applies to the `alert`, `popover`, and `modal` interfaces, but has no visible effect on radio options in `popover` or `modal` on the `md` and `ionic` themes (the radio control is hidden there). When unset, the interface picks a default based on theme and control type. + */ + "labelPlacement"?: 'start' | 'end'; /** * The mode determines the platform behaviors of the component. */ @@ -10024,7 +10071,7 @@ declare namespace LocalJSX { "theme"?: "ios" | "md" | "ionic"; /** * When the split-pane should be shown. Can be a CSS media query expression, or a shortcut expression. Can also be a boolean expression. - * @default QUERY['lg'] + * @default '(min-width: 992px)' */ "when"?: string | boolean; } @@ -11275,6 +11322,8 @@ declare namespace LocalJSX { "disabled": boolean; "value": string; "description": string; + "labelPlacement": 'start' | 'end'; + "justify": 'start' | 'end' | 'space-between'; } interface IonSelectPopoverAttributes { "header": string; @@ -11433,6 +11482,7 @@ declare namespace LocalJSX { "ion-fab-list": Omit & { [K in keyof IonFabList & keyof IonFabListAttributes]?: IonFabList[K] } & { [K in keyof IonFabList & keyof IonFabListAttributes as `attr:${K}`]?: IonFabListAttributes[K] } & { [K in keyof IonFabList & keyof IonFabListAttributes as `prop:${K}`]?: IonFabList[K] }; "ion-footer": Omit & { [K in keyof IonFooter & keyof IonFooterAttributes]?: IonFooter[K] } & { [K in keyof IonFooter & keyof IonFooterAttributes as `attr:${K}`]?: IonFooterAttributes[K] } & { [K in keyof IonFooter & keyof IonFooterAttributes as `prop:${K}`]?: IonFooter[K] }; "ion-gallery": Omit & { [K in keyof IonGallery & keyof IonGalleryAttributes]?: IonGallery[K] } & { [K in keyof IonGallery & keyof IonGalleryAttributes as `attr:${K}`]?: IonGalleryAttributes[K] } & { [K in keyof IonGallery & keyof IonGalleryAttributes as `prop:${K}`]?: IonGallery[K] }; + "ion-gallery-item": IonGalleryItem; "ion-grid": Omit & { [K in keyof IonGrid & keyof IonGridAttributes]?: IonGrid[K] } & { [K in keyof IonGrid & keyof IonGridAttributes as `attr:${K}`]?: IonGridAttributes[K] } & { [K in keyof IonGrid & keyof IonGridAttributes as `prop:${K}`]?: IonGrid[K] }; "ion-header": Omit & { [K in keyof IonHeader & keyof IonHeaderAttributes]?: IonHeader[K] } & { [K in keyof IonHeader & keyof IonHeaderAttributes as `attr:${K}`]?: IonHeaderAttributes[K] } & { [K in keyof IonHeader & keyof IonHeaderAttributes as `prop:${K}`]?: IonHeader[K] }; "ion-img": Omit & { [K in keyof IonImg & keyof IonImgAttributes]?: IonImg[K] } & { [K in keyof IonImg & keyof IonImgAttributes as `attr:${K}`]?: IonImgAttributes[K] } & { [K in keyof IonImg & keyof IonImgAttributes as `prop:${K}`]?: IonImg[K] }; @@ -11538,6 +11588,7 @@ declare module "@stencil/core" { "ion-fab-list": LocalJSX.IntrinsicElements["ion-fab-list"] & JSXBase.HTMLAttributes; "ion-footer": LocalJSX.IntrinsicElements["ion-footer"] & JSXBase.HTMLAttributes; "ion-gallery": LocalJSX.IntrinsicElements["ion-gallery"] & JSXBase.HTMLAttributes; + "ion-gallery-item": LocalJSX.IntrinsicElements["ion-gallery-item"] & JSXBase.HTMLAttributes; "ion-grid": LocalJSX.IntrinsicElements["ion-grid"] & JSXBase.HTMLAttributes; "ion-header": LocalJSX.IntrinsicElements["ion-header"] & JSXBase.HTMLAttributes; "ion-img": LocalJSX.IntrinsicElements["ion-img"] & JSXBase.HTMLAttributes; diff --git a/core/src/components/action-sheet/action-sheet.common.scss b/core/src/components/action-sheet/action-sheet.common.scss index 482ba792a87..9f8523b2538 100644 --- a/core/src/components/action-sheet/action-sheet.common.scss +++ b/core/src/components/action-sheet/action-sheet.common.scss @@ -1,4 +1,5 @@ -@import "./action-sheet.vars"; +@use "../../themes/mixins" as mixins; +@use "../select-option/select-option.common.overlay"; // Action Sheet // -------------------------------------------------- @@ -41,25 +42,22 @@ --button-color-hover: var(--button-color); --button-color-selected: var(--button-color); --min-width: auto; - --width: #{$action-sheet-width}; - --max-width: #{$action-sheet-max-width}; + --width: 100%; + --max-width: 500px; --min-height: auto; --height: auto; --max-height: calc(100% - (var(--ion-safe-area-top) + var(--ion-safe-area-bottom))); - @include font-smoothing(); - @include position(0, 0, 0, 0); + @include mixins.font-smoothing(); + @include mixins.position(0, 0, 0, 0); display: block; position: fixed; outline: none; - font-family: $font-family-base; - touch-action: none; user-select: none; - z-index: $z-index-overlay; } :host(.overlay-hidden) { @@ -67,8 +65,8 @@ } .action-sheet-wrapper { - @include position(null, 0, 0, 0); - @include transform(translate3d(0, 100%, 0)); + @include mixins.position(null, 0, 0, 0); + @include mixins.transform(translate3d(0, 100%, 0)); display: block; position: absolute; @@ -81,7 +79,6 @@ min-height: var(--min-height); max-height: var(--max-height); - z-index: $z-index-overlay-wrapper; pointer-events: none; } @@ -109,6 +106,10 @@ opacity: 0.4; } +.action-sheet-button:disabled ion-icon { + color: currentColor; +} + .action-sheet-button-inner { display: flex; @@ -176,7 +177,7 @@ // -------------------------------------------------- .action-sheet-button::after { - @include button-state(); + @include mixins.button-state(); } // Action Sheet: Selected @@ -208,7 +209,7 @@ // Action Sheet: Focused // -------------------------------------------------- -.action-sheet-button.ion-focused { +.action-sheet-button.ion-focused:not(.ion-activated) { color: var(--button-color-focused); &::after { @@ -216,6 +217,12 @@ opacity: var(--button-background-focused-opacity); } + + &.action-sheet-selected::after { + background: var(--button-background-focused, var(--button-background-selected)); + + opacity: var(--button-background-focused-opacity, var(--button-background-selected-opacity)); + } } // Action Sheet: Hover @@ -232,20 +239,3 @@ } } } - -// Action Sheet: Select Option -// -------------------------------------------------- - -.action-sheet-button-label { - display: flex; - - align-items: center; -} - -.select-option-content { - flex: 1; -} - -.select-option-description { - display: block; -} diff --git a/core/src/components/action-sheet/action-sheet.ionic.scss b/core/src/components/action-sheet/action-sheet.ionic.scss index b2c749d4e0a..14f12768d9e 100644 --- a/core/src/components/action-sheet/action-sheet.ionic.scss +++ b/core/src/components/action-sheet/action-sheet.ionic.scss @@ -1,22 +1,110 @@ @use "../../themes/ionic/ionic.globals.scss" as globals; +@use "../../themes/mixins" as mixins; +@use "../select-option/select-option.ionic.overlay"; @use "./action-sheet.common"; -@use "./action-sheet.md" as action-sheet-md; // Ionic Action Sheet // -------------------------------------------------- -// Action Sheet: Select Option -// -------------------------------------------------- +:host { + --background: #{globals.$ion-bg-surface-default}; + --backdrop-opacity: 0.7; + --button-background: transparent; + --button-background-selected: #{globals.$ion-bg-primary-subtle-default}; + --button-background-selected-opacity: 1; + --button-background-activated: #{globals.$ion-bg-neutral-subtlest-press}; + --button-background-activated-opacity: 1; + --button-background-hover: #{globals.$ion-bg-neutral-subtlest-press}; + --button-background-hover-opacity: 1; + --button-color: #{globals.$ion-text-default}; + --button-color-disabled: #{globals.$ion-text-disabled}; + --color: #{globals.$ion-text-default}; + + z-index: 1001; +} + +.action-sheet-wrapper { + z-index: 10; +} + +.action-sheet-button.ion-focused::after { + @include globals.focused-state( + globals.$ion-border-size-050, + globals.$ion-border-style-solid, + globals.$ion-border-focus-default + ); + + outline-offset: calc(globals.$ion-border-size-050 * -1); +} + +// Action Sheet Wrapper +// ----------------------------------------- -.action-sheet-button-label { - gap: globals.$ion-space-300; +.action-sheet-wrapper { + @include mixins.margin(var(--ion-safe-area-top, 0), auto, 0, auto); } -.select-option-description { +.action-sheet-title { + @include mixins.padding(globals.$ion-space-400); + @include globals.typography(globals.$ion-heading-h6-medium); + + color: var(--color); + + text-align: start; +} + +.action-sheet-sub-title { @include globals.typography(globals.$ion-body-md-regular); - @include globals.padding(0); - color: globals.$ion-text-subtle; + color: globals.$ion-text-subtlest; +} + +// Action Sheet Group +// ----------------------------------------- + +.action-sheet-group:first-child { + @include mixins.padding(globals.$ion-space-400, null, null, null); +} + +.action-sheet-group:last-child { + @include mixins.padding(null, null, globals.$ion-space-400, null); +} + +// Action Sheet Buttons +// ----------------------------------------- + +.action-sheet-button { + @include mixins.padding( + globals.$ion-space-200, + globals.$ion-space-400, + globals.$ion-space-200, + globals.$ion-space-400 + ); + @include globals.typography(globals.$ion-body-md-regular); + + position: relative; + + min-height: 52px; + + text-align: start; + + contain: content; + overflow: hidden; +} + +.action-sheet-icon { + @include mixins.margin(globals.$ion-space-0, globals.$ion-space-600, globals.$ion-space-0, globals.$ion-space-0); + + width: globals.$ion-scale-600; + height: globals.$ion-scale-600; + + color: var(--color, globals.$ion-text-default); +} + +.action-sheet-button-inner { + justify-content: flex-start; +} - font-size: globals.$ion-font-size-350; +.action-sheet-selected { + font-weight: bold; } diff --git a/core/src/components/action-sheet/action-sheet.ios.scss b/core/src/components/action-sheet/action-sheet.ios.scss index 94b98447981..94d5682f29a 100644 --- a/core/src/components/action-sheet/action-sheet.ios.scss +++ b/core/src/components/action-sheet/action-sheet.ios.scss @@ -1,3 +1,4 @@ +@use "../select-option/select-option.ios.overlay"; @import "./action-sheet.native"; @import "./action-sheet.ios.vars"; diff --git a/core/src/components/action-sheet/action-sheet.md.scss b/core/src/components/action-sheet/action-sheet.md.scss index e46f06085b3..567cfb4448f 100644 --- a/core/src/components/action-sheet/action-sheet.md.scss +++ b/core/src/components/action-sheet/action-sheet.md.scss @@ -1,7 +1,8 @@ +@use "../select-option/select-option.md.overlay"; @import "./action-sheet.native"; @import "./action-sheet.md.vars"; -// Material Design Action Sheet Title +// Material Design Action Sheet // ----------------------------------------- :host { diff --git a/core/src/components/action-sheet/action-sheet.native.scss b/core/src/components/action-sheet/action-sheet.native.scss index affa6aeb126..a4975e5620e 100644 --- a/core/src/components/action-sheet/action-sheet.native.scss +++ b/core/src/components/action-sheet/action-sheet.native.scss @@ -1,19 +1,15 @@ -@use "../../themes/native/native.theme.default" as native; -@use "../../themes/mixins" as mixins; -@use "../../themes/functions.font" as font; +@use "../../themes/native/native.globals" as native; @use "./action-sheet.common"; // Action Sheet: Native // -------------------------------------------------- -.action-sheet-button-label { - gap: 12px; -} - -.select-option-description { - @include mixins.padding(5px, 0, 0, 0); +:host { + font-family: native.$font-family-base; - color: native.$text-color-step-300; + z-index: native.$z-index-overlay; +} - font-size: font.dynamic-font(12px); +.action-sheet-wrapper { + z-index: native.$z-index-overlay-wrapper; } diff --git a/core/src/components/action-sheet/action-sheet.tsx b/core/src/components/action-sheet/action-sheet.tsx index 7012980439f..1a4922f5746 100644 --- a/core/src/components/action-sheet/action-sheet.tsx +++ b/core/src/components/action-sheet/action-sheet.tsx @@ -642,6 +642,7 @@ export class ActionSheet implements ComponentInterface, OverlayInterface { class="action-sheet-group" ref={(el) => (this.groupEl = el)} role={hasRadioButtons ? 'radiogroup' : undefined} + data-roving-focus={hasRadioButtons ? true : undefined} > {header !== undefined && (
diff --git a/core/src/components/action-sheet/test/basic/index.html b/core/src/components/action-sheet/test/basic/index.html index b95d43b42c7..73f78ca635d 100644 --- a/core/src/components/action-sheet/test/basic/index.html +++ b/core/src/components/action-sheet/test/basic/index.html @@ -14,7 +14,7 @@ @@ -46,6 +46,8 @@ .my-color-class { --background: #292929; --button-background-selected: #222222; + --button-background-activated: #393838; + --button-background-activated-opacity: 1; --color: #dfdfdf; --button-color: #dfdfdf; diff --git a/core/src/components/action-sheet/test/standalone/index.html b/core/src/components/action-sheet/test/standalone/index.html index b40c03c53c2..4ee6b77bf7e 100644 --- a/core/src/components/action-sheet/test/standalone/index.html +++ b/core/src/components/action-sheet/test/standalone/index.html @@ -14,7 +14,7 @@ diff --git a/core/src/components/action-sheet/test/states/action-sheet.e2e.ts b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts new file mode 100644 index 00000000000..6929878f12b --- /dev/null +++ b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts @@ -0,0 +1,40 @@ +import { configs, test } from '@utils/test/playwright'; + +import { ActionSheetFixture } from '../basic/fixture'; + +/** + * This behavior does not vary across directions. + */ +configs({ directions: ['ltr'], modes: ['ios', 'md', 'ionic-md'] }).forEach(({ config, screenshot, title }) => { + test.describe(title('action sheet: states'), () => { + /** + * `(any-hover: hover)` evaluates to "none" in all three mobile-emulated + * projects, suppressing the hover rules: + * + * - Chromium and WebKit suppress it because of hasTouch and isMobile. + * - Headless Firefox doesn't detect input devices and reports no hover + * capability regardless of context options, so override it via + * launchOptions.firefoxUserPrefs. Bit values: 4 = FINE (mouse), + * 8 = HOVER, 12 = FINE | HOVER. + * + * Viewport, userAgent, and scale factor remain mobile-sized. + */ + test.use({ + hasTouch: false, + isMobile: false, + }); + + test('should render all button states', async ({ page }) => { + await page.goto(`/src/components/action-sheet/test/states`, config); + + const actionSheetFixture = new ActionSheetFixture(page, screenshot); + + await actionSheetFixture.open('#basic'); + + const defaultButton = page.locator('ion-action-sheet button.action-sheet-button').first(); + await defaultButton.hover(); + + await actionSheetFixture.screenshot('states'); + }); + }); +}); diff --git a/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..fd0a23462ce Binary files /dev/null and b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c10ada5b0e0 Binary files /dev/null and b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..31914133756 Binary files /dev/null and b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..5a3e78894ef Binary files /dev/null and b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..5ae3278c1b2 Binary files /dev/null and b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..d03447bdf25 Binary files /dev/null and b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-md-ltr-Mobile-Chrome-linux.png b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..414268d7a30 Binary files /dev/null and b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..0b2cd5cb038 Binary files /dev/null and b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-md-ltr-Mobile-Safari-linux.png b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..1d3bc166290 Binary files /dev/null and b/core/src/components/action-sheet/test/states/action-sheet.e2e.ts-snapshots/action-sheet-states-diff-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/action-sheet/test/states/index.html b/core/src/components/action-sheet/test/states/index.html new file mode 100644 index 00000000000..5d5339d5d1b --- /dev/null +++ b/core/src/components/action-sheet/test/states/index.html @@ -0,0 +1,97 @@ + + + + + Action Sheet - States + + + + + + + + + + + + + + Action Sheet - States + + + + + + + + + + + + diff --git a/core/src/components/action-sheet/test/translucent/index.html b/core/src/components/action-sheet/test/translucent/index.html index 1eef4d3849a..cfff4874252 100644 --- a/core/src/components/action-sheet/test/translucent/index.html +++ b/core/src/components/action-sheet/test/translucent/index.html @@ -64,7 +64,7 @@ diff --git a/core/src/components/alert/alert.common.scss b/core/src/components/alert/alert.common.scss index aed62281ea0..e0e81d62543 100644 --- a/core/src/components/alert/alert.common.scss +++ b/core/src/components/alert/alert.common.scss @@ -1,4 +1,6 @@ -@import "./alert.vars"; +@use "../../themes/functions.font" as font; +@use "../../themes/mixins" as mixins; +@use "../select-option/select-option.common.overlay"; // Alert // -------------------------------------------------- @@ -17,14 +19,14 @@ * * @prop --backdrop-opacity: Opacity of the backdrop */ - --min-width: #{$alert-min-width}; + --min-width: 250px; --width: auto; --min-height: auto; --height: auto; - --max-height: #{$alert-max-height}; + --max-height: 90%; - @include font-smoothing(); - @include position(0, 0, 0, 0); + @include mixins.font-smoothing(); + @include mixins.position(0, 0, 0, 0); display: flex; position: absolute; @@ -34,12 +36,9 @@ outline: none; - font-family: $font-family-base; - contain: strict; touch-action: none; user-select: none; - z-index: $z-index-overlay; } :host(.overlay-hidden) { @@ -47,7 +46,7 @@ } :host(.alert-top) { - @include padding(50px, null, null, null); + @include mixins.padding(50px, null, null, null); align-items: flex-start; } @@ -69,17 +68,16 @@ contain: content; opacity: 0; - z-index: $z-index-overlay-wrapper; } .alert-title { - @include margin(0); - @include padding(0); + @include mixins.margin(0); + @include mixins.padding(0); } .alert-sub-title { - @include margin(5px, 0, 0); - @include padding(0); + @include mixins.margin(5px, 0, 0); + @include mixins.padding(0); font-weight: normal; } @@ -139,7 +137,7 @@ } .alert-input { - @include padding(10px, 0); + @include mixins.padding(10px, 0); width: 100%; @@ -165,24 +163,19 @@ } .alert-button { - @include margin(0); + @include mixins.margin(0); display: block; border: 0; - font-size: $alert-button-font-size; + font-size: font.dynamic-font(14px); - line-height: $alert-button-line-height; + line-height: font.dynamic-font(20px); z-index: 0; } -.alert-button.ion-focused, -.alert-tappable.ion-focused { - background: $background-color-step-100; -} - .alert-button-inner { display: flex; @@ -197,6 +190,45 @@ min-height: inherit; } +// Alert Option: Label Placement +// -------------------------------------------------- + +/** + * Label is on the right of the radio in LTR and + * on the left in RTL. + */ +.radio-label-placement-start, +.checkbox-label-placement-start { + flex-direction: row-reverse; +} + +/** + * Label is on the left of the radio in LTR and + * on the right in RTL. + */ +.radio-label-placement-end, +.checkbox-label-placement-end { + flex-direction: row; +} + +// Alert Option: Justify +// -------------------------------------------------- + +.radio-justify-start, +.checkbox-justify-start { + justify-content: start; +} + +.radio-justify-end, +.checkbox-justify-end { + justify-content: end; +} + +.radio-justify-space-between, +.checkbox-justify-space-between { + justify-content: space-between; +} + // Alert Button: Disabled // -------------------------------------------------- .alert-input-disabled, @@ -208,8 +240,8 @@ } .alert-tappable { - @include margin(0); - @include padding(0); + @include mixins.margin(0); + @include mixins.padding(0); display: flex; @@ -243,24 +275,6 @@ } textarea.alert-input { - min-height: $alert-input-min-height; + min-height: 37px; resize: none; } - -// Alert Button: Select Option -// -------------------------------------------------- - -.alert-radio-label, -.alert-checkbox-label { - display: flex; - - align-items: center; -} - -.select-option-content { - flex: 1; -} - -.select-option-description { - display: block; -} diff --git a/core/src/components/alert/alert.ionic.scss b/core/src/components/alert/alert.ionic.scss index 3c54136b477..3b41ed3928b 100644 --- a/core/src/components/alert/alert.ionic.scss +++ b/core/src/components/alert/alert.ionic.scss @@ -1,23 +1,380 @@ @use "../../themes/ionic/ionic.globals.scss" as globals; +@use "../../themes/mixins" as mixins; +@use "../select-option/select-option.ionic.overlay"; @use "./alert.common"; -@use "./alert.md" as alert-md; // Ionic Alert // -------------------------------------------------- -// Alert: Select Option +:host { + --background: #{globals.$ion-bg-surface-default}; + --max-width: #{globals.$ion-scale-7400}; + --backdrop-opacity: 0.7; + + z-index: 1001; +} + +.alert-wrapper { + @include globals.border-radius(globals.$ion-border-radius-200); + + box-shadow: globals.$ion-elevation-4; + + z-index: 10; +} + +.alert-button.ion-focused { + @include globals.focused-state( + globals.$ion-border-size-050, + globals.$ion-border-style-solid, + globals.$ion-border-focus-default + ); +} + +.alert-tappable.ion-focused { + @include globals.focused-state( + globals.$ion-border-size-050, + globals.$ion-border-style-solid, + globals.$ion-border-focus-default + ); + + outline-offset: calc(globals.$ion-border-size-050 * -1); +} + +.alert-tappable.ion-activated, +.alert-tappable:not(:disabled):hover { + background: globals.$ion-bg-neutral-subtlest-press; +} + +.alert-tappable[aria-checked="true"] { + background: globals.$ion-bg-primary-subtle-default; +} + +// Ionic Alert Header // -------------------------------------------------- -.alert-radio-label, -.alert-checkbox-label { - gap: globals.$ion-space-300; +.alert-head { + @include mixins.padding(globals.$ion-space-400); + + text-align: start; } -.select-option-description { +.alert-title { + @include globals.typography(globals.$ion-heading-h6-medium); + + color: globals.$ion-text-default; +} + +.alert-sub-title { + @include globals.typography(globals.$ion-body-md-regular); + + color: globals.$ion-text-subtlest; +} + +// Ionic Alert Message +// -------------------------------------------------- + +.alert-message, +.alert-input-group { @include globals.typography(globals.$ion-body-md-regular); - @include globals.padding(0); + @include mixins.padding(globals.$ion-space-400); + + color: globals.$ion-text-default; +} + +/** + * Ionic Alerts on tablets can expand vertically up to + * a total maximum height. We only want to set a max-height + * on mobile phones. + */ +@include globals.mobile-viewport() { + .alert-message { + max-height: globals.$ion-scale-6200; + } +} + +.alert-message:empty { + @include mixins.padding(globals.$ion-space-0); +} + +.alert-head + .alert-message { + padding-top: globals.$ion-space-0; +} + +// Ionic Alert Input +// -------------------------------------------------- + +.alert-input { + @include mixins.margin(globals.$ion-space-150, 0); + + border-bottom: globals.$ion-border-size-025 globals.$ion-border-style-solid globals.$ion-border-input-default; + + color: globals.$ion-text-default; + + &::placeholder { + color: globals.$ion-text-subtlest; + + font-family: inherit; + font-weight: inherit; + } + + &::-ms-clear { + display: none; + } +} + +.alert-input:focus { + @include mixins.margin(null, null, globals.$ion-scale-100, null); + + border-bottom: globals.$ion-border-size-050 globals.$ion-border-style-solid globals.$ion-border-focus-default; +} + +// Ionic Alert Radio/Checkbox Group +// -------------------------------------------------- + +.alert-radio-group, +.alert-checkbox-group { + position: relative; + + border-top: globals.$ion-border-size-025 globals.$ion-border-style-solid globals.$ion-border-default; + border-bottom: globals.$ion-border-size-025 globals.$ion-border-style-solid globals.$ion-border-default; + + overflow: auto; +} + +/** + * Ionic Alerts on tablets can expand vertically up to + * a total maximum height. We only want to set a max-height + * on mobile phones. + */ +@include globals.mobile-viewport() { + .alert-radio-group, + .alert-checkbox-group { + max-height: globals.$ion-scale-6200; + } +} + +.alert-tappable { + position: relative; + + min-height: globals.$ion-scale-1200; +} + +// Ionic Alert Radio +// -------------------------------------------------- + +.alert-radio-label { + @include globals.typography(globals.$ion-body-md-regular); + @include mixins.padding(globals.$ion-space-200, null, globals.$ion-space-200, null); + + // Required for the radio icon to stay on the screen without + // being squished when the font size scales up. + max-width: calc(100% - globals.$ion-space-400); + + color: globals.$ion-text-default; +} + +.radio-label-placement-start .alert-radio-label { + /** + * The label's inline padding clears space for the icon on the + * icon's side. When the label is placed at the start, the icon + * is at the end, so swap the inline paddings to mirror that geometry. + */ + @include mixins.padding-horizontal(globals.$ion-space-400, globals.$ion-space-800); +} + +.radio-label-placement-end .alert-radio-label { + /** + * The label's inline padding clears space for the icon on the + * icon's side. When the label is placed at the end, the icon + * is at the start, so swap the inline paddings to mirror that geometry. + */ + @include mixins.padding-horizontal(globals.$ion-space-800, globals.$ion-space-400); +} + +// Ionic Alert Radio Outer Circle: Unchecked +// --------------------------------------------------- + +.alert-radio-icon { + @include globals.position(globals.$ion-space-0, null, null, null); + @include globals.border-radius(globals.$ion-border-radius-full); - color: globals.$ion-text-subtle; + display: flex; + position: relative; + + flex-shrink: 0; + + align-items: center; + justify-content: center; + + width: globals.$ion-scale-600; + height: globals.$ion-scale-600; + + border-width: globals.$ion-border-size-025; + border-style: globals.$ion-border-style-solid; + border-color: globals.$ion-border-input-default; + + background-color: globals.$ion-bg-input-default; + + box-sizing: border-box; +} + +// The icon's inline offset gives it a gap from whichever edge the icon +// sits on, which depends on label placement +.radio-label-placement-start .alert-radio-icon { + @include globals.position-horizontal(null, globals.$ion-space-400); +} + +.radio-label-placement-end .alert-radio-icon { + @include globals.position-horizontal(globals.$ion-space-400, null); +} + +// Ionic Alert Radio Inner Dot +// --------------------------------------------------- + +.alert-radio-inner { + @include globals.border-radius(50%); + + width: calc(32% + globals.$ion-border-size-025); + height: calc(32% + globals.$ion-border-size-025); + + background-color: globals.$ion-bg-surface-inverse; + + box-sizing: border-box; +} + +// Ionic Alert Radio Outer Circle: Checked +// --------------------------------------------------- + +[aria-checked="true"] .alert-radio-icon { + border-color: globals.$ion-bg-primary-base-default; + + background-color: globals.$ion-bg-primary-base-default; +} + +// Ionic Alert Checkbox Label +// -------------------------------------------------- + +.alert-checkbox-label { + @include globals.typography(globals.$ion-body-md-regular); + @include mixins.padding( + globals.$ion-space-200, + globals.$ion-space-400, + globals.$ion-space-200, + globals.$ion-space-800 + ); + + // Required for the checkbox icon to stay on the screen without + // being squished when the font size scales up. + max-width: calc(100% - globals.$ion-space-400); + + color: globals.$ion-text-default; +} + +.checkbox-label-placement-start .alert-checkbox-label { + /** + * The label's inline padding clears space for the icon on the + * icon's side. When the label is placed at the start, the icon + * is at the end, so swap the inline paddings to mirror that geometry. + */ + @include mixins.padding-horizontal(globals.$ion-space-400, globals.$ion-space-800); +} + +// Ionic Alert Checkbox Outline: Unchecked +// -------------------------------------------------- + +.alert-checkbox-icon { + @include globals.position(globals.$ion-space-0, null, null, null); + @include globals.border-radius(globals.$ion-border-radius-100); + + display: flex; + position: relative; + + flex-shrink: 0; + + align-items: center; + justify-content: center; + + width: globals.$ion-scale-600; + height: globals.$ion-scale-600; + + border-width: globals.$ion-border-size-025; + border-style: globals.$ion-border-style-solid; + border-color: globals.$ion-primitives-neutral-800; + + background-color: globals.$ion-bg-input-default; + + box-sizing: border-box; +} + +// The icon's inline offset gives it a gap from whichever edge the icon +// sits on, which depends on label placement +.checkbox-label-placement-end .alert-checkbox-icon { + @include globals.position-horizontal(globals.$ion-space-400, null); +} + +.checkbox-label-placement-start .alert-checkbox-icon { + @include globals.position-horizontal(null, globals.$ion-space-400); +} + +.alert-checkbox-inner { + width: globals.$ion-scale-400; + height: globals.$ion-scale-400; +} + +.alert-checkbox-inner path { + fill: globals.$ion-bg-surface-default; +} + +// Ionic Alert Checkbox Checkmark: Checked +// -------------------------------------------------- + +[aria-checked="true"] .alert-checkbox-icon { + border-color: globals.$ion-semantics-primary-base; + + background-color: globals.$ion-bg-primary-base-default; +} + +// Ionic Alert Button +// -------------------------------------------------- + +.alert-button-group { + @include mixins.padding(8px); + + box-sizing: border-box; + + flex-wrap: wrap-reverse; + justify-content: flex-end; +} + +.alert-button { + @include globals.border-radius(globals.$ion-border-size-050); + @include mixins.margin(globals.$ion-space-0, globals.$ion-space-200, globals.$ion-space-0, globals.$ion-space-0); + @include mixins.padding(globals.$ion-space-250); + + // necessary for ripple to work properly + position: relative; + + background-color: transparent; + color: globals.ion-color(primary, base); + + font-weight: globals.$ion-font-weight-medium; + + text-align: end; + + overflow: hidden; +} + +.alert-button .alert-button-inner { + justify-content: flex-end; +} - font-size: globals.$ion-font-size-350; +/** + * Ionic alerts should scale up to 560px x 560px + * on tablet dimensions. + */ +@include globals.tablet-viewport() { + :host { + --max-width: #{min(calc(100vw - 96px), 560px)}; + --max-height: #{min(calc(100vh - 96px), 560px)}; + } } diff --git a/core/src/components/alert/alert.ios.scss b/core/src/components/alert/alert.ios.scss index 9a2d6e14c72..5410fc32bb2 100644 --- a/core/src/components/alert/alert.ios.scss +++ b/core/src/components/alert/alert.ios.scss @@ -1,3 +1,4 @@ +@use "../select-option/select-option.ios.overlay"; @import "./alert.native"; @import "./alert.ios.vars"; @@ -165,9 +166,6 @@ $alert-ios-radio-label-padding-start ); - flex: 1; - order: 0; - color: $alert-ios-radio-label-text-color; } @@ -184,7 +182,7 @@ .alert-radio-icon { position: relative; - order: 1; + flex-shrink: 0; min-width: $alert-ios-radio-min-width; } @@ -193,7 +191,7 @@ // ----------------------------------------- [aria-checked="true"] .alert-radio-inner { - @include position($alert-ios-radio-icon-top, null, null, $alert-ios-radio-icon-start); + @include position($alert-ios-radio-icon-top, null, null, null); position: absolute; @@ -209,6 +207,16 @@ border-color: $alert-ios-radio-icon-border-color; } +// The icon's inline offset gives it a gap from whichever edge the icon +// sits on, which depends on label placement +[aria-checked="true"] .radio-label-placement-end .alert-radio-inner { + @include position-horizontal(null, $alert-ios-radio-icon-start); +} + +[aria-checked="true"] .radio-label-placement-start .alert-radio-inner { + @include position-horizontal($alert-ios-radio-icon-start, null); +} + // iOS Alert Checkbox Label // -------------------------------------------------- @@ -220,8 +228,6 @@ $alert-ios-checkbox-label-padding-start ); - flex: 1; - color: $alert-ios-checkbox-label-text-color; } @@ -230,15 +236,12 @@ .alert-checkbox-icon { @include border-radius($alert-ios-checkbox-border-radius); - @include margin( - $alert-ios-checkbox-margin-top, - $alert-ios-checkbox-margin-end, - $alert-ios-checkbox-margin-bottom, - $alert-ios-checkbox-margin-start - ); + @include margin($alert-ios-checkbox-margin-top, null, $alert-ios-checkbox-margin-bottom, null); position: relative; + flex-shrink: 0; + width: $alert-ios-checkbox-size; height: $alert-ios-checkbox-size; @@ -251,6 +254,17 @@ contain: strict; } +// The icon's inline margins are asymmetric (larger gap from the row +// edge, smaller gap toward the label), so they swap with label +// placement. +.checkbox-label-placement-end .alert-checkbox-icon { + @include margin-horizontal($alert-ios-checkbox-margin-start, $alert-ios-checkbox-margin-end); +} + +.checkbox-label-placement-start .alert-checkbox-icon { + @include margin-horizontal($alert-ios-checkbox-margin-end, $alert-ios-checkbox-margin-start); +} + // iOS Alert Checkbox Outer Circle: Checked // ----------------------------------------- @@ -264,7 +278,7 @@ // ----------------------------------------- [aria-checked="true"] .alert-checkbox-inner { - @include position($alert-ios-checkbox-icon-top, null, null, $alert-ios-checkbox-icon-start); + @include position($alert-ios-checkbox-icon-top, null, null, null); position: absolute; @@ -280,6 +294,16 @@ border-color: $alert-ios-checkbox-icon-border-color; } +// The icon's inline offset gives it a gap from whichever edge the icon +// sits on, which depends on label placement +[aria-checked="true"] .checkbox-label-placement-end .alert-checkbox-inner { + @include position-horizontal($alert-ios-checkbox-icon-start, null); +} + +[aria-checked="true"] .checkbox-label-placement-start .alert-checkbox-inner { + @include position-horizontal(null, $alert-ios-checkbox-icon-start); +} + // iOS Alert Button // -------------------------------------------------- @@ -351,7 +375,7 @@ background-color: $alert-ios-button-background-color-activated; } -// iOS Action Sheet Button: Destructive +// iOS Alert Button: Destructive // --------------------------------------------------- .alert-button-role-destructive, diff --git a/core/src/components/alert/alert.md.scss b/core/src/components/alert/alert.md.scss index 2fbd0fd8775..efaf9c49f91 100644 --- a/core/src/components/alert/alert.md.scss +++ b/core/src/components/alert/alert.md.scss @@ -1,3 +1,4 @@ +@use "../select-option/select-option.md.overlay"; @import "./alert.native"; @import "./alert.md.vars"; @@ -157,30 +158,47 @@ // -------------------------------------------------- .alert-radio-label { - @include padding( - $alert-md-radio-label-padding-top, - $alert-md-radio-label-padding-end, - $alert-md-radio-label-padding-bottom, - $alert-md-radio-label-padding-start - ); + @include padding($alert-md-radio-label-padding-top, null, $alert-md-radio-label-padding-bottom, null); - flex: 1; + // Required for the radio icon to stay on the screen without + // being squished when the font size scales up. + max-width: calc(100% - $alert-md-radio-width); color: $alert-md-radio-label-text-color; font-size: $alert-md-radio-label-font-size; } +.radio-label-placement-end .alert-radio-label { + /** + * The label's inline padding clears space for the icon on the icon's side. + * When the label is placed at the end, the icon is at the start, so the + * larger padding clears it on the start side. + */ + @include padding-horizontal($alert-md-radio-label-padding-start, $alert-md-radio-label-padding-end); +} + +.radio-label-placement-start .alert-radio-label { + /** + * The label's inline padding clears space for the icon on the icon's side. + * When the label is placed at the start, the icon is at the end, so the + * larger padding clears it on the end side. + */ + @include padding-horizontal($alert-md-radio-label-padding-end, $alert-md-radio-label-padding-start); +} + // Material Design Alert Radio Unchecked Circle // --------------------------------------------------- .alert-radio-icon { - @include position($alert-md-radio-top, null, null, $alert-md-radio-left); + @include position($alert-md-radio-top, null, null, null); @include border-radius($alert-md-radio-border-radius); display: block; position: relative; + flex-shrink: 0; + width: $alert-md-radio-width; height: $alert-md-radio-height; @@ -189,6 +207,16 @@ border-color: $alert-md-radio-border-color-off; } +// The icon's inline offset gives it a gap from whichever edge the icon +// sits on, which depends on label placement +.radio-label-placement-end .alert-radio-icon { + @include position-horizontal($alert-md-radio-left, null); +} + +.radio-label-placement-start .alert-radio-icon { + @include position-horizontal(null, $alert-md-radio-left); +} + // Material Design Alert Radio Checked Dot // --------------------------------------------------- @@ -227,33 +255,46 @@ // -------------------------------------------------- .alert-checkbox-label { - @include padding( - $alert-md-checkbox-label-padding-top, - $alert-md-checkbox-label-padding-end, - $alert-md-checkbox-label-padding-bottom, - $alert-md-checkbox-label-padding-start - ); - - flex: 1; + @include padding($alert-md-checkbox-label-padding-top, null, $alert-md-checkbox-label-padding-bottom, null); // Required for the checkbox icon to stay on the screen without // being squished when the font size scales up. - width: calc(100% - $alert-md-checkbox-label-padding-start); + max-width: calc(100% - $alert-md-checkbox-width); color: $alert-md-checkbox-label-text-color; font-size: $alert-md-checkbox-label-font-size; } +.checkbox-label-placement-end .alert-checkbox-label { + /** + * The label's inline padding clears space for the icon on the icon's side. + * When the label is placed at the end, the icon is at the start, so the + * larger padding clears it on the start side. + */ + @include padding-horizontal($alert-md-checkbox-label-padding-start, $alert-md-checkbox-label-padding-end); +} + +.checkbox-label-placement-start .alert-checkbox-label { + /** + * The label's inline padding clears space for the icon on the icon's side. + * When the label is placed at the start, the icon is at the end, so the + * larger padding clears it on the end side. + */ + @include padding-horizontal($alert-md-checkbox-label-padding-end, $alert-md-checkbox-label-padding-start); +} + // Material Design Alert Checkbox Outline: Unchecked // -------------------------------------------------- .alert-checkbox-icon { - @include position($alert-md-checkbox-top, null, null, $alert-md-checkbox-left); + @include position($alert-md-checkbox-top, null, null, null); @include border-radius($alert-md-checkbox-border-radius); position: relative; + flex-shrink: 0; + width: $alert-md-checkbox-width; height: $alert-md-checkbox-height; @@ -264,6 +305,16 @@ contain: strict; } +// The icon's inline offset gives it a gap from whichever edge the icon +// sits on, which depends on label placement +.checkbox-label-placement-end .alert-checkbox-icon { + @include position-horizontal($alert-md-checkbox-left, null); +} + +.checkbox-label-placement-start .alert-checkbox-icon { + @include position-horizontal(null, $alert-md-checkbox-left); +} + // Material Design Alert Checkbox Checkmark: Checked // -------------------------------------------------- @@ -331,7 +382,7 @@ overflow: hidden; } -.alert-button-inner { +.alert-button-group .alert-button-inner { justify-content: $alert-md-button-group-justify-content; } diff --git a/core/src/components/alert/alert.md.vars.scss b/core/src/components/alert/alert.md.vars.scss index ce1fb7304dd..43458d416dd 100644 --- a/core/src/components/alert/alert.md.vars.scss +++ b/core/src/components/alert/alert.md.vars.scss @@ -34,11 +34,14 @@ $alert-md-background-color: $overlay-md-background-color; $alert-md-box-shadow: 0 11px 15px -7px rgba(0, 0, 0, 0.2), 0 24px 38px 3px rgba(0, 0, 0, 0.14), 0 9px 46px 8px rgba(0, 0, 0, 0.12); +/// @prop - Padding end of the alert +$alert-md-padding-end: 24px; + /// @prop - Padding top of the alert head $alert-md-head-padding-top: 20px; /// @prop - Padding end of the alert head -$alert-md-head-padding-end: 23px; +$alert-md-head-padding-end: $alert-md-padding-end; /// @prop - Padding bottom of the alert head $alert-md-head-padding-bottom: 15px; @@ -68,7 +71,7 @@ $alert-md-sub-title-text-color: $text-color; $alert-md-message-padding-top: 20px; /// @prop - Padding end of the alert message -$alert-md-message-padding-end: 24px; +$alert-md-message-padding-end: $alert-md-padding-end; /// @prop - Padding bottom of the alert message $alert-md-message-padding-bottom: $alert-md-message-padding-top; @@ -187,11 +190,14 @@ $alert-md-list-border-top: 1px solid $alert-md-input-border-color; /// @prop - Border bottom of the alert list $alert-md-list-border-bottom: $alert-md-list-border-top; +/// @prop - Spacing between control and label +$alert-md-control-label-spacing: 32px; + /// @prop - Top of the alert radio $alert-md-radio-top: 0; /// @prop - Left of the alert radio -$alert-md-radio-left: 26px; +$alert-md-radio-left: $alert-md-padding-end; /// @prop - Width of the alert radio $alert-md-radio-width: 20px; @@ -242,13 +248,13 @@ $alert-md-radio-icon-transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1); $alert-md-radio-label-padding-top: 13px; /// @prop - Padding end on the label for the radio alert -$alert-md-radio-label-padding-end: 26px; +$alert-md-radio-label-padding-end: $alert-md-padding-end; /// @prop - Padding bottom on the label for the radio alert $alert-md-radio-label-padding-bottom: $alert-md-radio-label-padding-top; /// @prop - Padding start on the label for the radio alert -$alert-md-radio-label-padding-start: $alert-md-radio-label-padding-end + 26px; +$alert-md-radio-label-padding-start: $alert-md-radio-left + $alert-md-control-label-spacing; /// @prop - Font size of the label for the radio alert $alert-md-radio-label-font-size: dynamic-font(16px); @@ -263,7 +269,7 @@ $alert-md-radio-label-text-color-checked: $alert-md-radio-label-text-color; $alert-md-checkbox-top: 0; /// @prop - Left of the checkbox in the alert -$alert-md-checkbox-left: 26px; +$alert-md-checkbox-left: $alert-md-padding-end; /// @prop - Width of the checkbox in the alert $alert-md-checkbox-width: 16px; @@ -314,13 +320,13 @@ $alert-md-checkbox-icon-transform: rotate(45deg); $alert-md-checkbox-label-padding-top: 13px; /// @prop - Padding end of the label for the checkbox in the alert -$alert-md-checkbox-label-padding-end: $alert-md-checkbox-left; +$alert-md-checkbox-label-padding-end: $alert-md-padding-end; /// @prop - Padding bottom of the label for the checkbox in the alert $alert-md-checkbox-label-padding-bottom: $alert-md-checkbox-label-padding-top; /// @prop - Padding start of the label for the checkbox in the alert -$alert-md-checkbox-label-padding-start: $alert-md-checkbox-label-padding-end + 27px; +$alert-md-checkbox-label-padding-start: $alert-md-checkbox-left + $alert-md-control-label-spacing; /// @prop - Text color of the label for the checkbox in the alert $alert-md-checkbox-label-text-color: $text-color-step-150; diff --git a/core/src/components/alert/alert.native.scss b/core/src/components/alert/alert.native.scss index e2d5a87b8a5..b6a5ca8ef1a 100644 --- a/core/src/components/alert/alert.native.scss +++ b/core/src/components/alert/alert.native.scss @@ -1,20 +1,23 @@ -@use "../../themes/native/native.theme.default" as native; +@use "../../themes/native/native.globals" as native; @use "../../themes/mixins" as mixins; @use "../../themes/functions.font" as font; +@use "../select-option/select-option.native.overlay"; @use "./alert.common"; // Alert: Native // -------------------------------------------------- -.alert-radio-label, -.alert-checkbox-label { - gap: 12px; -} +:host { + font-family: native.$font-family-base; -.select-option-description { - @include mixins.padding(5px, 0, 0, 0); + z-index: native.$z-index-overlay; +} - color: native.$text-color-step-300; +.alert-wrapper { + z-index: native.$z-index-overlay-wrapper; +} - font-size: font.dynamic-font(12px); +.alert-button.ion-focused, +.alert-tappable.ion-focused { + background: native.$background-color-step-100; } diff --git a/core/src/components/alert/alert.tsx b/core/src/components/alert/alert.tsx index e44077d0f86..44adc065aa9 100644 --- a/core/src/components/alert/alert.tsx +++ b/core/src/components/alert/alert.tsx @@ -6,6 +6,7 @@ import { createButtonActiveGesture } from '@utils/gesture/button-active'; import { raf } from '@utils/helpers'; import { createLockController } from '@utils/lock-controller'; import { printIonWarning } from '@utils/logging'; +import { getOverlayLabelJustify, getOverlayLabelPlacement } from '@utils/overlay-control-label'; import { createDelegateController, createTriggerController, @@ -603,6 +604,8 @@ export class Alert implements ComponentInterface, OverlayInterface { endContent: richInput.endContent, description: richInput.description, }; + const defaultLabelPlacement = getOverlayLabelPlacement(theme, 'checkbox'); + const defaultJustify = getOverlayLabelJustify(theme, 'checkbox'); return ( + + + + + + + + + diff --git a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-ios-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-ios-ltr-Mobile-Safari-linux.png index b05a3d16860..7bb9fb009eb 100644 Binary files a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-ios-rtl-Mobile-Safari-linux.png b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-ios-rtl-Mobile-Safari-linux.png index 02820dd91a8..7a7ea03eebe 100644 Binary files a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-ios-rtl-Mobile-Safari-linux.png and b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-ltr-Mobile-Chrome-linux.png index 5f17e5fabe5..7b5e4f5ea4d 100644 Binary files a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-ltr-Mobile-Firefox-linux.png b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-ltr-Mobile-Firefox-linux.png index 84a81e8c938..769000f8882 100644 Binary files a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-ltr-Mobile-Safari-linux.png index effe36becf6..484a0243290 100644 Binary files a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-rtl-Mobile-Chrome-linux.png b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-rtl-Mobile-Chrome-linux.png index dd305e62607..ef35d402577 100644 Binary files a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-rtl-Mobile-Firefox-linux.png b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-rtl-Mobile-Firefox-linux.png index ee5173b6a18..65e59390b54 100644 Binary files a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-rtl-Mobile-Safari-linux.png b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-rtl-Mobile-Safari-linux.png index 8811a5009a2..c8120e1899a 100644 Binary files a/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-rtl-Mobile-Safari-linux.png and b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/standalone/index.html b/core/src/components/alert/test/standalone/index.html index c0413f6fc09..b69f126c7ec 100644 --- a/core/src/components/alert/test/standalone/index.html +++ b/core/src/components/alert/test/standalone/index.html @@ -23,7 +23,7 @@ diff --git a/core/src/components/alert/test/states/alert.e2e.ts b/core/src/components/alert/test/states/alert.e2e.ts new file mode 100644 index 00000000000..95f51290a51 --- /dev/null +++ b/core/src/components/alert/test/states/alert.e2e.ts @@ -0,0 +1,60 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * This behavior does not vary across directions. + */ +configs({ directions: ['ltr'], modes: ['ios', 'md', 'ionic-md'] }).forEach(({ config, screenshot, title }) => { + test.describe(title('alert: input states'), () => { + /** + * `(any-hover: hover)` evaluates to "none" in all three mobile-emulated + * projects, suppressing the hover rules: + * + * - Chromium and WebKit suppress it because of hasTouch and isMobile. + * - Headless Firefox doesn't detect input devices and reports no hover + * capability regardless of context options, so override it via + * launchOptions.firefoxUserPrefs. Bit values: 4 = FINE (mouse), + * 8 = HOVER, 12 = FINE | HOVER. + * + * Viewport, userAgent, and scale factor remain mobile-sized. + */ + test.use({ + hasTouch: false, + isMobile: false, + }); + + test.beforeEach(async ({ page }) => { + await page.goto(`/src/components/alert/test/states`, config); + }); + + test('should render all radio states', async ({ page }) => { + const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent'); + await page.locator('#radio').click(); + await ionAlertDidPresent.next(); + + const alert = page.locator('ion-alert'); + await expect(alert).toBeVisible(); + + const defaultRadio = alert.locator('button.alert-radio-button').first(); + await defaultRadio.hover(); + + await expect(alert).toHaveScreenshot(screenshot('alert-radio-states')); + }); + + test('should render all checkbox states', async ({ page }) => { + const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent'); + await page.locator('#checkbox').click(); + await ionAlertDidPresent.next(); + + const alert = page.locator('ion-alert'); + await expect(alert).toBeVisible(); + + const defaultCheckbox = alert.locator('button.alert-checkbox-button').first(); + await defaultCheckbox.hover(); + + await page.waitForChanges(); + + await expect(alert).toHaveScreenshot(screenshot('alert-checkbox-states')); + }); + }); +}); diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..b763d66d316 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c54902e71d5 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..f973ba46292 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..d1c9a799e3d Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..cd1503d5798 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ios-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..061da6fa38b Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-md-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..0068646af36 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-md-ltr-Mobile-Firefox-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..24a998b57a0 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-md-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..3a4180d8cb6 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-checkbox-states-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..dcf60daefcf Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..28eaee45375 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..c39055546e1 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..1b509e947d5 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..e6ebfbdee08 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ios-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..f3cadc60546 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-md-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..276ea72ed09 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-md-ltr-Mobile-Firefox-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..1cdcf98e3b1 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-md-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..3ff1ea6eeb1 Binary files /dev/null and b/core/src/components/alert/test/states/alert.e2e.ts-snapshots/alert-radio-states-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/states/index.html b/core/src/components/alert/test/states/index.html new file mode 100644 index 00000000000..4591962127a --- /dev/null +++ b/core/src/components/alert/test/states/index.html @@ -0,0 +1,159 @@ + + + + + Alert - States + + + + + + + + + + + + + + Alert - States + + + + + + + + + + + + diff --git a/core/src/components/alert/test/translucent/index.html b/core/src/components/alert/test/translucent/index.html index 401bd35d652..c78b7106829 100644 --- a/core/src/components/alert/test/translucent/index.html +++ b/core/src/components/alert/test/translucent/index.html @@ -70,7 +70,7 @@ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png index 5769b2e860e..09c93ab3ca7 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png index 9a1dbee2541..ae012f8623d 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ionic-md-ltr-light-Mobile-Safari-linux.png index 176bcb51146..aea4148d201 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ionic-md-ltr-light-Mobile-Safari-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-ios-rtl-Mobile-Safari-linux.png b/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-ios-rtl-Mobile-Safari-linux.png index e4887dcb461..80bc6c8f73b 100644 Binary files a/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-ios-rtl-Mobile-Safari-linux.png and b/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-md-ltr-Mobile-Safari-linux.png b/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-md-ltr-Mobile-Safari-linux.png index 3a08bb79d95..386b21b1b96 100644 Binary files a/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-md-ltr-Mobile-Safari-linux.png and b/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-md-rtl-Mobile-Safari-linux.png b/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-md-rtl-Mobile-Safari-linux.png index 72efc44f67b..c26f869d7e7 100644 Binary files a/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-md-rtl-Mobile-Safari-linux.png and b/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/card/test/basic/card.e2e.ts-snapshots/card-no-content-or-header-md-ltr-Mobile-Chrome-linux.png b/core/src/components/card/test/basic/card.e2e.ts-snapshots/card-no-content-or-header-md-ltr-Mobile-Chrome-linux.png index 847a23aa4bc..db4e61495d2 100644 Binary files a/core/src/components/card/test/basic/card.e2e.ts-snapshots/card-no-content-or-header-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/card/test/basic/card.e2e.ts-snapshots/card-no-content-or-header-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/card/test/basic/card.e2e.ts-snapshots/card-no-content-or-header-md-ltr-Mobile-Safari-linux.png b/core/src/components/card/test/basic/card.e2e.ts-snapshots/card-no-content-or-header-md-ltr-Mobile-Safari-linux.png index 8ea651cd081..641d545b35e 100644 Binary files a/core/src/components/card/test/basic/card.e2e.ts-snapshots/card-no-content-or-header-md-ltr-Mobile-Safari-linux.png and b/core/src/components/card/test/basic/card.e2e.ts-snapshots/card-no-content-or-header-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/checkbox/checkbox.common.scss b/core/src/components/checkbox/checkbox.common.scss index 3623877e736..20d86101e78 100644 --- a/core/src/components/checkbox/checkbox.common.scss +++ b/core/src/components/checkbox/checkbox.common.scss @@ -53,6 +53,11 @@ width: auto; } +// Hide the native focus outline. +:host(:focus) { + outline: none; +} + .checkbox-wrapper { display: flex; diff --git a/core/src/components/checkbox/checkbox.tsx b/core/src/components/checkbox/checkbox.tsx index 72ebf4ed54b..2a26468fa1c 100644 --- a/core/src/components/checkbox/checkbox.tsx +++ b/core/src/components/checkbox/checkbox.tsx @@ -353,6 +353,7 @@ export class Checkbox implements ComponentInterface { } = this; const theme = getIonTheme(this); const path = getSVGPath(theme, indeterminate); + const inItem = hostContext('ion-item', el); renderHiddenInput(true, el, name, checked ? value : '', disabled); @@ -375,10 +376,11 @@ export class Checkbox implements ComponentInterface { onClick={this.onClick} class={createColorClasses(color, { [theme]: true, - 'in-item': hostContext('ion-item', el), + 'in-item': inItem, 'checkbox-checked': checked, 'checkbox-disabled': disabled, - 'ion-focusable': true, + // Focus styling should not apply when the checkbox is in an item + 'ion-focusable': !inItem, 'checkbox-indeterminate': indeterminate, interactive: true, [`checkbox-justify-${justify}`]: justify !== undefined, diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-ios-ltr-Mobile-Chrome-linux.png index 45885364c12..f32978d4c6d 100644 Binary files a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-ios-ltr-Mobile-Firefox-linux.png index 305f4d89e1b..6846d8d4167 100644 Binary files a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-ios-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-ios-ltr-Mobile-Safari-linux.png index aaccc05ff73..ace506deea3 100644 Binary files a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Chrome-linux.png index 2c0dcdc0baa..34d00f327c4 100644 Binary files a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Safari-linux.png index b465b40ad93..404fd8c4578 100644 Binary files a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts-snapshots/datetime-overlay-modal-ios-ltr-Mobile-Safari-linux.png b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts-snapshots/datetime-overlay-modal-ios-ltr-Mobile-Safari-linux.png index 8bad455d299..d2a7063a8f4 100644 Binary files a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts-snapshots/datetime-overlay-modal-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts-snapshots/datetime-overlay-modal-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/basic/datetime.e2e.ts b/core/src/components/datetime/test/basic/datetime.e2e.ts index cdc07c504da..4c4c0bc6f0c 100644 --- a/core/src/components/datetime/test/basic/datetime.e2e.ts +++ b/core/src/components/datetime/test/basic/datetime.e2e.ts @@ -411,9 +411,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(calendarHeader).toHaveText(/June 2022/); }); - test('should not re-render if swipe is in progress', async ({ page, skip }) => { - skip.browser('webkit', 'Wheel is not available in WebKit'); - + test('should not re-render while a swipe is in progress', async ({ page }) => { await page.setContent( ` @@ -428,15 +426,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(calendarHeader).toHaveText(/May 2022/); - const box = await calendarBody.boundingBox(); + // Scroll the calendar a little, but not far enough to land on the next + // month. This mimics a swipe that the user started but did not finish. + await calendarBody.evaluate((el: HTMLElement) => { + const monthWidth = el.querySelector('.calendar-month')!.clientWidth; + el.scrollLeft = monthWidth + 30; + }); - if (box) { - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - await page.mouse.wheel(-50, 0); - await page.waitForChanges(); + // Give the component time to react to the scroll + await page.waitForChanges(); - await expect(calendarHeader).toHaveText(/May 2022/); - } + // Because the calendar never settled on a new month, the header should + // still show the original month. + await expect(calendarHeader).toHaveText(/May 2022/); }); }); }); diff --git a/core/src/components/gallery-item/gallery-item.scss b/core/src/components/gallery-item/gallery-item.scss new file mode 100644 index 00000000000..8812392aedd --- /dev/null +++ b/core/src/components/gallery-item/gallery-item.scss @@ -0,0 +1,51 @@ +@use "../../themes/mixins" as mixins; + +// Gallery Item +// -------------------------------------------------- + +:host { + display: block; + + width: 100%; +} + +// Slotted content +// -------------------------------------------------- + +// Reset the default margin for slotted elements so wrapper elements +// (such as
) align properly with other gallery items. +::slotted(*) { + @include mixins.margin(0); + + width: 100%; +} + +::slotted(img) { + display: block; + + object-fit: cover; + object-position: center; +} + +// Layout: Uniform +// -------------------------------------------------- + +// In the uniform layout each cell is square. The aspect ratio is applied to +// the slotted content so it fills the cell and a wrapper such as `
` +// carries the ratio for a nested `img` to `inherit`; it is also applied to +// the item itself so the cell stays square even when it is empty or holds +// non-element content (e.g. bare text). An explicit `height` on the content +// overrides the ratio for that content. +:host(.in-gallery-layout-uniform), +:host(.in-gallery-layout-uniform) ::slotted(*) { + aspect-ratio: 1 / 1; +} + +// Layout: Masonry +// -------------------------------------------------- + +:host(.in-gallery-layout-masonry) { + // The spacing between stacked items. Applies to all items except + // for the last item in each column to remove any trailing space. + margin-bottom: var(--internal-gallery-gap, 16px); +} diff --git a/core/src/components/gallery-item/gallery-item.spec.ts b/core/src/components/gallery-item/gallery-item.spec.ts new file mode 100644 index 00000000000..f208f576269 --- /dev/null +++ b/core/src/components/gallery-item/gallery-item.spec.ts @@ -0,0 +1,160 @@ +import { newSpecPage } from '@stencil/core/testing'; +import * as logging from '@utils/logging'; + +import { Gallery } from '../gallery/gallery'; + +import { GalleryItem } from './gallery-item'; + +describe('gallery-item', () => { + let originalMutationObserver: typeof globalThis.MutationObserver | undefined; + let originalResizeObserver: typeof globalThis.ResizeObserver | undefined; + + beforeEach(() => { + // The spec environment does not implement these observers, which the + // components rely on. Provide no-op stand-ins for the duration of the test. + originalMutationObserver = globalThis.MutationObserver; + originalResizeObserver = globalThis.ResizeObserver; + (globalThis as any).MutationObserver = class { + observe() {} + disconnect() {} + }; + (globalThis as any).ResizeObserver = class { + observe() {} + disconnect() {} + }; + }); + + afterEach(() => { + (globalThis as any).MutationObserver = originalMutationObserver; + (globalThis as any).ResizeObserver = originalResizeObserver; + jest.restoreAllMocks(); + }); + + it('should warn when not used inside an ion-gallery', async () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + await newSpecPage({ + components: [GalleryItem], + html: ``, + }); + + expect(warningSpy).toHaveBeenCalledWith( + expect.stringContaining( + '[ion-gallery-item] - This component should be used as a child of an "ion-gallery" component.' + ), + expect.anything() + ); + }); + + it('should not warn when used inside an ion-gallery', async () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + await newSpecPage({ + components: [Gallery, GalleryItem], + html: ``, + }); + + expect(warningSpy).not.toHaveBeenCalled(); + }); + + it('should not have the gallery layout classes when not inside a gallery', async () => { + // Suppress the warning for the missing gallery parent. + jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + const page = await newSpecPage({ + components: [Gallery, GalleryItem], + html: ``, + }); + + const item = page.body.querySelector('ion-gallery-item')!; + + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(false); + expect(item.classList.contains('in-gallery-layout-masonry')).toBe(false); + }); + + it('should reflect the parent gallery uniform layout as a class', async () => { + const page = await newSpecPage({ + components: [Gallery, GalleryItem], + html: ``, + }); + + const item = page.body.querySelector('ion-gallery-item')!; + + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(true); + expect(item.classList.contains('in-gallery-layout-masonry')).toBe(false); + }); + + it('should reflect the parent gallery masonry layout as a class', async () => { + const page = await newSpecPage({ + components: [Gallery, GalleryItem], + html: ``, + }); + + const item = page.body.querySelector('ion-gallery-item')!; + + expect(item.classList.contains('in-gallery-layout-masonry')).toBe(true); + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(false); + }); + + it('should update the layout class when the parent gallery layout changes', async () => { + const page = await newSpecPage({ + components: [Gallery, GalleryItem], + html: ``, + }); + + const gallery = page.body.querySelector('ion-gallery')!; + const item = page.body.querySelector('ion-gallery-item')!; + + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(true); + + // Update the parent gallery's layout at runtime. + gallery.layout = 'masonry'; + await page.waitForChanges(); + + // Verify that the item reflects the new layout class. + expect(item.classList.contains('in-gallery-layout-masonry')).toBe(true); + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(false); + }); + + it('should keep its layout class after being detached and reattached', async () => { + const page = await newSpecPage({ + components: [Gallery, GalleryItem], + html: ``, + }); + + const gallery = page.body.querySelector('ion-gallery')!; + const item = page.body.querySelector('ion-gallery-item')!; + + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(true); + + // Detach and reattach the item, e.g. when a framework re-renders the DOM. + item.remove(); + gallery.appendChild(item); + await page.waitForChanges(); + + // Verify that the item still reflects the correct layout class. + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(true); + }); + + it('should reflect the new gallery layout after being moved between galleries', async () => { + const page = await newSpecPage({ + components: [Gallery, GalleryItem], + html: ` + + + `, + }); + + const item = page.body.querySelector('ion-gallery-item')!; + const masonryGallery = page.body.querySelectorAll('ion-gallery')[1]; + + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(true); + + // Move the item out of the uniform gallery and into the masonry gallery. + masonryGallery.appendChild(item); + await page.waitForChanges(); + + expect(item.classList.contains('in-gallery-layout-masonry')).toBe(true); + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(false); + }); +}); diff --git a/core/src/components/gallery-item/gallery-item.tsx b/core/src/components/gallery-item/gallery-item.tsx new file mode 100644 index 00000000000..abe25fc8f2e --- /dev/null +++ b/core/src/components/gallery-item/gallery-item.tsx @@ -0,0 +1,87 @@ +import type { ComponentInterface } from '@stencil/core'; +import { Component, Element, Host, Method, State, h } from '@stencil/core'; +import { printIonWarning } from '@utils/logging'; + +import { getIonTheme } from '../../global/ionic-global'; + +/** + * @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component. + * @virtualProp {"ios" | "md" | "ionic"} theme - The theme determines the visual appearance of the component. + * + * @slot - The content placed inside of the gallery item. This is typically an + * `img`, but can be any element (e.g. a `figure` wrapping an image and caption). + */ +@Component({ + tag: 'ion-gallery-item', + styleUrl: 'gallery-item.scss', + shadow: true, +}) +export class GalleryItem implements ComponentInterface { + private hasWarnedInvalidParent = false; + + @Element() el!: HTMLIonGalleryItemElement; + + /** + * The layout of the parent `ion-gallery`, mirrored as a class so the item + * can apply layout-specific styles (e.g. a square aspect ratio in the + * `uniform` layout, a bottom margin in the `masonry` layout). + */ + @State() galleryLayout?: 'uniform' | 'masonry'; + + componentWillLoad() { + this.syncGalleryLayout(); + } + + componentDidLoad() { + this.warnInvalidParent(); + } + + connectedCallback() { + this.syncGalleryLayout(); + } + + /** + * Resolve the layout from the parent `ion-gallery`. Called internally on + * load and connect, and by the gallery when its layout changes. + * @internal + */ + @Method() + async syncGalleryLayout() { + this.galleryLayout = this.el.closest('ion-gallery')?.layout; + } + + private onSlotChange = () => { + this.warnInvalidParent(); + }; + + /** + * Warn when the item is not a descendant of an `ion-gallery`. + */ + private warnInvalidParent() { + if (this.hasWarnedInvalidParent || this.el.closest('ion-gallery') !== null) { + return; + } + + printIonWarning( + '[ion-gallery-item] - This component should be used as a child of an "ion-gallery" component.', + this.el + ); + this.hasWarnedInvalidParent = true; + } + + render() { + const { galleryLayout } = this; + const theme = getIonTheme(this); + + return ( + + + + ); + } +} diff --git a/core/src/components/gallery/gallery.scss b/core/src/components/gallery/gallery.scss index f9ea282f52a..7a1879d2fed 100644 --- a/core/src/components/gallery/gallery.scss +++ b/core/src/components/gallery/gallery.scss @@ -1,5 +1,3 @@ -@use "../../themes/native/native.globals" as globals; - // Gallery // -------------------------------------------------- @@ -15,13 +13,6 @@ gap: var(--internal-gallery-gap, 16px); } -// Target all slotted elements in the uniform layout. This ensures that divs -// and images have an aspect ratio of 1/1. Nested images must inherit the -// aspect ratio of their parent. -:host(.gallery-layout-uniform) ::slotted(*) { - aspect-ratio: 1/1; -} - // Layout: Masonry // -------------------------------------------------- @@ -31,32 +22,9 @@ column-gap: var(--internal-gallery-gap, 16px); row-gap: 0; - grid-auto-rows: 2px; -} - -:host(.gallery-layout-masonry) ::slotted(*) { - display: block; - - // Clear min-height so items size to their content - min-height: unset; - - margin-bottom: var(--internal-gallery-gap, 16px); -} - -// Slotted elements -// -------------------------------------------------- - -// Reset the default margin for slotted elements so wrapper elements -// (such as
) align properly with other gallery items. -::slotted(*) { - @include globals.margin(0); - - width: 100%; -} - -::slotted(img) { - display: block; - - object-fit: cover; - object-position: center; + // Each item's row span is computed from its height, so the row track must be + // as small as possible to keep the gap between stacked items accurate. A + // larger track quantizes the span and can inflate the gap by up to (track - 1) + // pixels. 1px keeps the rounding error sub-pixel. + grid-auto-rows: 1px; } diff --git a/core/src/components/gallery/gallery.spec.ts b/core/src/components/gallery/gallery.spec.ts index 321d0024441..5ed9c1e40b9 100644 --- a/core/src/components/gallery/gallery.spec.ts +++ b/core/src/components/gallery/gallery.spec.ts @@ -390,6 +390,26 @@ describe('gallery', () => { expect((sharedGallery as any).sanitizeGap('clamp(10px, 20%, 30px)')).toBe('clamp(10px, 20%, 30px)'); }); + it('should return undefined for malformed math functions', () => { + const malformedValues = ['calc', 'calc(', 'calc()', 'min(', 'clamp(', 'calc(10px + 20px']; + malformedValues.forEach((value) => { + expect((sharedGallery as any).sanitizeGap(value)).toBeUndefined(); + }); + }); + + it('should return the string for CSS variables', () => { + expect((sharedGallery as any).sanitizeGap('var(--app-gap)')).toBe('var(--app-gap)'); + expect((sharedGallery as any).sanitizeGap('var(--app-gap, 16px)')).toBe('var(--app-gap, 16px)'); + expect((sharedGallery as any).sanitizeGap(' var(--app-gap) ')).toBe('var(--app-gap)'); + }); + + it('should return undefined for malformed CSS variables', () => { + const malformedValues = ['var(--app-gap. 16px)', 'var(--app-gap', 'var()', 'var(16px)']; + malformedValues.forEach((value) => { + expect((sharedGallery as any).sanitizeGap(value)).toBeUndefined(); + }); + }); + it('should return the px value for positive integers', () => { expect((sharedGallery as any).sanitizeGap(0)).toBe('0px'); expect((sharedGallery as any).sanitizeGap('0')).toBe('0px'); @@ -613,6 +633,93 @@ describe('gallery', () => { }); }); + it('should resolve to the CSS variable for each breakpoint without warning when gap is a CSS variable', () => { + const breakpoints = DEFAULT_BREAKPOINTS; + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + sharedGallery.gap = 'var(--app-gap)'; + + breakpoints.forEach(({ width }) => { + expect((sharedGallery as any).getGapForWidth(width)).toBe('var(--app-gap)'); + }); + + expect(warningSpy).not.toHaveBeenCalled(); + + warningSpy.mockRestore(); + }); + + it('should resolve to the CSS variable for breakpoints that set one when gap is a breakpoint map', () => { + const breakpoints = [ + { width: 0, expectedGap: DEFAULT_GAP }, + { width: 575, expectedGap: DEFAULT_GAP }, + { width: 576, expectedGap: DEFAULT_GAP }, + { width: 767, expectedGap: DEFAULT_GAP }, + { width: 768, expectedGap: 'var(--app-gap)' }, + { width: 991, expectedGap: 'var(--app-gap)' }, + { width: 992, expectedGap: DEFAULT_GAP }, + { width: 1199, expectedGap: DEFAULT_GAP }, + { width: 1200, expectedGap: DEFAULT_GAP }, + { width: 1399, expectedGap: DEFAULT_GAP }, + { width: 1400, expectedGap: DEFAULT_GAP }, + ]; + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + sharedGallery.gap = { md: 'var(--app-gap)' }; + + breakpoints.forEach(({ width, expectedGap }) => { + expect((sharedGallery as any).getGapForWidth(width)).toBe(expectedGap); + }); + + expect(warningSpy).not.toHaveBeenCalled(); + + warningSpy.mockRestore(); + }); + + it('should resolve a breakpoint map mixing CSS variables, literals, and unset (default) breakpoints', () => { + const breakpoints = [ + { width: 0, expectedGap: '8px' }, + { width: 575, expectedGap: '8px' }, + { width: 576, expectedGap: 'var(--g-sm)' }, + { width: 767, expectedGap: 'var(--g-sm)' }, + { width: 768, expectedGap: 'var(--g-md)' }, + { width: 991, expectedGap: 'var(--g-md)' }, + { width: 992, expectedGap: DEFAULT_GAP }, + { width: 1199, expectedGap: DEFAULT_GAP }, + { width: 1200, expectedGap: '2rem' }, + { width: 1399, expectedGap: '2rem' }, + { width: 1400, expectedGap: DEFAULT_GAP }, + ]; + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + sharedGallery.gap = { xs: '8px', sm: 'var(--g-sm)', md: 'var(--g-md)', xl: '2rem' }; + + breakpoints.forEach(({ width, expectedGap }) => { + expect((sharedGallery as any).getGapForWidth(width)).toBe(expectedGap); + }); + + expect(warningSpy).not.toHaveBeenCalled(); + + warningSpy.mockRestore(); + }); + + it('should warn and fallback to the default gap when gap is a malformed CSS variable', () => { + const breakpoints = DEFAULT_BREAKPOINTS; + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + sharedGallery.gap = 'var(--app-gap. 16px)'; + + breakpoints.forEach(({ width, expectedGap }) => { + expect((sharedGallery as any).getGapForWidth(width)).toBe(expectedGap); + }); + + expect(warningSpy).toHaveBeenCalledWith( + expect.stringContaining('[ion-gallery] - Invalid "gap" value ("var(--app-gap. 16px)").'), + el + ); + + warningSpy.mockRestore(); + }); + it('should resolve to the proper gap when the gap property is set to an out of order object', () => { const breakpoints = [ { width: 0, expectedGap: '8px' }, @@ -639,53 +746,189 @@ describe('gallery', () => { describe('gallery: layout', () => { describe('getItems()', () => { - it('should include direct child SVG elements with HTML elements', () => { - const div = document.createElement('div'); - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - el.appendChild(div); - el.appendChild(svg); + it('should collect direct ion-gallery-item children as items', () => { + const itemOne = document.createElement('ion-gallery-item'); + const itemTwo = document.createElement('ion-gallery-item'); + el.appendChild(itemOne); + el.appendChild(itemTwo); const items = (sharedGallery as any).getItems(); - expect(items).toEqual([div, svg]); - expect(items[1].namespaceURI).toBe('http://www.w3.org/2000/svg'); + expect(items).toEqual([itemOne, itemTwo]); }); - it('should exclude direct children without a usable CSSStyleDeclaration (no setProperty)', () => { - const included = document.createElement('div'); - const excluded = document.createElement('div'); - Object.defineProperty(excluded, 'style', { - configurable: true, - enumerable: true, - get() { - return { cssText: '' } as unknown as CSSStyleDeclaration; - }, - }); - el.appendChild(included); - el.appendChild(excluded); + it('should return items found inside a wrapper element', () => { + const wrapper = document.createElement('div'); + const itemOne = document.createElement('ion-gallery-item'); + const itemTwo = document.createElement('ion-gallery-item'); + wrapper.appendChild(itemOne); + wrapper.appendChild(itemTwo); + el.appendChild(wrapper); const items = (sharedGallery as any).getItems(); - expect(items).toEqual([included]); + expect(items).toEqual([itemOne, itemTwo]); }); - it('should apply masonry grid placement styles to slotted SVG elements', () => { - const div = document.createElement('div'); - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - el.appendChild(div); - el.appendChild(svg); + it('should not return items that belong to a nested gallery', () => { + const ownedItem = document.createElement('ion-gallery-item'); + + const nestedGallery = document.createElement('ion-gallery'); + nestedGallery.appendChild(document.createElement('ion-gallery-item')); + + // A wrapper holds one of our items alongside a nested gallery. + const wrapper = document.createElement('div'); + wrapper.appendChild(ownedItem); + wrapper.appendChild(nestedGallery); + el.appendChild(wrapper); const items = (sharedGallery as any).getItems(); - jest.spyOn(div, 'getBoundingClientRect').mockReturnValue({ height: 20 } as DOMRect); - jest.spyOn(svg, 'getBoundingClientRect').mockReturnValue({ height: 30 } as DOMRect); + // Only the item whose nearest gallery ancestor is this gallery is + // returned; the nested gallery's item is left to the nested gallery. + expect(items).toEqual([ownedItem]); + }); + + it('should return no items when a gallery only contains a nested gallery', () => { + const nestedGallery = document.createElement('ion-gallery'); + nestedGallery.appendChild(document.createElement('ion-gallery-item')); + nestedGallery.appendChild(document.createElement('ion-gallery-item')); + el.appendChild(nestedGallery); + + const items = (sharedGallery as any).getItems(); + + // The nested gallery's items belong to it, not the outer gallery. + expect(items).toEqual([]); + }); + }); + + describe('collapseWrappers()', () => { + it('should collapse a wrapper that owns items with display: contents', () => { + const wrapper = document.createElement('div'); + wrapper.appendChild(document.createElement('ion-gallery-item')); + el.appendChild(wrapper); + + (sharedGallery as any).collapseWrappers(); + + expect(wrapper.style.display).toBe('contents'); + }); + + it('should restore the box of a wrapper that no longer owns items', () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + const wrapper = document.createElement('div'); + const item = document.createElement('ion-gallery-item'); + wrapper.appendChild(item); + el.appendChild(wrapper); + + // Wrapper is initially collapsed with display: contents. + (sharedGallery as any).collapseWrappers(); + expect(wrapper.style.display).toBe('contents'); + + // Remove the item, leaving the wrapper empty. + wrapper.removeChild(item); + + // The wrapper is no longer collapsed and a warning is issued about the + // now-invalid child element. + (sharedGallery as any).collapseWrappers(); + expect(wrapper.style.display).toBe(''); + expect(warningSpy).toHaveBeenCalledWith( + expect.stringContaining('[ion-gallery] - Gallery items must be wrapped in "ion-gallery-item" components.'), + el + ); + + warningSpy.mockRestore(); + }); + + it('should not clear an unrelated inline display on an invalid child', () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + // A non-item child with its own inline display and no gallery items. + const stray = document.createElement('div'); + stray.style.display = 'flex'; + el.appendChild(stray); + + (sharedGallery as any).collapseWrappers(); + + // We only undo a `display: contents` we set ourselves; an inline + // display the consumer set must be left intact. + expect(stray.style.display).toBe('flex'); + + warningSpy.mockRestore(); + }); + + it('should warn and not collapse when a gallery only contains a nested gallery', () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + // Nesting a gallery directly inside a gallery is invalid: the outer + // gallery has no items of its own to place. + const nestedGallery = document.createElement('ion-gallery'); + nestedGallery.appendChild(document.createElement('ion-gallery-item')); + el.appendChild(nestedGallery); + + (sharedGallery as any).collapseWrappers(); + + // The nested gallery is left untouched and the outer gallery warns. + expect(nestedGallery.style.display).toBe(''); + expect(warningSpy).toHaveBeenCalledWith( + expect.stringContaining('[ion-gallery] - Gallery items must be wrapped in "ion-gallery-item" components.'), + el + ); + + warningSpy.mockRestore(); + }); + + it('should warn about children that do not contain an ion-gallery-item', () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + const img = document.createElement('img'); + el.appendChild(img); + + (sharedGallery as any).collapseWrappers(); + + expect(warningSpy).toHaveBeenCalledWith( + expect.stringContaining('[ion-gallery] - Gallery items must be wrapped in "ion-gallery-item" components.'), + el + ); + + // Only wrappers that own items are collapsed, so an ignored child's + // inline display must be left untouched. + expect(img.style.display).toBe(''); + + warningSpy.mockRestore(); + }); + + it('should only warn once about invalid children', () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + el.appendChild(document.createElement('img')); + el.appendChild(document.createElement('span')); + + (sharedGallery as any).collapseWrappers(); + (sharedGallery as any).collapseWrappers(); + + expect(warningSpy).toHaveBeenCalledTimes(1); + + warningSpy.mockRestore(); + }); + }); + + describe('layoutMasonry()', () => { + it('should apply masonry grid placement styles to items', () => { + const itemOne = document.createElement('ion-gallery-item'); + const itemTwo = document.createElement('ion-gallery-item'); + el.appendChild(itemOne); + el.appendChild(itemTwo); + + jest.spyOn(itemOne, 'getBoundingClientRect').mockReturnValue({ height: 20 } as DOMRect); + jest.spyOn(itemTwo, 'getBoundingClientRect').mockReturnValue({ height: 30 } as DOMRect); - (sharedGallery as any).layoutMasonry(items, 10, 0, 2); + (sharedGallery as any).layoutMasonry([itemOne, itemTwo], 10, 0, 2); - expect(div.style.gridColumn).toBe('1'); - expect(svg.style.gridColumn).toBe('2'); - expect(svg.style.gridRowStart).not.toBe(''); - expect(svg.style.gridRowEnd).not.toBe(''); + expect(itemOne.style.gridColumn).toBe('1'); + expect(itemTwo.style.gridColumn).toBe('2'); + expect(itemTwo.style.gridRowStart).not.toBe(''); + expect(itemTwo.style.gridRowEnd).not.toBe(''); }); }); diff --git a/core/src/components/gallery/gallery.tsx b/core/src/components/gallery/gallery.tsx index acaba155ac2..3b5b7168cea 100644 --- a/core/src/components/gallery/gallery.tsx +++ b/core/src/components/gallery/gallery.tsx @@ -1,6 +1,6 @@ import type { ComponentInterface } from '@stencil/core'; import { Component, Element, Host, Listen, Prop, Watch, h } from '@stencil/core'; -import { isValidLengthPercentage } from '@utils/css-value-validation'; +import { isCssVariable, isValidLengthPercentage } from '@utils/css-value-validation'; import { raf } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; @@ -23,16 +23,16 @@ type GalleryBreakpoint = keyof typeof BREAKPOINTS; const BREAKPOINT_ORDER: GalleryBreakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl']; /** - * Direct slotted children that support CSS grid placement and inline `style`. - * This is a union of `HTMLElement` and `SVGElement` to support both HTML and SVG elements. + * The tag of the component used to wrap each gallery item. */ -type GalleryItemElement = HTMLElement | SVGElement; +const GALLERY_ITEM_SELECTOR = 'ion-gallery-item'; /** * @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component. * @virtualProp {"ios" | "md" | "ionic"} theme - The theme determines the visual appearance of the component. * - * @slot - Content is placed in a responsive gallery layout. + * @slot - One or more `ion-gallery-item` components, placed in a responsive + * gallery layout. */ @Component({ tag: 'ion-gallery', @@ -51,6 +51,7 @@ export class Gallery implements ComponentInterface { private hasWarnedInvalidColumns = false; private hasWarnedInvalidGap = false; private hasWarnedUnusedOrder = false; + private hasWarnedInvalidItems = false; /** * The visual layout of the gallery. When `uniform`, rows take up the height @@ -79,7 +80,8 @@ export class Gallery implements ComponentInterface { /** * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) * values like `16px`, `1rem`, `20%`, math functions like `calc(10px + 20%)`, - * or numbers (treated as pixel values). Can also be set as a breakpoint map + * CSS variables like `var(--app-gallery-gap)`, or numbers (treated as pixel + * values). Can also be set as a breakpoint map * (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept * space-separated values or CSS keyword values like `inherit`, `auto`, etc. */ @@ -95,6 +97,7 @@ export class Gallery implements ComponentInterface { @Watch('order') protected onLayoutOrOrderChanged() { this.syncResponsiveLayout(); + this.syncItemLayout(); // Wait until the next animation frame to warn about unused order // to avoid erroneous warnings when the layout and order are updated @@ -104,13 +107,24 @@ export class Gallery implements ComponentInterface { }); } + /** + * Sync the current layout with each item when the gallery's `layout` + * changes. + */ + private syncItemLayout() { + this.getItems().forEach((item) => { + item.syncGalleryLayout(); + }); + } + componentDidLoad() { + this.collapseWrappers(); this.updateResponsiveStyles(true); this.resizeObserver = new ResizeObserver(() => { this.updateResponsiveStyles(); this.scheduleMasonryResize(); }); - this.resizeObserver.observe(this.el); + this.observeResizes(); this.scheduleMasonryResize(); @@ -127,6 +141,24 @@ export class Gallery implements ComponentInterface { this.resizeObserver = undefined; } + /** + * Observe the host and each item for size changes. Items are observed in + * addition to the host so masonry placement is recomputed when an item's + * rendered height changes — most importantly when a dynamically added + * `ion-gallery-item` finishes hydrating, which (unlike an ``) emits no + * `load` event and does not change the host's measured size while collapsed. + */ + private observeResizes() { + const observer = this.resizeObserver; + if (observer === undefined) { + return; + } + + observer.disconnect(); + observer.observe(this.el); + this.getItems().forEach((item) => observer.observe(item)); + } + /** * Listen for the load event on child elements. * When the layout is `masonry`, this listener is used to schedule a resize @@ -146,12 +178,13 @@ export class Gallery implements ComponentInterface { } /** - * Listen for the slotchange event on the slot. - * When the layout is `masonry`, this listener is used to schedule a resize - * of the masonry grid when the slot changes. This is useful for when items - * are added or removed from the gallery. + * Listen for the slotchange event on the slot. When the gallery's items are + * added or removed, re-collapse wrappers, re-observe items for size changes, + * and recompute the masonry grid. */ private onSlotChange = () => { + this.collapseWrappers(); + this.observeResizes(); this.scheduleMasonryResize(); }; @@ -201,9 +234,10 @@ export class Gallery implements ComponentInterface { } /** - * Normalize a single gap value (`gap` as a number, string, or one entry from - * a `gap` breakpoint map) to a CSS length string. Returns `undefined` when - * the input cannot be interpreted as a valid CSS length. + * Normalize a single gap value (`gap` as a number, a string such as a CSS + * length-percentage or `var()` reference, or one entry from a `gap` + * breakpoint map) to a CSS length string. Returns `undefined` when the + * input cannot be interpreted as a valid CSS length or `var()` reference. */ private sanitizeGap(gap: number | string | undefined): string | undefined { if (gap === undefined) { @@ -224,6 +258,10 @@ export class Gallery implements ComponentInterface { return undefined; } + if (isCssVariable(normalizedGap)) { + return normalizedGap; + } + const isValidCssLength = isValidLengthPercentage(normalizedGap); return isValidCssLength ? normalizedGap : undefined; @@ -346,7 +384,7 @@ export class Gallery implements ComponentInterface { printIonWarning( `[ion-gallery] - Invalid "gap" value (${JSON.stringify( gap - )}). Expected a non-negative number, CSS length string, or breakpoint map object (e.g. { xs: 8, md: "1rem" }).`, + )}). Expected a non-negative number, CSS length string, CSS variable (e.g. var(--app-gap)), or breakpoint map object (e.g. { xs: 8, md: "1rem" }).`, this.el ); this.hasWarnedInvalidGap = true; @@ -444,20 +482,67 @@ export class Gallery implements ComponentInterface { } /** - * Return all directly slotted children of the gallery that can be grid items - * with inline placement styles (HTML elements and SVG elements). + * Return the `ion-gallery-item` elements to place in the grid. Each item is a + * direct grid cell, whether a direct child or nested inside a pass-through + * wrapper (e.g. a layout `
`). Items belonging to a nested `ion-gallery` + * are excluded. */ - private getItems(): GalleryItemElement[] { - return Array.from(this.el.children).filter( - (child): child is GalleryItemElement => typeof (child as any).style?.setProperty === 'function' + private getItems(): HTMLIonGalleryItemElement[] { + return Array.from(this.el.querySelectorAll(GALLERY_ITEM_SELECTOR)).filter( + (item) => item.closest('ion-gallery') === this.el ); } + /** + * Collapse each pass-through wrapper's box with `display: contents` so its + * items participate in the gallery grid. Restore the box of a wrapper that + * no longer contains items, and warn about children that contain none. + */ + private collapseWrappers() { + const items = this.getItems(); + + Array.from(this.el.children as HTMLCollectionOf).forEach((child) => { + if (child.matches(GALLERY_ITEM_SELECTOR)) { + return; + } + + if (!items.some((item) => child.contains(item))) { + // If the wrapper was previously collapsed with `display: contents` + // but now contains no items, clear the display style. + if (child.style.display === 'contents') { + child.style.display = ''; + } + this.warnInvalidItems(); + return; + } + + // Collapse the wrapper's box so its items sit directly in the grid. + child.style.display = 'contents'; + }); + } + + /** + * Warn when the gallery has content that is not wrapped in an + * `ion-gallery-item` component. Items belonging to a nested + * gallery are considered invalid content for the parent gallery. + */ + private warnInvalidItems() { + if (this.hasWarnedInvalidItems) { + return; + } + + printIonWarning( + `[ion-gallery] - Gallery items must be wrapped in "ion-gallery-item" components. Direct children that are not "ion-gallery-item" (and do not contain one) are ignored.`, + this.el + ); + this.hasWarnedInvalidItems = true; + } + /** * Clear the item styles for the given item element. * This is used to switch between uniform and masonry layouts. */ - private clearItemStyles(itemEl: GalleryItemElement) { + private clearItemStyles(itemEl: HTMLIonGalleryItemElement) { itemEl.style.gridRowStart = ''; itemEl.style.gridRowEnd = ''; itemEl.style.gridColumn = ''; @@ -471,12 +556,20 @@ export class Gallery implements ComponentInterface { this.getItems().forEach((itemEl) => this.clearItemStyles(itemEl)); } + /** + * Whether the item contains any images that have not finished loading. + * Used to defer masonry placement until the rendered height is final. + */ + private hasUnloadedImages(itemEl: HTMLIonGalleryItemElement): boolean { + return Array.from(itemEl.querySelectorAll('img')).some((img) => !img.complete || img.naturalHeight === 0); + } + /** * Convert a rendered item height to the number of grid rows it should span. - * Returns undefined for images that are not fully loaded yet. + * Returns undefined when the item has images that are not fully loaded yet. */ - private calculateRowSpan(itemEl: GalleryItemElement, rowHeight: number, rowGap: number) { - if (itemEl instanceof HTMLImageElement && (!itemEl.complete || itemEl.naturalHeight === 0)) { + private calculateRowSpan(itemEl: HTMLIonGalleryItemElement, rowHeight: number, rowGap: number) { + if (this.hasUnloadedImages(itemEl)) { return undefined; } @@ -517,9 +610,9 @@ export class Gallery implements ComponentInterface { /** * Apply masonry placement by assigning each item a column and row span. */ - private layoutMasonry(items: GalleryItemElement[], rowHeight: number, rowGap: number, columns: number) { + private layoutMasonry(items: HTMLIonGalleryItemElement[], rowHeight: number, rowGap: number, columns: number) { const columnHeights = new Array(columns).fill(0); - const lastItemsByColumn = new Array(columns).fill(undefined); + const lastItemsByColumn = new Array(columns).fill(undefined); items.forEach((itemEl, i) => { itemEl.style.marginBottom = ''; diff --git a/core/src/components/gallery/test/basic/gallery.e2e.ts b/core/src/components/gallery/test/basic/gallery.e2e.ts index 71ea1ac2317..2ead83d28cf 100644 --- a/core/src/components/gallery/test/basic/gallery.e2e.ts +++ b/core/src/components/gallery/test/basic/gallery.e2e.ts @@ -1,13 +1,15 @@ import { expect } from '@playwright/test'; import { configs, test } from '@utils/test/playwright'; +import { DEFAULT_COLUMNS, DEFAULT_GAP } from '../../gallery-constants'; + const DEFAULT_COLUMNS_BREAKPOINTS = [ - { name: 'xs', width: 384, expectedColumns: 2 }, - { name: 'sm', width: 576, expectedColumns: 3 }, - { name: 'md', width: 768, expectedColumns: 4 }, - { name: 'lg', width: 992, expectedColumns: 6 }, - { name: 'xl', width: 1200, expectedColumns: 8 }, - { name: 'xxl', width: 1400, expectedColumns: 10 }, + { name: 'xs', width: 384, expectedColumns: DEFAULT_COLUMNS.xs }, + { name: 'sm', width: 576, expectedColumns: DEFAULT_COLUMNS.sm }, + { name: 'md', width: 768, expectedColumns: DEFAULT_COLUMNS.md }, + { name: 'lg', width: 992, expectedColumns: DEFAULT_COLUMNS.lg }, + { name: 'xl', width: 1200, expectedColumns: DEFAULT_COLUMNS.xl }, + { name: 'xxl', width: 1400, expectedColumns: DEFAULT_COLUMNS.xxl }, ]; /** @@ -24,18 +26,18 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve `, config @@ -62,18 +64,18 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve `, config @@ -96,18 +98,18 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve `, config @@ -121,6 +123,118 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t ) .toBe(`${breakpoint.expectedColumns}`); }); + + test(`should resolve the default gap value on ${breakpoint.name} screens`, async ({ page }) => { + await page.setViewportSize({ width: breakpoint.width, height: 900 }); + + await page.setContent( + ` + + One + Two + Three + Four + + `, + config + ); + + const gallery = page.locator('ion-gallery'); + + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).rowGap)).toBe(DEFAULT_GAP); + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).columnGap)).toBe(DEFAULT_GAP); + }); + + test(`should resolve the gap CSS variable on ${breakpoint.name} screens`, async ({ page }) => { + await page.setViewportSize({ width: breakpoint.width, height: 900 }); + + await page.setContent( + ` + + One + Two + Three + Four + + `, + config + ); + + const gallery = page.locator('ion-gallery'); + + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).rowGap)).toBe('24px'); + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).columnGap)).toBe('24px'); + }); + + test(`should resolve a gap breakpoint map of CSS variables on ${breakpoint.name} screens`, async ({ page }) => { + await page.setViewportSize({ width: breakpoint.width, height: 900 }); + + await page.setContent( + ` + + One + Two + Three + Four + + `, + config + ); + + const gallery = page.locator('ion-gallery'); + + // Breakpoint maps are objects, so they are set as a property rather + // than an attribute. + await gallery.evaluate((el) => { + (el as HTMLIonGalleryElement).gap = { + xs: 'var(--g-xs)', + sm: 'var(--g-sm)', + md: 'var(--g-md)', + lg: 'var(--g-lg)', + xl: 'var(--g-xl)', + xxl: 'var(--g-xxl)', + }; + }); + + // The resolved gap for each breakpoint, matching the variables set in + // the style above. + const expectedGap: Record = { + xs: '2px', + sm: '4px', + md: '8px', + lg: '16px', + xl: '24px', + xxl: '32px', + }; + + // Each breakpoint resolves its own gap variable. + await expect + .poll(() => gallery.evaluate((el) => getComputedStyle(el).rowGap)) + .toBe(expectedGap[breakpoint.name]); + }); + }); + + test('should resolve the gap CSS variable fallback when the variable is not defined', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 900 }); + + // The CSS variable `--app-gap` is never declared, so the browser + // resolves the var() fallback (8px). + await page.setContent( + ` + + One + Two + Three + Four + + `, + config + ); + + const gallery = page.locator('ion-gallery'); + + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).rowGap)).toBe('8px'); + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).columnGap)).toBe('8px'); }); }); }); diff --git a/core/src/components/gallery/test/basic/index.html b/core/src/components/gallery/test/basic/index.html index 65b186fb795..9c422741305 100644 --- a/core/src/components/gallery/test/basic/index.html +++ b/core/src/components/gallery/test/basic/index.html @@ -24,33 +24,48 @@ - One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve -
One
-
Two
-
Three
-
Four
-
Five
-
Six
-
Seven
-
Eight
-
Nine
-
Ten
-
Eleven
-
Twelve
+ One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve +
+ + + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve
@@ -60,64 +75,64 @@ margin-bottom: 16px; } - ion-gallery img, - ion-gallery div { + ion-gallery-item, + ion-gallery-item img { border-radius: 16px; } - ion-gallery div { + ion-gallery-item { display: flex; align-items: center; justify-content: center; color: #fff; } - ion-gallery div:nth-child(1) { + ion-gallery-item:nth-child(1) { background: #ff6b6b; } - ion-gallery div:nth-child(2) { + ion-gallery-item:nth-child(2) { background: #4ecdc4; } - ion-gallery div:nth-child(3) { + ion-gallery-item:nth-child(3) { background: #ffe66d; color: #333; } - ion-gallery div:nth-child(4) { + ion-gallery-item:nth-child(4) { background: #5f27cd; } - ion-gallery div:nth-child(5) { + ion-gallery-item:nth-child(5) { background: #7f8c8d; } - ion-gallery div:nth-child(6) { + ion-gallery-item:nth-child(6) { background: #ff9f43; } - ion-gallery div:nth-child(7) { + ion-gallery-item:nth-child(7) { background: #ff3f34; } - ion-gallery div:nth-child(8) { + ion-gallery-item:nth-child(8) { background: #2ecc71; } - ion-gallery div:nth-child(9) { + ion-gallery-item:nth-child(9) { background: #34495e; } - ion-gallery div:nth-child(10) { + ion-gallery-item:nth-child(10) { background: #1abc9c; } - ion-gallery div:nth-child(11) { + ion-gallery-item:nth-child(11) { background: #e67e22; } - ion-gallery div:nth-child(12) { + ion-gallery-item:nth-child(12) { background: #9b59b6; } diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts b/core/src/components/gallery/test/layout/gallery.e2e.ts index 2ed856602ed..32a7472147b 100644 --- a/core/src/components/gallery/test/layout/gallery.e2e.ts +++ b/core/src/components/gallery/test/layout/gallery.e2e.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; import { configs, test } from '@utils/test/playwright'; -import { numberToWords, sharedStyles } from '../utils'; +import { numberToWords, sharedGalleryStyles, sharedGalleryItemStyles } from '../utils'; const LAYOUT_OPTIONS = ['uniform', 'masonry']; const ORDER_OPTIONS = ['sequential', 'best-fit']; @@ -18,28 +18,29 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t const orderSuffix = layout === 'masonry' ? `-${order}` : ''; test.describe(title(`gallery: ${layout} layout${layout === 'masonry' ? ` (${order})` : ''}`), () => { - test(`should properly display same height divs with ${layout} layout${ + test(`should properly display same height items with ${layout} layout${ layout === 'masonry' ? ` and ${order} order` : '' }`, async ({ page }) => { await page.setContent( ` -
One
-
Two
-
Three
-
Four
-
Five
-
Six
-
Seven
-
Eight
-
Nine
-
Ten
-
Eleven
-
Twelve
+ One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve
`, config @@ -58,28 +59,29 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await expect(gallery).toHaveScreenshot(screenshot(`gallery-${layout}${orderSuffix}-divs-same-height`)); }); - test(`should properly display variable height divs with ${layout} layout${ + test(`should properly display variable height items with ${layout} layout${ layout === 'masonry' ? ` and ${order} order` : '' }`, async ({ page }) => { await page.setContent( ` -
One
-
Two
-
Three
-
Four
-
Five
-
Six
-
Seven
-
Eight
-
Nine
-
Ten
-
Eleven
-
Twelve
+ One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve
`, config @@ -104,7 +106,7 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve `, config @@ -148,22 +150,22 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve `, config @@ -183,20 +185,21 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t }); if (layout === 'masonry') { - test(`should properly display dynamically appended divs with ${order} order`, async ({ page }) => { + test(`should properly display dynamically appended items with ${order} order`, async ({ page }) => { await page.setContent( ` -
One
-
Two
-
Three
-
Four
-
Five
-
Six
+ One + Two + Three + Four + Five + Six
`, config @@ -204,18 +207,18 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t const gallery = page.locator('ion-gallery'); - const divHeights = [130, 80, 110, 90, 100, 150]; - const appendedItems = divHeights.map((height, i) => ({ + const itemHeights = [130, 80, 110, 90, 100, 150]; + const appendedItems = itemHeights.map((height, i) => ({ itemLabel: numberToWords(7 + i), itemHeight: height, })); await gallery.evaluate((galleryEl, items) => { items.forEach(({ itemLabel, itemHeight }) => { - const divEl = document.createElement('div'); - divEl.style.height = `${itemHeight}px`; - divEl.textContent = itemLabel; - galleryEl.append(divEl); + const galleryItemEl = document.createElement('ion-gallery-item'); + galleryItemEl.style.height = `${itemHeight}px`; + galleryItemEl.textContent = itemLabel; + galleryEl.append(galleryItemEl); }); }, appendedItems); @@ -236,16 +239,16 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four - Five - Six + One + Two + Three + Four + Five + Six `, config @@ -259,11 +262,13 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await gallery.evaluate((galleryEl, items) => { items.forEach(({ itemLabel, itemSrc }) => { + const galleryItemEl = document.createElement('ion-gallery-item'); const imageEl = document.createElement('img'); imageEl.src = itemSrc; imageEl.alt = itemLabel; - galleryEl.append(imageEl); + galleryItemEl.append(imageEl); + galleryEl.append(galleryItemEl); }); }, appendedItems); @@ -292,11 +297,12 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` -
- One -
-
- Two -
-
- Three -
-
- Four -
-
- Five -
-
- Six -
+ +
+ One +
+
+ +
+ Two +
+
+ +
+ Three +
+
+ +
+ Four +
+
+ +
+ Five +
+
+ +
+ Six +
+
`, config @@ -338,6 +356,7 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await gallery.evaluate((galleryEl, items) => { items.forEach(({ itemLabel, itemSrc }) => { + const galleryItemEl = document.createElement('ion-gallery-item'); const figureEl = document.createElement('figure'); figureEl.className = 'gallery-image-item'; @@ -346,7 +365,8 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t imageEl.alt = itemLabel; figureEl.append(imageEl); - galleryEl.append(figureEl); + galleryItemEl.append(figureEl); + galleryEl.append(galleryItemEl); }); }, appendedItems); @@ -366,4 +386,39 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t }); }); }); + + test.describe(title('gallery: masonry gap'), () => { + test('should resolve the gap CSS variable in the masonry layout', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 900 }); + + // Twelve items so the first item is never the last in its column, whose + // bottom margin masonry zeroes out to remove trailing space. + await page.setContent( + ` + + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve + + `, + config + ); + + const gallery = page.locator('ion-gallery'); + + // In the masonry layout the gap variable drives the column gap + // and the spacing below items (margin bottom). + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).columnGap)).toBe('24px'); + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el.children[0]).marginBottom)).toBe('24px'); + }); + }); }); diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png index dcb8b8e60e5..4e920dd5853 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png index 22afe4afd0a..c65fb378f1f 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png index ecab78ae12f..f2c03927b75 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Chrome-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Chrome-linux.png index dcb8b8e60e5..4e920dd5853 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Firefox-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Firefox-linux.png index 22afe4afd0a..c65fb378f1f 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Safari-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Safari-linux.png index ecab78ae12f..f2c03927b75 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Safari-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-figures-images-dynamically-appended-md-ltr-Mobile-Safari-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-figures-images-dynamically-appended-md-ltr-Mobile-Safari-linux.png index 4b62aed8461..99d7f716a5c 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-figures-images-dynamically-appended-md-ltr-Mobile-Safari-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-figures-images-dynamically-appended-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-images-dynamically-appended-md-ltr-Mobile-Safari-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-images-dynamically-appended-md-ltr-Mobile-Safari-linux.png index 4b62aed8461..99d7f716a5c 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-images-dynamically-appended-md-ltr-Mobile-Safari-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-images-dynamically-appended-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png index 49911676b12..bc87b1b7b5e 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png index 8278407501d..e812584e1da 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png index 232203ba278..7a5b0a30745 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Chrome-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Chrome-linux.png index 49911676b12..bc87b1b7b5e 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Firefox-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Firefox-linux.png index 8278407501d..e812584e1da 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Safari-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Safari-linux.png index 232203ba278..7a5b0a30745 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Safari-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/gallery/test/layout/index.html b/core/src/components/gallery/test/layout/index.html index feffc05f6b6..9a7df7a5b53 100644 --- a/core/src/components/gallery/test/layout/index.html +++ b/core/src/components/gallery/test/layout/index.html @@ -39,52 +39,72 @@

Uniform

Divs

-
One
-
Two
-
Three
-
Four
-
Five
-
Six
-
Seven
-
Eight
-
Nine
-
Ten
-
Eleven
-
Twelve
+
One
+
Two
+
Three
+
Four
+
Five
+
Six
+
Seven
+
Eight
+
Nine
+
Ten
+
Eleven
+
Twelve

Images

- One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve

Same Height Images

- One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve
@@ -210,6 +230,7 @@

Same Height Images

galleries.forEach((galleryEl) => { const isImageGallery = galleryEl.querySelector('img') !== null; + const galleryItemEl = document.createElement('ion-gallery-item'); if (isImageGallery) { const photoId = 100 + ((nextItemNumber - 1) % 100); @@ -218,7 +239,8 @@

Same Height Images

`https://picsum.photos/id/${photoId}/164/${alternatingImgHeight}`, labelText ); - galleryEl.append(imageItemEl); + galleryItemEl.append(imageItemEl); + galleryEl.append(galleryItemEl); return; } @@ -227,7 +249,8 @@

Same Height Images

divEl.textContent = numberToWords(nextItemNumber); divEl.style.height = `${randomDivHeight}px`; divEl.style.background = randomColor; - galleryEl.append(divEl); + galleryItemEl.append(divEl); + galleryEl.append(galleryItemEl); }); nextItemNumber++; @@ -267,16 +290,16 @@

Same Height Images

margin: 0 auto; } - ion-gallery img, - ion-gallery div { + ion-gallery-item img, + ion-gallery-item div { border-radius: 16px; } - .same-height-gallery img { + .same-height-gallery ion-gallery-item img { height: 164px; } - ion-gallery .gallery-image-item img { + ion-gallery-item .gallery-image-item img { display: block; /** @@ -290,11 +313,11 @@

Same Height Images

object-position: center; } - ion-gallery .gallery-image-item { + ion-gallery-item .gallery-image-item { position: relative; } - ion-gallery .gallery-image-label { + ion-gallery-item .gallery-image-label { position: absolute; inset: 0; display: flex; @@ -307,70 +330,70 @@

Same Height Images

pointer-events: none; } - ion-gallery div { + ion-gallery-item div { display: flex; align-items: center; justify-content: center; color: #fff; } - ion-gallery div:nth-child(1) { + ion-gallery-item:nth-child(1) div { background: #ff6b6b; height: 175px; } - ion-gallery div:nth-child(2) { + ion-gallery-item:nth-child(2) div { background: #4ecdc4; height: 30px; } - ion-gallery div:nth-child(3) { + ion-gallery-item:nth-child(3) div { background: #ffe66d; color: #333; height: 90px; } - ion-gallery div:nth-child(4) { + ion-gallery-item:nth-child(4) div { background: #5f27cd; height: 50px; } - ion-gallery div:nth-child(5) { + ion-gallery-item:nth-child(5) div { background: #7f8c8d; height: 110px; } - ion-gallery div:nth-child(6) { + ion-gallery-item:nth-child(6) div { background: #ff9f43; height: 175px; } - ion-gallery div:nth-child(7) { + ion-gallery-item:nth-child(7) div { background: #ff3f34; height: 130px; } - ion-gallery div:nth-child(8) { + ion-gallery-item:nth-child(8) div { background: #2ecc71; height: 80px; } - ion-gallery div:nth-child(9) { + ion-gallery-item:nth-child(9) div { background: #34495e; height: 110px; } - ion-gallery div:nth-child(10) { + ion-gallery-item:nth-child(10) div { background: #1abc9c; height: 90px; } - ion-gallery div:nth-child(11) { + ion-gallery-item:nth-child(11) div { background: #e67e22; height: 100px; } - ion-gallery div:nth-child(12) { + ion-gallery-item:nth-child(12) div { background: #9b59b6; height: 150px; } diff --git a/core/src/components/gallery/test/utils.ts b/core/src/components/gallery/test/utils.ts index 3ffc0b076b5..65c9ceda27b 100644 --- a/core/src/components/gallery/test/utils.ts +++ b/core/src/components/gallery/test/utils.ts @@ -1,59 +1,60 @@ -export const sharedStyles = ` +export const sharedGalleryStyles = ` ion-gallery { width: 343px; } +`; - div { +export const sharedGalleryItemStyles = ` + ion-gallery-item { color: #fff; - height: 150px; } - div:nth-child(1) { + ion-gallery-item:nth-child(1) { background: #ff6b6b; } - div:nth-child(2) { + ion-gallery-item:nth-child(2) { background: #4ecdc4; } - div:nth-child(3) { + ion-gallery-item:nth-child(3) { background: #ffe66d; color: #333; } - div:nth-child(4) { + ion-gallery-item:nth-child(4) { background: #5f27cd; } - div:nth-child(5) { + ion-gallery-item:nth-child(5) { background: #7f8c8d; } - div:nth-child(6) { + ion-gallery-item:nth-child(6) { background: #ff9f43; } - div:nth-child(7) { + ion-gallery-item:nth-child(7) { background: #ff3f34; } - div:nth-child(8) { + ion-gallery-item:nth-child(8) { background: #2ecc71; } - div:nth-child(9) { + ion-gallery-item:nth-child(9) { background: #34495e; } - div:nth-child(10) { + ion-gallery-item:nth-child(10) { background: #1abc9c; } - div:nth-child(11) { + ion-gallery-item:nth-child(11) { background: #e67e22; } - div:nth-child(12) { + ion-gallery-item:nth-child(12) { background: #9b59b6; } `; diff --git a/core/src/components/gallery/test/wrapper/gallery.e2e.ts b/core/src/components/gallery/test/wrapper/gallery.e2e.ts new file mode 100644 index 00000000000..2ac57a820fb --- /dev/null +++ b/core/src/components/gallery/test/wrapper/gallery.e2e.ts @@ -0,0 +1,84 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +import { sharedGalleryStyles } from '../utils'; + +const LAYOUT_OPTIONS = ['uniform', 'masonry']; +const ITEM_HEIGHTS = [175, 30, 90, 50, 110, 175, 130, 80, 110, 90, 100, 150]; + +const buildItems = () => + ITEM_HEIGHTS.map( + (height, i) => `
${i + 1}
` + ).join(''); + +/** + * A wrapper element that contains gallery items (e.g. a layout `
` + * or a framework-generated wrapper) must be transparent to the gallery + * layout. The gallery collapses the wrapper with `display: contents` + * so the nested items participate in the grid as if the wrapper were + * not present. + * + * Rather than rely on a screenshot, this asserts that a wrapped gallery lays + * its items out identically to an unwrapped one. + * + * This behavior does not vary across modes/directions. + */ +configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, title }) => { + LAYOUT_OPTIONS.forEach((layout) => { + test.describe(title(`gallery: wrapper (${layout})`), () => { + test('should lay out wrapped items identically to unwrapped items', async ({ page }) => { + const items = buildItems(); + + await page.setContent( + ` + + + ${items} + + +
${items}
+
+ `, + config + ); + + // The wrapper's box is collapsed so it does not affect the grid. + await expect + .poll(() => page.locator('#wrapped .some-wrapper').evaluate((el) => getComputedStyle(el).display)) + .toBe('contents'); + + const measure = () => + page.evaluate(() => { + const itemRects = (gallerySelector: string) => { + const gallery = document.querySelector(gallerySelector)!; + const galleryRect = gallery.getBoundingClientRect(); + return Array.from(gallery.querySelectorAll('ion-gallery-item')).map((item) => { + const rect = item.getBoundingClientRect(); + return { + left: Math.round(rect.left - galleryRect.left), + top: Math.round(rect.top - galleryRect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + }); + }; + + return { unwrapped: itemRects('#unwrapped'), wrapped: itemRects('#wrapped') }; + }); + + // Wait for both layouts to settle, then confirm they match exactly. + await expect + .poll(async () => { + const { unwrapped, wrapped } = await measure(); + return JSON.stringify(unwrapped) === JSON.stringify(wrapped); + }) + .toBe(true); + + const { unwrapped, wrapped } = await measure(); + expect(wrapped).toEqual(unwrapped); + }); + }); + }); +}); diff --git a/core/src/components/gallery/test/wrapper/index.html b/core/src/components/gallery/test/wrapper/index.html new file mode 100644 index 00000000000..28aaa7ab656 --- /dev/null +++ b/core/src/components/gallery/test/wrapper/index.html @@ -0,0 +1,79 @@ + + + + + Gallery - Wrapper + + + + + + + + + + + + + Gallery - Wrapper + + Toggle Layout + + + + + +

Layout: Uniform

+ +
+ One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve +
+
+ + +
+
+ + diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx index 440f75c6e4c..5e1388f6343 100644 --- a/core/src/components/input-otp/input-otp.tsx +++ b/core/src/components/input-otp/input-otp.tsx @@ -1,12 +1,23 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { AttachInternals, Component, Element, Event, Fragment, Host, Prop, State, h, Watch } from '@stencil/core'; +import { + Component, + Element, + Event, + Fragment, + Host, + Method, + Prop, + State, + h, + Watch, + AttachInternals, +} from '@stencil/core'; import { reportValidityToElementInternals } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; import { isRTL } from '@utils/rtl'; import { createColorClasses } from '@utils/theme'; -import { Method } from 'ionicons/dist/types/stencil-public-runtime'; import { getIonMode } from '../../global/ionic-global'; import type { Color } from '../../interface'; diff --git a/core/src/components/item/item.ionic.scss b/core/src/components/item/item.ionic.scss index 665f5346c69..107480559a0 100644 --- a/core/src/components/item/item.ionic.scss +++ b/core/src/components/item/item.ionic.scss @@ -6,7 +6,8 @@ :host { --background: #{globals.$ion-bg-surface-default}; - --background-activated: #{globals.$ion-bg-select-default}; + --background-activated: #{globals.$ion-bg-neutral-subtlest-press}; + --background-activated-opacity: 1; --border-color: #{globals.$ion-primitives-neutral-300}; --border-style: #{globals.$ion-border-style-solid}; --border-width: #{0 0 globals.$ion-border-size-025 0}; @@ -62,24 +63,20 @@ slot[name="end"]::slotted(*) { @include globals.disabled-state(); } -// Item: Activated -// -------------------------------------------------- - -:host(.ion-activated) .item-native { - background: var(--background-activated); -} - // Item: Focused // -------------------------------------------------- :host(.ion-focused) .item-native::after { @include globals.border-radius(inherit); @include globals.position(0, 0, 0, 0); + @include globals.focused-state( + globals.$ion-border-size-050, + globals.$ion-border-style-solid, + globals.$ion-border-focus-default + ); position: absolute; - border-width: globals.$ion-border-size-050; - border-style: globals.$ion-border-style-solid; - border-color: globals.$ion-border-focus-default; + outline-offset: calc(globals.$ion-border-size-050 * -1); content: ""; } @@ -111,14 +108,3 @@ slot[name="end"]::slotted(*) { :host(.item-lines-none) { --inner-border-width: #{globals.$ion-border-size-0}; } - -// Item in Select Modal -// -------------------------------------------------- -:host(.in-select-modal) { - --background-focused: #{globals.$ion-bg-neutral-subtlest-press}; - --background-focused-opacity: 0; -} - -:host(.in-select-modal.ion-focused) .item-native { - --border-radius: #{globals.$ion-border-radius-400}; -} diff --git a/core/src/components/item/item.tsx b/core/src/components/item/item.tsx index d25f473f586..503f42eb423 100644 --- a/core/src/components/item/item.tsx +++ b/core/src/components/item/item.tsx @@ -259,7 +259,12 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac private isFocusable(): boolean { const focusableChild = this.el.querySelector('.ion-focusable'); - return this.canActivate() || focusableChild !== null; + // An item is focusable when it can receive keyboard focus: when it is + // clickable (has a `button` or `href`), when it has a single input cover + // (e.g. a radio or checkbox), or when it contains a focusable child. + // Focusable items get the `ion-focusable` class so the `ion-focused` + // class is applied while tabbing through them. + return this.isClickable() || this.hasCover() || focusableChild !== null; } private hasStartEl() { @@ -415,7 +420,6 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac 'item-control-needs-pointer-cursor': firstInteractiveNeedsPointerCursor, 'item-disabled': disabled, 'in-list': inList, - 'in-select-modal': hostContext('ion-select-modal', this.el), 'item-multiple-inputs': this.multipleInputs, 'ion-activatable': canActivate, 'ion-focusable': this.focusable, diff --git a/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-ltr-Mobile-Safari-linux.png index 14e473b2c4e..4b36bc36f92 100644 Binary files a/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-rtl-Mobile-Safari-linux.png b/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-rtl-Mobile-Safari-linux.png index 389cbd0560e..ea9acd314dd 100644 Binary files a/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-rtl-Mobile-Safari-linux.png and b/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-md-rtl-Mobile-Safari-linux.png b/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-md-rtl-Mobile-Safari-linux.png index a9072fa2cc4..c33969f30e7 100644 Binary files a/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-md-rtl-Mobile-Safari-linux.png and b/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/item/test/colors/item.e2e.ts-snapshots/item-colors-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/item/test/colors/item.e2e.ts-snapshots/item-colors-diff-ios-ltr-Mobile-Safari-linux.png index 898b3b789f1..a725431351a 100644 Binary files a/core/src/components/item/test/colors/item.e2e.ts-snapshots/item-colors-diff-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/item/test/colors/item.e2e.ts-snapshots/item-colors-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/item/test/reorder/item.e2e.ts-snapshots/item-reorder-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/item/test/reorder/item.e2e.ts-snapshots/item-reorder-diff-ios-ltr-Mobile-Firefox-linux.png index 1e95a095fee..c47f99b2ff4 100644 Binary files a/core/src/components/item/test/reorder/item.e2e.ts-snapshots/item-reorder-diff-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/item/test/reorder/item.e2e.ts-snapshots/item-reorder-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/item/test/reorder/item.e2e.ts-snapshots/item-reorder-diff-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/item/test/reorder/item.e2e.ts-snapshots/item-reorder-diff-ios-rtl-Mobile-Firefox-linux.png index 54c0ef144b7..f622b627e21 100644 Binary files a/core/src/components/item/test/reorder/item.e2e.ts-snapshots/item-reorder-diff-ios-rtl-Mobile-Firefox-linux.png and b/core/src/components/item/test/reorder/item.e2e.ts-snapshots/item-reorder-diff-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/loading/test/standalone/index.html b/core/src/components/loading/test/standalone/index.html index c0826d3f30f..cf3853af58f 100644 --- a/core/src/components/loading/test/standalone/index.html +++ b/core/src/components/loading/test/standalone/index.html @@ -14,7 +14,7 @@ diff --git a/core/src/components/menu-button/test/async/index.html b/core/src/components/menu-button/test/async/index.html index 105b9a3b87a..682673195ba 100644 --- a/core/src/components/menu-button/test/async/index.html +++ b/core/src/components/menu-button/test/async/index.html @@ -13,7 +13,7 @@ diff --git a/core/src/components/menu/test/a11y/index.html b/core/src/components/menu/test/a11y/index.html index cf67b2133c2..8b0aa68c702 100644 --- a/core/src/components/menu/test/a11y/index.html +++ b/core/src/components/menu/test/a11y/index.html @@ -11,7 +11,7 @@ diff --git a/core/src/components/menu/test/basic/index.html b/core/src/components/menu/test/basic/index.html index 4e79a4fbb21..5c11d02859d 100644 --- a/core/src/components/menu/test/basic/index.html +++ b/core/src/components/menu/test/basic/index.html @@ -30,7 +30,7 @@ diff --git a/core/src/components/menu/test/multiple/index.html b/core/src/components/menu/test/multiple/index.html index 9a45a237fa9..d4bcf2dec4e 100644 --- a/core/src/components/menu/test/multiple/index.html +++ b/core/src/components/menu/test/multiple/index.html @@ -13,7 +13,7 @@ diff --git a/core/src/components/menu/test/safe-area/index.html b/core/src/components/menu/test/safe-area/index.html index 477d5890aa9..64c1a89b6dc 100644 --- a/core/src/components/menu/test/safe-area/index.html +++ b/core/src/components/menu/test/safe-area/index.html @@ -13,7 +13,7 @@ diff --git a/core/src/components/modal/test/card/index.html b/core/src/components/modal/test/card/index.html index c47f5bda425..d064d0030ae 100644 --- a/core/src/components/modal/test/card/index.html +++ b/core/src/components/modal/test/card/index.html @@ -26,7 +26,7 @@ diff --git a/core/src/components/modal/test/card/modal-card.e2e.ts b/core/src/components/modal/test/card/modal-card.e2e.ts index 8df6161ec70..b07de14cfb4 100644 --- a/core/src/components/modal/test/card/modal-card.e2e.ts +++ b/core/src/components/modal/test/card/modal-card.e2e.ts @@ -35,12 +35,22 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c await cardModalPage.openModalByTrigger('#card'); await cardModalPage.openModalByTrigger('.add'); + // Firefox only: Move the mouse away from the ".add" button + // so the button's hover state is not captured in the + // screenshot + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot(screenshot(`modal-card-stacked-present`)); }); test('should not have visual regressions with stacked custom cards', async ({ page }) => { await cardModalPage.openModalByTrigger('#card-custom'); await cardModalPage.openModalByTrigger('.add'); + // Firefox only: Move the mouse away from the ".add" button + // so the button's hover state is not captured in the + // screenshot + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot(screenshot(`modal-card-custom-stacked-present`)); }); }); @@ -125,7 +135,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c expect(ionDragEnd.length).toBe(0); /** - * Drage the modal further to verify it does: + * Drag the modal further to verify it does: * - not emit the event again for `ionDragStart` * - emit more `ionDragMove` events * - emit the `ionDragEnd` event when the gesture ends diff --git a/core/src/components/modal/test/card/modal-card.e2e.ts-snapshots/modal-card-custom-stacked-present-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/card/modal-card.e2e.ts-snapshots/modal-card-custom-stacked-present-ios-ltr-Mobile-Firefox-linux.png index b0d38e0b11c..780ff62f55a 100644 Binary files a/core/src/components/modal/test/card/modal-card.e2e.ts-snapshots/modal-card-custom-stacked-present-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/modal/test/card/modal-card.e2e.ts-snapshots/modal-card-custom-stacked-present-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/card/modal-card.e2e.ts-snapshots/modal-card-stacked-present-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/card/modal-card.e2e.ts-snapshots/modal-card-stacked-present-ios-ltr-Mobile-Firefox-linux.png index 4bd4dd52c17..ef65d773244 100644 Binary files a/core/src/components/modal/test/card/modal-card.e2e.ts-snapshots/modal-card-stacked-present-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/modal/test/card/modal-card.e2e.ts-snapshots/modal-card-stacked-present-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/card/modal-tablet.e2e.ts b/core/src/components/modal/test/card/modal-tablet.e2e.ts index 96291f50271..d8177407168 100644 --- a/core/src/components/modal/test/card/modal-tablet.e2e.ts +++ b/core/src/components/modal/test/card/modal-tablet.e2e.ts @@ -28,12 +28,22 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c await cardModalPage.openModalByTrigger('#card'); await cardModalPage.openModalByTrigger('.add'); + // Firefox only: Move the mouse away from the ".add" button + // so the button's hover state is not captured in the + // screenshot + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot(screenshot(`modal-card-stacked-present-tablet`)); }); test('should not have visual regressions with stacked custom cards', async ({ page }) => { await cardModalPage.openModalByTrigger('#card-custom'); await cardModalPage.openModalByTrigger('.add'); + // Firefox only: Move the mouse away from the ".add" button + // so the button's hover state is not captured in the + // screenshot + await page.mouse.move(0, 0); + await expect(page).toHaveScreenshot(screenshot(`modal-card-custom-stacked-present-tablet`)); }); }); diff --git a/core/src/components/modal/test/card/modal-tablet.e2e.ts-snapshots/modal-card-custom-stacked-present-tablet-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/card/modal-tablet.e2e.ts-snapshots/modal-card-custom-stacked-present-tablet-ios-ltr-Mobile-Firefox-linux.png index f279d8b3c1f..794c3ad6253 100644 Binary files a/core/src/components/modal/test/card/modal-tablet.e2e.ts-snapshots/modal-card-custom-stacked-present-tablet-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/modal/test/card/modal-tablet.e2e.ts-snapshots/modal-card-custom-stacked-present-tablet-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/card/modal-tablet.e2e.ts-snapshots/modal-card-stacked-present-tablet-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/card/modal-tablet.e2e.ts-snapshots/modal-card-stacked-present-tablet-ios-ltr-Mobile-Firefox-linux.png index 0f5d0d99b32..a77d4998842 100644 Binary files a/core/src/components/modal/test/card/modal-tablet.e2e.ts-snapshots/modal-card-stacked-present-tablet-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/modal/test/card/modal-tablet.e2e.ts-snapshots/modal-card-stacked-present-tablet-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/host-elements/index.html b/core/src/components/modal/test/host-elements/index.html index d5d90f9c6ad..1ac0cb59860 100644 --- a/core/src/components/modal/test/host-elements/index.html +++ b/core/src/components/modal/test/host-elements/index.html @@ -14,7 +14,7 @@ diff --git a/core/src/components/modal/test/sheet/modal.e2e.ts b/core/src/components/modal/test/sheet/modal.e2e.ts index ffa89001d9b..81fecdac7ac 100644 --- a/core/src/components/modal/test/sheet/modal.e2e.ts +++ b/core/src/components/modal/test/sheet/modal.e2e.ts @@ -197,7 +197,6 @@ configs({ modes: ['ios', 'ionic-ios'], directions: ['ltr'] }).forEach(({ title, }); test('it should reset the breakpoint value on dismiss', async ({ page }) => { - await page.goto('/src/components/modal/test/sheet', config); test.info().annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/25245', @@ -342,8 +341,6 @@ configs({ modes: ['ios', 'ionic-ios'], directions: ['ltr'] }).forEach(({ title, // In this scenario, the modal is opened and has no backdrop, allowing // the background content to be focused. We need to ensure that we can // navigate to the drag handle using the keyboard and voiceover/talkback. - await page.goto('/src/components/modal/test/sheet', config); - await page.setContent( ` @@ -391,6 +388,73 @@ configs({ modes: ['ios', 'ionic-ios'], directions: ['ltr'] }).forEach(({ title, await expect(dragHandle).toBeFocused(); }); + + test('it should preserve the last arrow-focused radio when tabbing', async ({ page, pageUtils }) => { + await page.setContent( + ` + + Open + + + + Options + + Cancel + + + + + + + + One + + + Two + + + Three + + + + + + + + `, + config + ); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#open-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + const firstRadio = modal.locator('ion-radio').nth(0); + const secondRadio = modal.locator('ion-radio').nth(1); + const handle = modal.locator('.modal-handle'); + + await firstRadio.focus(); + await expect(firstRadio).toBeFocused(); + + await pageUtils.pressKeys('ArrowDown'); + await expect(secondRadio).toBeFocused(); + + await pageUtils.pressKeys('Tab'); + await expect(handle).toBeFocused(); + }); }); test.describe(title('sheet modal: drag events'), () => { @@ -438,4 +502,51 @@ configs({ modes: ['ios', 'ionic-ios'], directions: ['ltr'] }).forEach(({ title, expect(Object.keys(dragEndEvent.detail).length).toBe(5); }); }); + + test.describe(title('sheet modal: late breakpoints binding'), () => { + test('should not crash when swiped after breakpoints are set after the modal loads', async ({ page }) => { + const pageErrors: string[] = []; + page.on('pageerror', (err) => pageErrors.push(err.message)); + + await page.setContent( + ` + + Modal Content + + `, + config + ); + + const modal = page.locator('ion-modal'); + + /** + * Simulates a JS framework (e.g. Angular with zoneless change detection) + * applying the `breakpoints` binding after the web component has finished + * loading. `setContent` resolves after `componentDidLoad`, so this lands + * too late for the manual `breakpointsChanged()` call in `componentDidLoad` + * to pick it up + */ + await modal.evaluate((el: HTMLIonModalElement) => { + el.breakpoints = [0, 1]; + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + + await modal.evaluate((el: HTMLIonModalElement) => el.present()); + await ionModalDidPresent.next(); + + // Swiping the sheet down should snap it to breakpoint 0 and dismiss it + // without throwing an error + const handle = page.locator('ion-modal .modal-handle'); + await expect(handle).toBeVisible(); + await dragElementBy(handle, page, 0, 600); + + // Flush any pending errors from the gesture's end handler + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + expect(pageErrors).toEqual([]); + + await ionModalDidDismiss.next(); + }); + }); }); diff --git a/core/src/components/modal/test/spec/index.html b/core/src/components/modal/test/spec/index.html index 361fdf4de1c..851eb8a9133 100644 --- a/core/src/components/modal/test/spec/index.html +++ b/core/src/components/modal/test/spec/index.html @@ -40,7 +40,7 @@ diff --git a/core/src/components/modal/test/standalone/index.html b/core/src/components/modal/test/standalone/index.html index a14ce783469..c16baba38ab 100644 --- a/core/src/components/modal/test/standalone/index.html +++ b/core/src/components/modal/test/standalone/index.html @@ -14,7 +14,7 @@ diff --git a/core/src/components/picker-column-option/picker-column-option.md.scss b/core/src/components/picker-column-option/picker-column-option.md.scss index b2cb665b7a7..c80f5ed9333 100644 --- a/core/src/components/picker-column-option/picker-column-option.md.scss +++ b/core/src/components/picker-column-option/picker-column-option.md.scss @@ -1,5 +1,17 @@ @import "./picker-column-option"; -:host(.option-active) { +/** + * The active option is colored when either: + * - picker-column applies the `option-active` class (standalone ion-picker, + * where the column lives in the light DOM), or + * - datetime sets `part="... active"` on the option. This is the reliable + * value-based source of truth and keeps the option colored when the column + * lives inside datetime's shadow DOM, where the class can be missed + * (WebKit's elementsFromPoint is unreliable in a shadow root). + * TODO(FW-6594): Determine if this workaround can be removed when iOS 16 is + * no longer supported. + */ +:host(.option-active), +:host([part~="active"]) { color: current-color(base); } diff --git a/core/src/components/picker-legacy-column/test/standalone/index.html b/core/src/components/picker-legacy-column/test/standalone/index.html index f269eb055a5..add3401292a 100644 --- a/core/src/components/picker-legacy-column/test/standalone/index.html +++ b/core/src/components/picker-legacy-column/test/standalone/index.html @@ -14,7 +14,7 @@ diff --git a/core/src/components/picker-legacy/test/basic/index.html b/core/src/components/picker-legacy/test/basic/index.html index 23cd22a2d03..5f02db1cc61 100644 --- a/core/src/components/picker-legacy/test/basic/index.html +++ b/core/src/components/picker-legacy/test/basic/index.html @@ -29,7 +29,7 @@ diff --git a/core/src/components/popover/test/adjustment/index.html b/core/src/components/popover/test/adjustment/index.html index 78d8dc105a6..c51d44a4748 100644 --- a/core/src/components/popover/test/adjustment/index.html +++ b/core/src/components/popover/test/adjustment/index.html @@ -13,7 +13,7 @@ diff --git a/core/src/components/popover/test/basic/index.html b/core/src/components/popover/test/basic/index.html index dce0a242e0a..dab4ba52e24 100644 --- a/core/src/components/popover/test/basic/index.html +++ b/core/src/components/popover/test/basic/index.html @@ -14,7 +14,7 @@ diff --git a/core/src/components/popover/test/size/index.html b/core/src/components/popover/test/size/index.html index 2dc0a420825..6d30addef76 100644 --- a/core/src/components/popover/test/size/index.html +++ b/core/src/components/popover/test/size/index.html @@ -41,7 +41,7 @@ diff --git a/core/src/components/popover/test/standalone/index.html b/core/src/components/popover/test/standalone/index.html index c149da2ea94..c804ff54b94 100644 --- a/core/src/components/popover/test/standalone/index.html +++ b/core/src/components/popover/test/standalone/index.html @@ -14,7 +14,7 @@ diff --git a/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-ios-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-ios-ltr-Mobile-Safari-linux.png index b0894575b0e..5c2f116d569 100644 Binary files a/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-ios-rtl-Mobile-Safari-linux.png b/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-ios-rtl-Mobile-Safari-linux.png index 37aec7be624..0f2b81dae8b 100644 Binary files a/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-ios-rtl-Mobile-Safari-linux.png and b/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-md-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-md-ltr-Mobile-Safari-linux.png index ba0cccc7e78..24ae03189b6 100644 Binary files a/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-md-ltr-Mobile-Safari-linux.png and b/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-md-rtl-Mobile-Safari-linux.png b/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-md-rtl-Mobile-Safari-linux.png index ff9947ec11e..412da91df23 100644 Binary files a/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-md-rtl-Mobile-Safari-linux.png and b/core/src/components/popover/test/standalone/popover.e2e.ts-snapshots/popover-standalone-basic-popover-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/radio-group/radio-group.tsx b/core/src/components/radio-group/radio-group.tsx index f1ff225faa0..4d597db617c 100644 --- a/core/src/components/radio-group/radio-group.tsx +++ b/core/src/components/radio-group/radio-group.tsx @@ -386,6 +386,7 @@ export class RadioGroup implements ComponentInterface { aria-labelledby={label ? labelId : null} aria-describedby={this.hintTextId} aria-invalid={this.isInvalid ? 'true' : undefined} + data-roving-focus onClick={this.onClick} > {this.renderHintText()} diff --git a/core/src/components/radio/radio.common.scss b/core/src/components/radio/radio.common.scss index 75677ccbfdf..ebb7b8194b7 100644 --- a/core/src/components/radio/radio.common.scss +++ b/core/src/components/radio/radio.common.scss @@ -51,6 +51,7 @@ input { box-sizing: border-box; } +// Hide the native focus outline. :host(:focus) { outline: none; } diff --git a/core/src/components/router/router.tsx b/core/src/components/router/router.tsx index c6576698a3d..ae617bca694 100644 --- a/core/src/components/router/router.tsx +++ b/core/src/components/router/router.tsx @@ -9,7 +9,7 @@ import type { NavigationHookResult } from '../route/route-interface'; import { ROUTER_INTENT_BACK, ROUTER_INTENT_FORWARD, ROUTER_INTENT_NONE } from './utils/constants'; import { printRedirects, printRoutes } from './utils/debug'; -import { readNavState, waitUntilNavNode, writeNavState } from './utils/dom'; +import { readNavState, scrollToFragment, waitUntilNavNode, writeNavState } from './utils/dom'; import type { RouteChain, RouterDirection, RouterEventDetail } from './utils/interface'; import { findChainForIDs, findChainForSegments, findRouteRedirect } from './utils/matching'; import { readRedirects, readRoutes } from './utils/parser'; @@ -28,6 +28,7 @@ export class Router implements ComponentInterface { private state = 0; private lastState = 0; private waitPromise?: Promise; + private fragmentScrollToken = 0; @Element() el!: HTMLElement; @@ -71,11 +72,18 @@ export class Router implements ComponentInterface { if (typeof canProceed === 'object') { const { redirect } = canProceed; const path = parsePath(redirect); - this.setSegments(path.segments, ROUTER_INTENT_NONE, path.queryString); - await this.writeNavStateRoot(path.segments, ROUTER_INTENT_NONE); + this.setSegments(path.segments, ROUTER_INTENT_NONE, path.queryString, path.fragment); + const result = await this.writeNavStateRoot(path.segments, ROUTER_INTENT_NONE); + if (result) { + this.maybeScrollToFragment(); + } } - } else { - await this.onRoutesChanged(); + return; + } + + const result = await this.onRoutesChanged(); + if (result) { + this.maybeScrollToFragment(); } } @@ -97,7 +105,12 @@ export class Router implements ComponentInterface { return false; } } - return this.writeNavStateRoot(segments, direction); + const result = await this.writeNavStateRoot(segments, direction); + if (result) { + this.maybeScrollToFragment(); + } + + return result; } @Listen('ionBackButton', { target: 'document' }) @@ -136,7 +149,7 @@ export class Router implements ComponentInterface { const currentPath = this.previousPath ?? '/'; // Convert currentPath to an URL by pre-pending a protocol and a host to resolve the relative path. const url = new URL(path, `https://host/${currentPath}`); - path = url.pathname + url.search; + path = url.pathname + url.search + url.hash; } let parsedPath = parsePath(path); @@ -150,8 +163,13 @@ export class Router implements ComponentInterface { } } - this.setSegments(parsedPath.segments, direction, parsedPath.queryString); - return this.writeNavStateRoot(parsedPath.segments, direction, animation); + this.setSegments(parsedPath.segments, direction, parsedPath.queryString, parsedPath.fragment); + const result = await this.writeNavStateRoot(parsedPath.segments, direction, animation); + if (result) { + this.maybeScrollToFragment(); + } + + return result; } /** Go back to previous page in the window.history. */ @@ -192,7 +210,12 @@ export class Router implements ComponentInterface { return false; } - this.setSegments(segments, direction); + // navChanged is an outlet-driven URL sync. Only keep the fragment when + // the path is unchanged; on a real navigation it refers to an anchor on + // the page being left and would be stale. + const newPath = generatePath(segments); + const fragment = newPath === this.previousPath ? this.getFragment() : undefined; + this.setSegments(segments, direction, undefined, fragment); await this.safeWriteNavState(outlet, chain, ROUTER_INTENT_NONE, segments, null, ids.length); return true; @@ -249,8 +272,8 @@ export class Router implements ComponentInterface { let redirectFrom: string[] | null = null; if (redirect) { - const { segments: toSegments, queryString } = redirect.to!; - this.setSegments(toSegments, direction, queryString); + const { segments: toSegments, queryString, fragment } = redirect.to!; + this.setSegments(toSegments, direction, queryString, fragment); redirectFrom = redirect.from; segments = toSegments; } @@ -365,15 +388,38 @@ export class Router implements ComponentInterface { return changed; } - private setSegments(segments: string[], direction: RouterDirection, queryString?: string) { + private setSegments(segments: string[], direction: RouterDirection, queryString?: string, fragment?: string) { this.state++; - writeSegments(window.history, this.root, this.useHash, segments, direction, this.state, queryString); + // Every URL write invalidates any in-flight fragment scroll: a newer nav + // (with or without a fragment, successful or not) should always supersede. + this.fragmentScrollToken++; + writeSegments(window.history, this.root, this.useHash, segments, direction, this.state, queryString, fragment); } private getSegments(): string[] | null { return readSegments(window.location, this.root, this.useHash); } + private getFragment(): string | undefined { + // In hash mode the URL fragment trails a second `#` (e.g. `#/path#anchor`); + // parse the routing portion to extract it. + const raw = this.useHash ? parsePath(window.location.hash.slice(1)).fragment : window.location.hash.slice(1); + return raw ? raw : undefined; + } + + /** + * Fires a best-effort scroll to the current URL fragment. The scroll bails + * if a newer `setSegments` advances `fragmentScrollToken` mid-flight. + */ + private maybeScrollToFragment() { + const fragment = this.getFragment(); + if (!fragment) return; + const token = this.fragmentScrollToken; + // Fire-and-forget; the returned promise resolves only after the scroll + // animation completes, which the caller does not need to await. + scrollToFragment(fragment, () => token === this.fragmentScrollToken).catch(() => {}); + } + private routeChangeEvent(toSegments: string[], redirectFromSegments: string[] | null): RouterEventDetail | null { const from = this.previousPath; const to = generatePath(toSegments); diff --git a/core/src/components/router/test/basic/index.html b/core/src/components/router/test/basic/index.html index 406adb95f49..12d459caa50 100644 --- a/core/src/components/router/test/basic/index.html +++ b/core/src/components/router/test/basic/index.html @@ -26,7 +26,10 @@

Go to page 2

Go to page 3 (hola)

Go to page 3 (something)

- +

Page 2 with fragment

+

Page 3 with query and fragment

+
page-one spacer
+

page-one anchor (must lose to the active page's anchor)

`; } } @@ -42,6 +45,9 @@

Go to page 3 (hola)

Go to page 3 (hello)

+
spacer
+

Anchor target

+
trailing spacer
`; } } @@ -60,6 +66,7 @@

Go to page 2

Go to page 1

Page 3 (relative) + Page 3 (relative with fragment) Page 3 (absolute) `; } diff --git a/core/src/components/router/test/basic/router.e2e.ts b/core/src/components/router/test/basic/router.e2e.ts index 33f4e37d33b..ed9faab3532 100644 --- a/core/src/components/router/test/basic/router.e2e.ts +++ b/core/src/components/router/test/basic/router.e2e.ts @@ -1,6 +1,21 @@ import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; import { configs, test } from '@utils/test/playwright'; +/** + * Waits until `page-two`'s `ion-content` has scrolled past the fixture's 2000px + * spacer. The anchor target sits below the spacer, so a successful fragment + * scroll must move `scrollTop` well past it; a regression that scrolled by + * only a handful of pixels would fail this threshold. + */ +const waitForAnchorScrolled = (page: E2EPage) => + page.waitForFunction(async () => { + const content = document.querySelector('page-two ion-content') as HTMLIonContentElement | null; + if (!content) return false; + const scrollEl = await content.getScrollElement(); + return scrollEl.scrollTop > 1500; + }); + /** * This behavior does not vary across modes/directions. */ @@ -27,6 +42,188 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => expect(page.url()).toContain('#/two/three/absolute'); }); + + test('should route when ion-router-link href contains a fragment', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + + await page.goto(`/src/components/router/test/basic#/two`, config); + await page.click('#link-with-fragment'); + + await expect(page.locator('page-two')).toBeVisible(); + expect(page.url()).toContain('#/two/second-page#anchor'); + expect(errors.filter((m) => m.includes('not part of the routing set'))).toEqual([]); + }); + + test('should route when ion-router-link href contains both query and fragment', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + await page.goto(`/src/components/router/test/basic#/two`, config); + await page.click('#link-with-query-and-fragment'); + + await expect(page.locator('page-three')).toBeVisible(); + expect(page.url()).toContain('#/two/three/hola?flag=true#anchor'); + }); + + test('should preserve the fragment when push() resolves a relative path', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + await page.goto(`/src/components/router/test/basic#/two/three/hola`, config); + await page.click('#btn-rel-with-fragment'); + + expect(page.url()).toContain('#/two/three/relative#anchor'); + }); + + test('should scroll to the fragment target after navigating', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + await page.goto(`/src/components/router/test/basic#/two`, config); + await page.click('#link-with-fragment'); + + await expect(page.locator('page-two #anchor')).toBeVisible(); + await waitForAnchorScrolled(page); + }); + + test('should scroll to the fragment target on initial deep-link load', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + // Land on the fixture without a fragment first so the test helper can + // attach its query params (it appends them after the hash, which would + // otherwise pollute the fragment). Once loaded we replaceState to a URL + // that includes the fragment, then reload to simulate a true cold open. + await page.goto(`/src/components/router/test/basic#/two`, config); + await page.evaluate(() => { + const { origin, pathname, search } = window.location; + window.history.replaceState({}, '', `${origin}${pathname}${search}#/two/second-page#anchor`); + }); + await page.reload(); + + await expect(page.locator('page-two #anchor')).toBeVisible(); + await waitForAnchorScrolled(page); + }); + + test('should scroll on deep-link load even when an inactive tab has hydrated', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + // Inactive `ion-tab` elements carry `.ion-page` but use `.tab-hidden` + // instead of `.ion-page-hidden`. The fixture's inline `tab-four` is one + // such sibling. Waiting for it to hydrate before reload makes the + // active-page lookup deterministic across runs. + await page.goto(`/src/components/router/test/basic#/two`, config); + await page.waitForFunction(() => !!document.querySelector('ion-tab[tab="tab-four"].hydrated')); + await page.evaluate(() => { + const { origin, pathname, search } = window.location; + window.history.replaceState({}, '', `${origin}${pathname}${search}#/two/second-page#anchor`); + }); + await page.reload(); + await page.waitForFunction(() => !!document.querySelector('ion-tab[tab="tab-four"].hydrated')); + + await expect(page.locator('page-two #anchor')).toBeVisible(); + await waitForAnchorScrolled(page); + }); + + test('should scope the fragment lookup to the active page', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + // page-one and page-two both expose `id="anchor"`. page-one is kept in + // the DOM as `.ion-page-hidden` after the push; a document-wide + // `getElementById` would return its anchor first. The router must scope + // the lookup to the active page so page-two's anchor wins. + await page.goto(`/src/components/router/test/basic#/two`, config); + await page.click('#link-with-fragment'); + + await expect(page.locator('page-two #anchor')).toBeVisible(); + await waitForAnchorScrolled(page); + + // page-one is still in the DOM but should not have been scrolled. + const pageOneScrollTop = await page.evaluate(async () => { + const content = document.querySelector('page-one ion-content') as HTMLIonContentElement | null; + if (!content) return 0; + const scrollEl = await content.getScrollElement(); + return scrollEl.scrollTop; + }); + expect(pageOneScrollTop).toBeLessThan(100); + }); + + test('should drop a stale fragment when navChanged fires for a different path', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + // Land on a URL with a fragment, then trigger a tab switch. The tab + // outlet emits `navChanged` for the new path; the fragment referred to + // an anchor on the previous page and must not survive the rewrite. + await page.goto(`/src/components/router/test/basic#/two/second-page#anchor`, config); + await expect(page.locator('page-two')).toBeVisible(); + + await page.click('#tab-button-tab-one'); + + await expect(page.locator('tab-one')).toBeVisible(); + await page.waitForFunction(() => !window.location.hash.includes('#anchor')); + expect(page.url()).not.toContain('#anchor'); + }); + + test('should cancel an in-flight fragment scroll when a newer navigation supersedes it', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19365', + }); + // Two rapid pushes: the first targets a fragment (begins polling + + // smooth scroll), the second arrives before the first lands and clears + // the fragment. The cancellation token must abort the first scroll so + // we end up at the top of the page, not parked at #anchor. + await page.goto(`/src/components/router/test/basic#/two`, config); + await expect(page.locator('page-one')).toBeVisible(); + + await page.evaluate(async () => { + const router = document.querySelector('ion-router') as HTMLIonRouterElement; + router.push('/two/second-page#anchor'); + await router.push('/two/second-page'); + }); + + await expect(page.locator('page-two')).toBeVisible(); + // Wait for page-two's scrollTop to stabilise across two consecutive + // frames. A scroll triggered by the un-cancelled first push would + // still be animating when the assertion runs. + await page.waitForFunction(async () => { + const content = document.querySelector('page-two ion-content') as HTMLIonContentElement | null; + if (!content) return false; + const scrollEl = await content.getScrollElement(); + const first = scrollEl.scrollTop; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return scrollEl.scrollTop === first; + }); + + const pageTwoScrollTop = await page.evaluate(async () => { + const content = document.querySelector('page-two ion-content') as HTMLIonContentElement | null; + if (!content) return -1; + const scrollEl = await content.getScrollElement(); + return scrollEl.scrollTop; + }); + expect(pageTwoScrollTop).toBeLessThan(100); + expect(page.url()).not.toContain('#anchor'); + }); }); test.describe(title('router: tabs'), () => { diff --git a/core/src/components/router/test/dom.spec.tsx b/core/src/components/router/test/dom.spec.tsx new file mode 100644 index 00000000000..c8b47b485e7 --- /dev/null +++ b/core/src/components/router/test/dom.spec.tsx @@ -0,0 +1,99 @@ +import { scrollToFragment } from '../utils/dom'; + +describe('scrollToFragment', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+

target

+
+ `; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should return false for null/undefined/empty fragment', async () => { + expect(await scrollToFragment(undefined)).toBe(false); + expect(await scrollToFragment('')).toBe(false); + }); + + it('should find and scroll to an existing target', async () => { + // No ion-content here, so the function falls back to scrollIntoView. We + // assert the truthy return (target found, scroll attempted) rather than + // jsdom-specific scroll state. + expect(await scrollToFragment('target')).toBe(true); + }); + + it('should not match similarly named fragments', async () => { + // The lookup uses an exact `#id` selector, so partial/substring matches + // must not resolve. Regression guard for any future refactor that swaps + // the selector for fuzzy matching. + expect(await scrollToFragment('target2')).toBe(false); + expect(await scrollToFragment('targe')).toBe(false); + }); + + it('should return false when shouldContinue is false from the start', async () => { + // The target exists in the DOM, so the only reason this can return false + // is the cancellation predicate firing inside findFragmentTarget. + expect(await scrollToFragment('target', () => false)).toBe(false); + }); + + it('should respect shouldContinue mid-poll when the target is missing', async () => { + // Target is not in the DOM; findFragmentTarget enters its poll loop. + // shouldContinue flips false on the third call, which must abort the loop. + let calls = 0; + const result = await scrollToFragment('missing', () => ++calls <= 2); + expect(result).toBe(false); + expect(calls).toBeGreaterThan(2); + }); + + it('should not call shouldContinue after returning early on null fragment', async () => { + let calls = 0; + await scrollToFragment(undefined, () => { + calls++; + return true; + }); + expect(calls).toBe(0); + }); + + it('should reject a target whose ancestor is .ion-page-hidden', async () => { + // Back-stack pages stay in the DOM with `.ion-page-hidden`. A target + // matched only on a hidden page must not win. + document.body.innerHTML = ` +
+

stale

+
+ `; + expect(await scrollToFragment('stale')).toBe(false); + }); + + it('should reject a target whose ancestor is .tab-hidden', async () => { + // Inactive `ion-tab` elements carry `.ion-page` but use `.tab-hidden` + // (not `.ion-page-hidden`). A target inside an inactive tab must not win; + // a naive "last `.ion-page:not(.ion-page-hidden)`" pick would otherwise + // land on it. + document.body.innerHTML = ` +
+

stale

+
+ `; + expect(await scrollToFragment('in-inactive-tab')).toBe(false); + }); + + it('should accept a target inside a sibling non-hidden ion-page', async () => { + // During transitions multiple `.ion-page` elements may briefly coexist + // without `.ion-page-hidden`. A target inside any non-hidden page must + // still resolve. + document.body.innerHTML = ` +
+

in hidden page

+
+
+

other

+
+ `; + // Lookup of 'other' must succeed even though a hidden sibling page exists. + expect(await scrollToFragment('other')).toBe(true); + }); +}); diff --git a/core/src/components/router/test/path.spec.tsx b/core/src/components/router/test/path.spec.tsx index 48e366aebca..e8e398d9389 100644 --- a/core/src/components/router/test/path.spec.tsx +++ b/core/src/components/router/test/path.spec.tsx @@ -48,6 +48,47 @@ describe('parsePath', () => { expect(parsePath('path/to/file.js?').queryString).toEqual(''); expect(parsePath('path/to/file.js?a=b').queryString).toEqual('a=b'); }); + + it('should strip the fragment from segments and return it', () => { + const result = parsePath('/catalog#pens'); + expect(result.segments).toEqual(['catalog']); + expect(result.fragment).toEqual('pens'); + }); + + it('should parse fragment alongside query string (query first)', () => { + const result = parsePath('/catalog?x=1#pens'); + expect(result.segments).toEqual(['catalog']); + expect(result.queryString).toEqual('x=1'); + expect(result.fragment).toEqual('pens'); + }); + + it('should treat "?" inside fragment as part of the fragment', () => { + // Per RFC 3986 the fragment starts at the first "#" and runs to the end. + const result = parsePath('/catalog#pens?x=1'); + expect(result.segments).toEqual(['catalog']); + expect(result.queryString).toBeUndefined(); + expect(result.fragment).toEqual('pens?x=1'); + }); + + it('should parse fragment-only path', () => { + const result = parsePath('#pens'); + expect(result.segments).toEqual(['']); + expect(result.fragment).toEqual('pens'); + }); + + it('should leave fragment undefined when there is no "#"', () => { + expect(parsePath('/catalog').fragment).toBeUndefined(); + expect(parsePath('/catalog?x=1').fragment).toBeUndefined(); + expect(parsePath(null).fragment).toBeUndefined(); + expect(parsePath(undefined).fragment).toBeUndefined(); + }); + + it('should preserve percent-encoded characters in the fragment', () => { + // parsePath keeps the fragment in its URL-encoded form; decoding for id + // matching is the consumer's responsibility (see `scrollToFragment`). + expect(parsePath('/catalog#sec%20one').fragment).toEqual('sec%20one'); + expect(parsePath('/catalog#%E4%B8%AD%E6%96%87').fragment).toEqual('%E4%B8%AD%E6%96%87'); + }); }); describe('generatePath', () => { @@ -243,6 +284,31 @@ describe('writeSegments', () => { writeSegments(history, '/path/to/', true, ['second', 'page'], ROUTER_INTENT_FORWARD, 123, 'flag=true'); expect(history.pushState).toHaveBeenCalledWith(123, '', '#/path/to/second/page?flag=true'); }); + + it('should append the fragment after the query string (no hash)', () => { + const history = mockHistory(); + writeSegments(history, '/', false, ['catalog'], ROUTER_INTENT_FORWARD, 1, 'x=1', 'pens'); + expect(history.pushState).toHaveBeenCalledWith(1, '', '/catalog?x=1#pens'); + }); + + it('should append the fragment when there is no query string (no hash)', () => { + const history = mockHistory(); + writeSegments(history, '/', false, ['catalog'], ROUTER_INTENT_FORWARD, 1, undefined, 'pens'); + expect(history.pushState).toHaveBeenCalledWith(1, '', '/catalog#pens'); + }); + + it('should append the fragment in hash routing mode', () => { + // In hash routing the routing "#" wraps the path; the URL fragment is a second "#" appended at the end. + const history = mockHistory(); + writeSegments(history, '/', true, ['catalog'], ROUTER_INTENT_FORWARD, 1, undefined, 'pens'); + expect(history.pushState).toHaveBeenCalledWith(1, '', '#/catalog#pens'); + }); + + it('should omit the fragment when none is provided', () => { + const history = mockHistory(); + writeSegments(history, '/', false, ['catalog'], ROUTER_INTENT_FORWARD, 1); + expect(history.pushState).toHaveBeenCalledWith(1, '', '/catalog'); + }); }); function mockHistory(): History { diff --git a/core/src/components/router/utils/dom.ts b/core/src/components/router/utils/dom.ts index b71020fcd15..cb2ec83f2dc 100644 --- a/core/src/components/router/utils/dom.ts +++ b/core/src/components/router/utils/dom.ts @@ -1,4 +1,5 @@ -import { waitForComponentReady } from '@utils/helpers'; +import { findClosestIonContent, getScrollElement, isIonContent } from '@utils/content'; +import { raf, waitForComponentReady } from '@utils/helpers'; import { printIonError } from '@utils/logging'; import type { AnimationBuilder } from '../../../interface'; @@ -81,6 +82,126 @@ export const readNavState = async (root: HTMLElement | undefined) => { return { ids, outlet }; }; +/** Max animation frames `scrollToFragment` polls while waiting for the target to mount. */ +const FRAGMENT_POLL_FRAMES = 30; + +/** Duration (ms) of the smooth-scroll animation that lands on the fragment target. */ +const FRAGMENT_SCROLL_DURATION = 300; + +const nextFrame = () => new Promise((resolve) => raf(() => resolve())); + +/** + * Returns true when `el` lives inside an active `.ion-page`. `ion-page-hidden` + * marks nav back-stack entries; `tab-hidden` marks inactive `ion-tab` elements. + * Either class on the page's ancestor chain disqualifies it. When no `.ion-page` + * exists in the document at all (non-router pages), the candidate is accepted + * so plain anchors still work. + */ +const isInActivePage = (el: HTMLElement): boolean => { + const page = el.closest('.ion-page'); + if (page === null) { + return document.querySelector('.ion-page') === null; + } + return page.closest('.ion-page-hidden, .tab-hidden') === null; +}; + +/** + * Polls across animation frames for an element matching `fragment` that lives + * in the active page. Scoping by "last `.ion-page:not(.ion-page-hidden)`" is + * unreliable: inactive `ion-tab` siblings carry `.ion-page` (gated by + * `.tab-hidden`, not `.ion-page-hidden`) and can be ordered after the leaf. + * Instead, locate candidates globally and walk them from last to first, + * accepting the deepest one whose `.ion-page` ancestor is not hidden. The + * last-to-first order preserves leaf-most preference for nested outlets. + */ +const findFragmentTarget = async (fragment: string, shouldContinue: () => boolean): Promise => { + // CSS.escape is unavailable on very old WebViews; the fallback path uses + // `getElementById` and drops the legacy `` branch. + const canEscape = typeof CSS !== 'undefined' && typeof CSS.escape === 'function'; + const escaped = canEscape ? CSS.escape(fragment) : null; + + for (let i = 0; i < FRAGMENT_POLL_FRAMES; i++) { + if (!shouldContinue()) return null; + + let candidates: HTMLElement[] = []; + if (escaped !== null) { + try { + candidates = [...document.querySelectorAll(`#${escaped}, a[name="${escaped}"]`)]; + } catch { + candidates = [...document.querySelectorAll(`#${escaped}`)]; + } + } else { + const byId = document.getElementById(fragment); + if (byId !== null) candidates = [byId]; + } + + for (let j = candidates.length - 1; j >= 0; j--) { + if (isInActivePage(candidates[j])) { + return candidates[j]; + } + } + await nextFrame(); + } + + return null; +}; + +/** + * Scrolls to the element whose id matches `fragment`, falling back to a legacy + * `` target. When the target lives inside an `ion-content`, the + * scroll uses its smooth-animated scroll API; otherwise it falls back to + * `Element.scrollIntoView`. + * + * `shouldContinue` lets callers cancel in-flight scrolls when a newer + * navigation supersedes this one. It is checked between async steps. + */ +export const scrollToFragment = async ( + fragment: string | undefined, + shouldContinue: () => boolean = () => true +): Promise => { + if (fragment == null || fragment === '') { + return false; + } + + // URL fragments are percent-encoded but element ids are not; decode for + // matching per the HTML spec's indicated-element resolution. + let decoded: string; + try { + decoded = decodeURIComponent(fragment); + } catch { + decoded = fragment; + } + + const target = await findFragmentTarget(decoded, shouldContinue); + if (!target || !shouldContinue()) { + return false; + } + + // Best-effort scroll: swallow exceptions if the page tears down mid-animation. + try { + const contentHost = findClosestIonContent(target); + if (contentHost && isIonContent(contentHost)) { + const content = contentHost as HTMLIonContentElement; + const scrollEl = await getScrollElement(content); + // Yield one frame so the newly mounted target's layout is stable + // before we measure its rect. + await nextFrame(); + if (!shouldContinue()) return false; + const targetRect = target.getBoundingClientRect(); + const scrollRect = scrollEl.getBoundingClientRect(); + const top = targetRect.top - scrollRect.top + scrollEl.scrollTop; + // Preserve scrollLeft so RTL and horizontally-scrolling pages aren't reset. + await content.scrollToPoint(scrollEl.scrollLeft, top, FRAGMENT_SCROLL_DURATION); + } else { + target.scrollIntoView({ behavior: 'smooth' }); + } + return true; + } catch (e) { + printIonError('[ion-router] - Exception in scrollToFragment:', e); + return false; + } +}; + export const waitUntilNavNode = (): Promise => { if (searchNavNode(document.body)) { return Promise.resolve(); diff --git a/core/src/components/router/utils/interface.ts b/core/src/components/router/utils/interface.ts index caeb6e57c08..c0457da9680 100644 --- a/core/src/components/router/utils/interface.ts +++ b/core/src/components/router/utils/interface.ts @@ -57,6 +57,8 @@ export interface ParsedRoute { segments: string[]; /** Unparsed query string. */ queryString?: string; + /** URL fragment (the part after `#`), without the leading `#`. */ + fragment?: string; } export type RouterDirection = 'forward' | 'back' | 'root'; diff --git a/core/src/components/router/utils/path.ts b/core/src/components/router/utils/path.ts index 4305d84e986..dad058c56b0 100644 --- a/core/src/components/router/utils/path.ts +++ b/core/src/components/router/utils/path.ts @@ -8,7 +8,7 @@ export const generatePath = (segments: string[]): string => { return '/' + path; }; -const generateUrl = (segments: string[], useHash: boolean, queryString?: string) => { +const generateUrl = (segments: string[], useHash: boolean, queryString?: string, fragment?: string) => { let url = generatePath(segments); if (useHash) { url = '#' + url; @@ -16,6 +16,9 @@ const generateUrl = (segments: string[], useHash: boolean, queryString?: string) if (queryString !== undefined) { url += '?' + queryString; } + if (fragment !== undefined) { + url += '#' + fragment; + } return url; }; @@ -26,9 +29,10 @@ export const writeSegments = ( segments: string[], direction: RouterDirection, state: number, - queryString?: string + queryString?: string, + fragment?: string ) => { - const url = generateUrl([...parsePath(root).segments, ...segments], useHash, queryString); + const url = generateUrl([...parsePath(root).segments, ...segments], useHash, queryString, fragment); if (direction === ROUTER_INTENT_FORWARD) { history.pushState(state, '', url); } else { @@ -97,13 +101,23 @@ export const readSegments = (loc: Location, root: string, useHash: boolean): str /** * Parses the path to: * - segments an array of '/' separated parts, - * - queryString (undefined when no query string). + * - queryString (undefined when no query string), + * - fragment (undefined when no `#`). */ export const parsePath = (path: string | undefined | null): ParsedRoute => { let segments = ['']; let queryString; + let fragment; if (path != null) { + // The fragment ("#") starts a section that runs to the end of the URL. + // Anything inside it (including "?") is part of the fragment per RFC 3986. + const fragStart = path.indexOf('#'); + if (fragStart > -1) { + fragment = path.substring(fragStart + 1); + path = path.substring(0, fragStart); + } + const qsStart = path.indexOf('?'); if (qsStart > -1) { queryString = path.substring(qsStart + 1); @@ -120,5 +134,5 @@ export const parsePath = (path: string | undefined | null): ParsedRoute => { } } - return { segments, queryString }; + return { segments, queryString, fragment }; }; diff --git a/core/src/components/searchbar/searchbar.tsx b/core/src/components/searchbar/searchbar.tsx index 8e1606e91f5..67c4f2cebc2 100644 --- a/core/src/components/searchbar/searchbar.tsx +++ b/core/src/components/searchbar/searchbar.tsx @@ -37,6 +37,7 @@ export class Searchbar implements ComponentInterface { private inheritedAttributes: Attributes = {}; private loadTimeout: ReturnType | undefined; private clearTimeout: ReturnType | undefined; + private searchIconResizeObserver?: ResizeObserver; /** * The value of the input when the textarea is focused. @@ -325,6 +326,7 @@ export class Searchbar implements ComponentInterface { if (this.clearTimeout) { clearTimeout(this.clearTimeout); } + this.searchIconResizeObserver?.disconnect(); } private emitStyle() { @@ -562,7 +564,26 @@ export class Searchbar implements ComponentInterface { * such as Dynamic Type on iOS as well as 8px * of padding. */ - const iconLeft = 'calc(50% - ' + (textWidth / 2 + iconEl.clientWidth + 8) + 'px)'; + const iconWidth = iconEl.clientWidth; + + /** + * Fix for https://github.com/ionic-team/ionic-framework/issues/30434: + * iconEl.clientWidth can very briefly be 0 when this is called from componentDidLoad. + * If it is zero, set up a ResizeObserver and call this function when it observes a width greater than 0. + */ + if (iconWidth === 0) { + this.searchIconResizeObserver?.disconnect(); + this.searchIconResizeObserver = new ResizeObserver((entries) => { + if (entries[0].contentRect.width > 0) { + this.searchIconResizeObserver?.disconnect(); + this.positionPlaceholder(); + } + }); + this.searchIconResizeObserver?.observe(iconEl); + return; + } + + const iconLeft = 'calc(50% - ' + (textWidth / 2 + iconWidth + 8) + 'px)'; // Set the input padding start and icon margin start if (rtl) { diff --git a/core/src/components/searchbar/test/basic/searchbar.e2e.ts-snapshots/searchbar-text-clear-hidden-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/searchbar/test/basic/searchbar.e2e.ts-snapshots/searchbar-text-clear-hidden-ionic-md-ltr-light-Mobile-Firefox-linux.png index f24d0824055..0ae8a8b0984 100644 Binary files a/core/src/components/searchbar/test/basic/searchbar.e2e.ts-snapshots/searchbar-text-clear-hidden-ionic-md-ltr-light-Mobile-Firefox-linux.png and b/core/src/components/searchbar/test/basic/searchbar.e2e.ts-snapshots/searchbar-text-clear-hidden-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/searchbar/test/basic/searchbar.e2e.ts-snapshots/searchbar-text-clear-visible-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/searchbar/test/basic/searchbar.e2e.ts-snapshots/searchbar-text-clear-visible-ionic-md-ltr-light-Mobile-Firefox-linux.png index bd2c8d5ef20..320f27a8186 100644 Binary files a/core/src/components/searchbar/test/basic/searchbar.e2e.ts-snapshots/searchbar-text-clear-visible-ionic-md-ltr-light-Mobile-Firefox-linux.png and b/core/src/components/searchbar/test/basic/searchbar.e2e.ts-snapshots/searchbar-text-clear-visible-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-modal/select-modal.common.scss b/core/src/components/select-modal/select-modal.common.scss index 3bbb48b557d..1cd115ba602 100644 --- a/core/src/components/select-modal/select-modal.common.scss +++ b/core/src/components/select-modal/select-modal.common.scss @@ -1,19 +1,8 @@ +@use "../select-option/select-option.common.overlay"; + // Select Modal // -------------------------------------------------- :host { height: 100%; } - -// Select Modal: Select Option -// -------------------------------------------------- - -.select-option-label { - display: flex; - - align-items: center; -} - -.select-option-description { - display: block; -} diff --git a/core/src/components/select-modal/select-modal.ionic.scss b/core/src/components/select-modal/select-modal.ionic.scss index e3124b5f08f..e000497ff08 100644 --- a/core/src/components/select-modal/select-modal.ionic.scss +++ b/core/src/components/select-modal/select-modal.ionic.scss @@ -1,4 +1,5 @@ @use "../../themes/ionic/ionic.globals.scss" as globals; +@use "../select-option/select-option.ionic.overlay"; @use "./select-modal.common"; // Ionic Select Modal @@ -12,12 +13,14 @@ // ---------------------------------------------------------------- ion-item { - --border-width: 0; -} - -ion-item.ion-focused::part(native)::after { - // Your styles for the ::after pseudo element when ion-item is focused - border: none; + --border-width: #{globals.$ion-border-size-0}; + --background-focused: transparent; + --background-focused-opacity: 1; + --background-hover: #{globals.$ion-bg-neutral-subtlest-press}; + --background-hover-opacity: 1; + --background-activated: #{globals.$ion-bg-neutral-subtlest-press}; + --background-activated-opacity: 1; + --border-radius: #{globals.$ion-border-radius-400}; } // Toolbar @@ -35,7 +38,7 @@ ion-list ion-radio::part(container) { } ion-list ion-radio::part(label) { - @include globals.margin(0); + @include globals.margin(globals.$ion-space-0); } // Radio and Checkbox: Label @@ -51,8 +54,7 @@ ion-list ion-checkbox::part(label) { .item-radio-checked, .item-checkbox-checked { - --background: #{globals.$ion-semantics-primary-100}; - --border-radius: #{globals.$ion-border-radius-400}; + --background: #{globals.$ion-bg-primary-subtle-default}; } // Content diff --git a/core/src/components/select-modal/select-modal.ios.scss b/core/src/components/select-modal/select-modal.ios.scss index abac9c8220b..07becc9d2e3 100644 --- a/core/src/components/select-modal/select-modal.ios.scss +++ b/core/src/components/select-modal/select-modal.ios.scss @@ -1,3 +1,4 @@ +@use "../select-option/select-option.ios.overlay"; @import "./select-modal.native"; @import "../item/item.ios.vars"; @import "../radio/radio.ios.vars"; diff --git a/core/src/components/select-modal/select-modal.md.scss b/core/src/components/select-modal/select-modal.md.scss index 260f6aba5be..1cb08cca77c 100644 --- a/core/src/components/select-modal/select-modal.md.scss +++ b/core/src/components/select-modal/select-modal.md.scss @@ -1,3 +1,4 @@ +@use "../select-option/select-option.md.overlay"; @import "./select-modal.native"; @import "../../themes/mixins.scss"; @import "../item/item.md.vars"; diff --git a/core/src/components/select-modal/select-modal.native.scss b/core/src/components/select-modal/select-modal.native.scss index 29b81819fcf..1c030fcfc38 100644 --- a/core/src/components/select-modal/select-modal.native.scss +++ b/core/src/components/select-modal/select-modal.native.scss @@ -1,19 +1,8 @@ @use "../../themes/native/native.theme.default" as native; @use "../../themes/mixins" as mixins; @use "../../themes/functions.font" as font; +@use "../select-option/select-option.native.overlay"; @use "./select-modal.common"; // Select Modal: Native // -------------------------------------------------- - -.select-option-label { - gap: 12px; -} - -.select-option-description { - @include mixins.padding(5px, 0, 0, 0); - - color: native.$text-color-step-300; - - font-size: font.dynamic-font(12px); -} diff --git a/core/src/components/select-modal/select-modal.tsx b/core/src/components/select-modal/select-modal.tsx index ca21e73a1f0..baafc792e83 100644 --- a/core/src/components/select-modal/select-modal.tsx +++ b/core/src/components/select-modal/select-modal.tsx @@ -2,6 +2,7 @@ import { getIonMode, getIonTheme } from '@global/ionic-global'; import xRegular from '@phosphor-icons/core/assets/regular/x.svg'; import type { ComponentInterface } from '@stencil/core'; import { Component, Element, Host, Prop, forceUpdate, h } from '@stencil/core'; +import { getOverlayLabelJustify, getOverlayLabelPlacement } from '@utils/overlay-control-label'; import { safeCall } from '@utils/overlays'; import { renderOptionLabel } from '@utils/select-option-render'; import { getClassMap, hostContext } from '@utils/theme'; @@ -115,6 +116,7 @@ export class SelectModal implements ComponentInterface { } private renderRadioOptions() { + const theme = getIonTheme(this); const checked = this.options.filter((o) => o.checked).map((o) => o.value)[0]; return ( @@ -127,6 +129,7 @@ export class SelectModal implements ComponentInterface { * part of the public `SelectModalOption` interface. */ const richOption = option as SelectOverlayOption; + const hasRichContent = !!richOption.startContent || !!richOption.endContent || !!richOption.description; const optionLabelOptions = { id: `modal-option-${index}`, label: richOption.text, @@ -134,10 +137,15 @@ export class SelectModal implements ComponentInterface { endContent: richOption.endContent, description: richOption.description, }; + const defaultLabelPlacement = getOverlayLabelPlacement(theme, 'radio', 'modal'); + const defaultJustify = getOverlayLabelJustify(theme, 'radio', 'modal'); return ( this.closeModal()} onKeyDown={(ev) => { if (ev.key === 'Enter' && !ev.repeat) { @@ -178,6 +189,7 @@ export class SelectModal implements ComponentInterface { } private renderCheckboxOptions() { + const theme = getIonTheme(this); return this.options.map((option, index) => { /** * Cast to `SelectOverlayOption` to access rich content @@ -186,6 +198,7 @@ export class SelectModal implements ComponentInterface { * part of the public `SelectModalOption` interface. */ const richOption = option as SelectOverlayOption; + const hasRichContent = !!richOption.startContent || !!richOption.endContent || !!richOption.description; const optionLabelOptions = { id: `modal-option-${index}`, label: richOption.text, @@ -193,9 +206,14 @@ export class SelectModal implements ComponentInterface { endContent: richOption.endContent, description: richOption.description, }; + const defaultLabelPlacement = getOverlayLabelPlacement(theme, 'checkbox', 'modal'); + const defaultJustify = getOverlayLabelJustify(theme, 'checkbox', 'modal'); return ( { this.setChecked(ev); this.callOptionHandler(ev); @@ -235,7 +256,11 @@ export class SelectModal implements ComponentInterface { {this.header !== undefined && {this.header}} - this.closeModal()}> + this.closeModal()} + > {this.cancelIcon ? ( ) : ( diff --git a/core/src/components/select-modal/test/basic/index.html b/core/src/components/select-modal/test/basic/index.html index 3e0ac7eec55..52e13a10462 100644 --- a/core/src/components/select-modal/test/basic/index.html +++ b/core/src/components/select-modal/test/basic/index.html @@ -2,7 +2,7 @@ - Select - Modal + Select Modal - Basic - + Cancel Text (default) - + diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png index 743aa408579..232b34daf60 100644 Binary files a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png index c47f13cde4f..0422fbc34ed 100644 Binary files a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png index 9627de08483..c0e99c6bb5e 100644 Binary files a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ionic-md-ltr-light-Mobile-Safari-linux.png index 3e30a54b205..4172d823b97 100644 Binary files a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ionic-md-ltr-light-Mobile-Safari-linux.png and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-modal/test/custom/select-modal.e2e.ts-snapshots/select-modal-cancel-icon-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select-modal/test/custom/select-modal.e2e.ts-snapshots/select-modal-cancel-icon-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png index eafac1f4249..d355a57b2fc 100644 Binary files a/core/src/components/select-modal/test/custom/select-modal.e2e.ts-snapshots/select-modal-cancel-icon-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png and b/core/src/components/select-modal/test/custom/select-modal.e2e.ts-snapshots/select-modal-cancel-icon-diff-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-modal/test/states/index.html b/core/src/components/select-modal/test/states/index.html new file mode 100644 index 00000000000..5bd4553a7c9 --- /dev/null +++ b/core/src/components/select-modal/test/states/index.html @@ -0,0 +1,106 @@ + + + + + Select Modal - States + + + + + + + + + + + + + Select Modal - States + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts b/core/src/components/select-modal/test/states/select-modal.e2e.ts new file mode 100644 index 00000000000..6a32f78172b --- /dev/null +++ b/core/src/components/select-modal/test/states/select-modal.e2e.ts @@ -0,0 +1,80 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * This behavior does not vary across directions. + */ +configs({ directions: ['ltr'], modes: ['ios', 'md', 'ionic-md'] }).forEach(({ config, screenshot, title }) => { + test.describe(title('select-modal: states'), () => { + /** + * `(any-hover: hover)` evaluates to "none" in all three mobile-emulated + * projects, suppressing the hover rules: + * + * - Chromium and WebKit suppress it because of hasTouch and isMobile. + * - Headless Firefox doesn't detect input devices and reports no hover + * capability regardless of context options, so override it via + * launchOptions.firefoxUserPrefs. Bit values: 4 = FINE (mouse), + * 8 = HOVER, 12 = FINE | HOVER. + * + * Viewport, userAgent, and scale factor remain mobile-sized. + */ + test.use({ + hasTouch: false, + isMobile: false, + }); + + test.beforeEach(async ({ page }) => { + await page.goto(`/src/components/select-modal/test/states`, config); + }); + + test('should render all radio states', async ({ page }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + await page.locator('#single').click(); + await ionModalDidPresent.next(); + + const modal = page.locator('#modal-single'); + const selectModal = modal.locator('ion-select-modal'); + await expect(selectModal).toBeVisible(); + + /** + * After clicking the trigger button, the cursor sits at the + * button's screen coordinates — which may coincide with the + * "Default" row once the modal opens, depending on mode/viewport. + * Without a transition, `mouseenter` doesn't fire and the JS-driven + * label swap never runs, causing inconsistent hover states in the + * screenshots. + */ + await page.mouse.move(0, 0); + + const defaultRow = selectModal.locator('ion-item').first(); + await defaultRow.hover(); + + await expect(selectModal).toHaveScreenshot(screenshot('select-modal-radio-states')); + }); + + test('should render all checkbox states', async ({ page }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + await page.locator('#multiple').click(); + await ionModalDidPresent.next(); + + const modal = page.locator('#modal-multiple'); + const selectModal = modal.locator('ion-select-modal'); + await expect(selectModal).toBeVisible(); + + /** + * After clicking the trigger button, the cursor sits at the + * button's screen coordinates — which may coincide with the + * "Default" row once the modal opens, depending on mode/viewport. + * Without a transition, `mouseenter` doesn't fire and the JS-driven + * label swap never runs, causing inconsistent hover states in the + * screenshots. + */ + await page.mouse.move(0, 0); + + const defaultRow = selectModal.locator('ion-item').first(); + await defaultRow.hover(); + + await expect(selectModal).toHaveScreenshot(screenshot('select-modal-checkbox-states')); + }); + }); +}); diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..3787c9e7b68 Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..1e6b6d973e7 Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..701a847907a Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..fd1716eb2db Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..1a4744a1dc9 Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..7bbc80f2147 Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..e071c2f6b99 Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..4dd93626894 Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-md-ltr-Mobile-Safari-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..0fec86f8133 Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-checkbox-states-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..26762bd525f Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c30b69efea4 Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..a766e706cfa Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..5905048cdd4 Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..9eec3e12894 Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..53fb60527bb Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..f68735e8dd6 Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..5e135f73220 Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-md-ltr-Mobile-Safari-linux.png b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..61a79657dd1 Binary files /dev/null and b/core/src/components/select-modal/test/states/select-modal.e2e.ts-snapshots/select-modal-radio-states-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/select-option.common.overlay.scss b/core/src/components/select-option/select-option.common.overlay.scss new file mode 100644 index 00000000000..166da5884b9 --- /dev/null +++ b/core/src/components/select-option/select-option.common.overlay.scss @@ -0,0 +1,83 @@ +// Select Option - Overlay +// -------------------------------------------------- + +// Outer label container, which also spaces the start +// and end slots when a select option has rich content +.action-sheet-button-label-has-rich-content, +.alert-radio-label-has-rich-content, +.alert-checkbox-label-has-rich-content, +.select-option-label-has-rich-content { + display: flex; + + align-items: center; +} + +/** + * Outer label container has rich content + * (start, content, description, end) that needs the + * label to span the available row width. + */ +.action-sheet-button-label-has-rich-content, +.alert-radio-label-has-rich-content, +.alert-checkbox-label-has-rich-content, +.select-option-content { + flex: 1; +} + +// Inner label container of a select option when +// there is rich content within the default slot +.action-sheet-button-label-text, +.alert-checkbox-label-text, +.alert-radio-label-text, +.select-option-label-text { + display: flex; + + align-items: center; +} + +// Start and end slots +.select-option-start, +.select-option-end { + display: flex; + + align-items: center; +} + +.select-option-description { + display: block; +} + +// Select Option: Select Modal / Select Popover +// -------------------------------------------------- + +/** + * Non-rich labels are plain text and should ellipsize when they + * overflow the row. Rich-content labels switch to flex so the + * start / content / end pieces can lay out side-by-side and wrap. + */ +.select-option-label:not(.select-option-label-has-rich-content) { + text-overflow: ellipsis; + + white-space: nowrap; + + overflow: hidden; +} + +.select-option-label-has-rich-content { + display: flex; + + align-items: center; +} + +ion-radio.select-option-has-rich-content::part(label), +ion-checkbox.select-option-has-rich-content::part(label), +.select-option-content { + flex: 1; + + /** + * Let rich content wrap instead of inheriting the label part's + * single-line truncation, so arbitrary slotted elements render + * without clipping. + */ + white-space: normal; +} diff --git a/core/src/components/select-option/select-option.ionic.overlay.scss b/core/src/components/select-option/select-option.ionic.overlay.scss new file mode 100644 index 00000000000..746ec00594e --- /dev/null +++ b/core/src/components/select-option/select-option.ionic.overlay.scss @@ -0,0 +1,60 @@ +@use "../../themes/ionic/ionic.globals.scss" as globals; + +// Select Option - Interface +// -------------------------------------------------- + +// Outer label container, which also spaces the start +// and end slots when a select option has rich content +.action-sheet-button-label-has-rich-content, +.alert-radio-label-has-rich-content, +.alert-checkbox-label-has-rich-content, +.select-option-label-has-rich-content { + gap: globals.$ion-space-300; +} + +// Inner label container of a select option when +// there is rich content within the default slot +.action-sheet-button-label-text, +.alert-checkbox-label-text, +.alert-radio-label-text, +.select-option-label-text { + gap: globals.$ion-space-300; +} + +.select-option-description { + @include globals.typography(globals.$ion-body-md-regular); + @include globals.padding(globals.$ion-space-0); + + color: globals.$ion-text-subtle; + + font-size: globals.$ion-font-size-350; +} + +// Start and end slots +.select-option-start, +.select-option-end { + gap: globals.$ion-space-200; +} + +/** + * Cap slotted children so they can't stretch the option + * row out of proportion, keeping rows visually uniform + * regardless of the content. + */ + +// Avatar / Image / SVG / Thumbnail / Icon +.select-option-start > ion-avatar, +.select-option-start > ion-img, +.select-option-start > ion-thumbnail, +.select-option-start > ion-icon, +.select-option-start > img, +.select-option-start > svg, +.select-option-end > ion-avatar, +.select-option-end > ion-img, +.select-option-end > ion-thumbnail, +.select-option-end > ion-icon, +.select-option-end > img, +.select-option-end > svg { + width: globals.$ion-scale-1200; + height: globals.$ion-scale-1200; +} diff --git a/core/src/components/select-option/select-option.ios.overlay.scss b/core/src/components/select-option/select-option.ios.overlay.scss new file mode 100644 index 00000000000..da95dd05fc3 --- /dev/null +++ b/core/src/components/select-option/select-option.ios.overlay.scss @@ -0,0 +1,47 @@ +@use "./select-option.native.overlay"; +@use "../../themes/mixins" as mixins; +@use "../item/item.ios.vars" as item; + +// Select Option - Interface +// -------------------------------------------------- + +/** + * Cap slotted children so they can't stretch the option + * row out of proportion, keeping rows visually uniform + * regardless of the content. + */ + +// Avatar / Image / SVG / Thumbnail +.select-option-start > ion-avatar, +.select-option-start > ion-img, +.select-option-start > ion-thumbnail, +.select-option-start > img, +.select-option-start > svg, +.select-option-end > ion-avatar, +.select-option-end > ion-img, +.select-option-end > ion-thumbnail, +.select-option-end > img, +.select-option-end > svg { + width: 44px; + height: 44px; +} + +// Icon +.select-option-start > ion-icon, +.select-option-end > ion-icon { + width: 28px; + height: 28px; +} + +// Select Option: Action Sheet +// -------------------------------------------------- +.action-sheet-button-label-text { + justify-content: center; +} + +// Select Option: Select Modal +// -------------------------------------------------- + +.select-option-has-rich-content { + @include mixins.padding-horizontal(null, item.$item-ios-padding-end); +} diff --git a/core/src/components/select-option/select-option.md.overlay.scss b/core/src/components/select-option/select-option.md.overlay.scss new file mode 100644 index 00000000000..ba72e243f39 --- /dev/null +++ b/core/src/components/select-option/select-option.md.overlay.scss @@ -0,0 +1,44 @@ +@use "./select-option.native.overlay"; + +// Select Option - Interface +// -------------------------------------------------- + +/** + * Cap slotted children so they can't stretch the option + * row out of proportion, keeping rows visually uniform + * regardless of the content. + */ + +// Avatar +.select-option-start > ion-avatar, +.select-option-end > ion-avatar { + width: 40px; + height: 40px; +} + +// Icon +.select-option-start > ion-icon, +.select-option-end > ion-icon { + width: 24px; + height: 24px; +} + +// Image / SVG / Thumbnail +.select-option-start > ion-img, +.select-option-start > img, +.select-option-start > svg, +.select-option-start > ion-thumbnail, +.select-option-end > ion-img, +.select-option-end > img, +.select-option-end > svg, +.select-option-end > ion-thumbnail { + width: 56px; + height: 56px; +} + +// Video +.select-option-start > video, +.select-option-end > video { + width: 114px; + height: 56px; +} diff --git a/core/src/components/select-option/select-option.native.overlay.scss b/core/src/components/select-option/select-option.native.overlay.scss new file mode 100644 index 00000000000..e2311843644 --- /dev/null +++ b/core/src/components/select-option/select-option.native.overlay.scss @@ -0,0 +1,38 @@ +@use "../../themes/native/native.globals" as native; +@use "../../themes/mixins" as mixins; +@use "../../themes/functions.font" as font; + +// Select Option - Interface +// -------------------------------------------------- + +// Outer label container, which also spaces the start +// and end slots when a select option has rich content +.action-sheet-button-label-has-rich-content, +.alert-radio-label-has-rich-content, +.alert-checkbox-label-has-rich-content, +.select-option-label-has-rich-content { + gap: 16px; +} + +// Inner label container of a select option when +// there is rich content within the default slot +.action-sheet-button-label-text, +.alert-checkbox-label-text, +.alert-radio-label-text, +.select-option-label-text { + gap: 12px; +} + +// Start and end slots +.select-option-start, +.select-option-end { + gap: 8px; +} + +.select-option-description { + @include mixins.padding(5px, 0, 0, 0); + + color: native.$text-color-step-300; + + font-size: font.dynamic-font(12px); +} diff --git a/core/src/components/select-option/select-option.scss b/core/src/components/select-option/select-option.scss index cc36f78bfa0..b166e375c82 100644 --- a/core/src/components/select-option/select-option.scss +++ b/core/src/components/select-option/select-option.scss @@ -1,3 +1,6 @@ +// Select Option +// -------------------------------------------------- + :host { display: none; } diff --git a/core/src/components/select-option/select-option.tsx b/core/src/components/select-option/select-option.tsx index dba97e56c47..a00d98c3bce 100644 --- a/core/src/components/select-option/select-option.tsx +++ b/core/src/components/select-option/select-option.tsx @@ -36,6 +36,40 @@ export class SelectOption implements ComponentInterface { */ @Prop() description?: string; + /** + * Where the label is placed relative to the option's selection control + * (radio circle or checkbox box) when the option is rendered in an + * `alert`, `popover`, or `modal` interface. + * `"start"`: The label will appear to the left of the radio in LTR and to the right in RTL. + * `"end"`: The label will appear to the right of the radio in LTR and to the left in RTL. + * + * Applies to the `alert`, `popover`, and `modal` interfaces, but has no + * visible effect on radio options in `popover` or `modal` on the `md` + * and `ionic` themes (the radio control is hidden there). + * + * When unset, the interface picks a default based on theme and control + * type. + */ + @Prop() labelPlacement?: 'start' | 'end'; + + /** + * How to pack the label and the option's selection control within a line. + * `"start"`: The label and radio will appear on the left in LTR and + * on the right in RTL. + * `"end"`: The label and radio will appear on the right in LTR and + * on the left in RTL. + * `"space-between"`: The label and radio will appear on opposite + * ends of the line with space between the two elements. + * + * Applies to the `alert`, `popover`, and `modal` interfaces, but has no + * visible effect on radio options in `popover` or `modal` on the `md` + * and `ionic` themes (the radio control is hidden there). + * + * When unset, the interface picks a default based on theme and control + * type. + */ + @Prop() justify?: 'start' | 'end' | 'space-between'; + render() { const theme = getIonTheme(this); diff --git a/core/src/components/select-option/test/label-placement/index.html b/core/src/components/select-option/test/label-placement/index.html new file mode 100644 index 00000000000..fdd0d235b5b --- /dev/null +++ b/core/src/components/select-option/test/label-placement/index.html @@ -0,0 +1,68 @@ + + + + + Select Option - Label Placement + + + + + + + + + + + + + Select Option - Label Placement + + + + + + + + + + + diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts b/core/src/components/select-option/test/label-placement/select-option.e2e.ts new file mode 100644 index 00000000000..8bea9a13d07 --- /dev/null +++ b/core/src/components/select-option/test/label-placement/select-option.e2e.ts @@ -0,0 +1,79 @@ +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * iOS does not respect the viewport so styles must be updated instead. + */ +const ALERT_SIZE_OVERRIDES = ` + ion-alert { + --max-width: 560px !important; + --max-height: none !important; + } + ion-alert .alert-radio-group, + ion-alert .alert-checkbox-group { + max-height: none !important; + } +`; + +const INTERFACES = [ + { name: 'alert', presentEvent: 'ionAlertDidPresent', locator: 'ion-alert .alert-wrapper' }, + { name: 'popover', presentEvent: 'ionPopoverDidPresent', locator: 'ion-popover' }, + { name: 'modal', presentEvent: 'ionModalDidPresent', locator: 'ion-modal' }, +] as const; + +const JUSTIFY_VARIANTS = ['start', 'end', 'space-between'] as const; + +const LABEL_PLACEMENTS = ['start', 'end'] as const; + +const FIRST_OPTION_VALUE = `${JUSTIFY_VARIANTS[0]}-short`; + +const renderOptions = (labelPlacement: 'start' | 'end') => + JUSTIFY_VARIANTS.flatMap((justify) => { + const longLabel = `Justify ${justify} — ${'long label '.repeat(6).trim()}`; + return [ + `Justify ${justify}`, + `${longLabel}`, + ]; + }).join(''); + +const setContentForInterface = async ( + page: Page, + interfaceName: 'alert' | 'popover' | 'modal', + labelPlacement: 'start' | 'end', + config: object +) => { + await page.setContent( + ` + + ${renderOptions(labelPlacement)} + + `, + config + ); +}; + +configs({ modes: ['md', 'ios', 'ionic-md'] }).forEach(({ config, screenshot, title }) => { + test.describe(title('select-option: label placement'), () => { + for (const { name, presentEvent, locator } of INTERFACES) { + test.describe(`${name} interface`, () => { + for (const placement of LABEL_PLACEMENTS) { + test(`placement ${placement}`, async ({ page }) => { + await setContentForInterface(page, name, placement, config); + + if (name === 'alert') { + await page.addStyleTag({ content: ALERT_SIZE_OVERRIDES }); + } + + const didPresent = await page.spyOnEvent(presentEvent); + await page.locator('#select').click(); + await didPresent.next(); + + const overlay = page.locator(locator); + await expect(overlay).toHaveScreenshot(screenshot(`select-option-label-${name}-${placement}`)); + }); + } + }); + } + }); +}); diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..d07357be7ed Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..b69f2dad1cd Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..f68390f273d Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-rtl-light-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-rtl-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..ebbd5fbba85 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-rtl-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-rtl-light-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-rtl-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..0ab39b75963 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-rtl-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-rtl-light-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-rtl-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..5c2f873fb9e Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ionic-md-rtl-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..9317780e3a2 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..00c275ac24e Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..689b588fc65 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..6e1f40168c6 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..ffbab7c8919 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-rtl-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..ddad1372052 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..46cfe39151f Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..412b6469f4f Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-ltr-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..0bf4723748f Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-rtl-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..27763acc007 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-rtl-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..7dcfbd9826b Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-rtl-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..2269e33d711 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-end-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..d6195184ad3 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..0070f2a4194 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..3b8d10df487 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-rtl-light-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-rtl-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..1bb49038732 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-rtl-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-rtl-light-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-rtl-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..9f877d92266 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-rtl-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-rtl-light-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-rtl-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..7d42743b99b Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ionic-md-rtl-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..56c16bba9f2 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..a0b8449914a Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..7c34b34f55a Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..3bda3556f65 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..30e6a5de2a2 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-rtl-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..312fd390a94 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..6bcf0597a7b Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..5985dde3e94 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-ltr-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..80b0484d59f Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-rtl-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..28e4b16fc74 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-rtl-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..57392ec7794 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-rtl-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..aa0abd1b1b7 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-alert-start-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..ca74cba615c Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c8f9e1d4cab Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..ed6aec886d1 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-rtl-light-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-rtl-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..2ef9ee8c860 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-rtl-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-rtl-light-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-rtl-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c7c46faa4c5 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-rtl-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-rtl-light-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-rtl-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..b6759ef50cd Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ionic-md-rtl-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..821d991a870 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..6713610f1ef Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..baa2f3680d2 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..99ba9258b12 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..bad88595e54 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-rtl-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..e4488119066 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..b38d5b82126 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..7a8638bfd41 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-ltr-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..d5968bcfac1 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-rtl-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..aeb3b7cd841 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-rtl-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..a3b237c4410 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-rtl-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..c977a6dbb3b Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-end-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..ca74cba615c Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c8f9e1d4cab Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..ed6aec886d1 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-rtl-light-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-rtl-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..2ef9ee8c860 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-rtl-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-rtl-light-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-rtl-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c7c46faa4c5 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-rtl-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-rtl-light-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-rtl-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..b6759ef50cd Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ionic-md-rtl-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..84153c6f41a Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..f197c5c4514 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..db4030312a0 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..b22bdfdeaea Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..720116cf1cb Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-rtl-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..4a84e90bcdd Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..c36791eb1c2 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..540d563377c Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-ltr-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..722b846dc1e Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-rtl-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..e3fb8a2ed88 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-rtl-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..03d30774c67 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-rtl-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..ac996d14943 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-modal-start-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..1abf12cee2a Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..4748b709611 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..aa8ab2242a1 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-rtl-light-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-rtl-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..3414ef156d3 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-rtl-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-rtl-light-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-rtl-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..3b58e6dd1f1 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-rtl-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-rtl-light-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-rtl-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..08bbb64977b Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ionic-md-rtl-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..f19ee15e627 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..57b91450e0d Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..cf8c1533aae Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..49f1ab849f4 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..496eea2a8bd Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-rtl-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..aa70f017281 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..4301e63f0e0 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..d132894239e Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-ltr-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..9c035e71043 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-rtl-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..bb6b8421da9 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-rtl-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..79bd12dd0c1 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-rtl-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..923a4ca22f8 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-end-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..e89d403830d Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..abda0ed0f19 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..5308e6a239a Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-rtl-light-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-rtl-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..5c7236fac19 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-rtl-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-rtl-light-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-rtl-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..ed6b8f13ddd Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-rtl-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-rtl-light-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-rtl-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..7a6e83efaa7 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ionic-md-rtl-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..510859f2a22 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..6c3b7c4f0e7 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..779044231ee Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..27c11d3a495 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..5f3d1540166 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-rtl-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..6bf85e0399e Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..d5190455b7c Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..ae282fc5ae6 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-ltr-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..855f16724b1 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-rtl-Mobile-Chrome-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-rtl-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..1b871d28990 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-rtl-Mobile-Firefox-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-rtl-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..425a6284131 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-rtl-Mobile-Safari-linux.png b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-rtl-Mobile-Safari-linux.png new file mode 100644 index 00000000000..cda4837fcc7 Binary files /dev/null and b/core/src/components/select-option/test/label-placement/select-option.e2e.ts-snapshots/select-option-label-popover-start-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-popover/select-popover.common.scss b/core/src/components/select-popover/select-popover.common.scss index 095b6660f35..02f70aaf5e2 100644 --- a/core/src/components/select-popover/select-popover.common.scss +++ b/core/src/components/select-popover/select-popover.common.scss @@ -1,15 +1,16 @@ -@import "../../themes/native/native.globals"; +@use "../../themes/mixins" as mixins; +@use "../select-option/select-option.common.overlay"; // Select Popover // -------------------------------------------------- :host ion-list { - @include margin(0); + @include mixins.margin(0); } ion-list-header, ion-label { - @include margin(0); + @include mixins.margin(0); } /** @@ -21,22 +22,3 @@ ion-label { :host { overflow-y: auto; } - -// Select Popover: Select Option -// -------------------------------------------------- - -.select-option-label { - display: flex; - - align-items: center; -} - -.select-option-label-text { - display: flex; - - flex-wrap: wrap; -} - -.select-option-description { - display: block; -} diff --git a/core/src/components/select-popover/select-popover.ionic.scss b/core/src/components/select-popover/select-popover.ionic.scss index 1813794975d..e194c06a0d6 100644 --- a/core/src/components/select-popover/select-popover.ionic.scss +++ b/core/src/components/select-popover/select-popover.ionic.scss @@ -1,22 +1,50 @@ @use "../../themes/ionic/ionic.globals.scss" as globals; +@use "../../themes/mixins" as mixins; +@use "../select-option/select-option.ionic.overlay"; @use "./select-popover.common"; -@use "./select-popover.md" as select-popover-md; // Ionic Select Popover // -------------------------------------------------- -// Select Popover: Select Option -// -------------------------------------------------- +ion-item { + --border-width: #{globals.$ion-border-size-0}; + --background-focused: transparent; + --background-focused-opacity: 1; + --background-hover: #{globals.$ion-bg-neutral-subtlest-press}; + --background-hover-opacity: 1; + --background-activated: #{globals.$ion-bg-neutral-subtlest-press}; + --background-activated-opacity: 1; +} + +ion-item.ion-focused.item-checkbox-checked, +ion-item.ion-focused.item-radio-checked { + --background-focused: #{globals.$ion-bg-primary-subtle-default}; + --background-focused-opacity: 1; +} + +// Radio +// ---------------------------------------------------------------- -.select-option-label { - gap: globals.$ion-space-300; +ion-list ion-radio::part(container) { + display: none; } -.select-option-description { - @include globals.typography(globals.$ion-body-md-regular); - @include globals.padding(0); +ion-list ion-radio::part(label) { + @include mixins.margin(globals.$ion-space-0); +} + +// Radio and Checkbox: Label +// ---------------------------------------------------------------- + +ion-list ion-radio::part(label), +ion-list ion-checkbox::part(label) { + @include globals.typography(globals.$ion-body-lg-medium); +} - color: globals.$ion-text-subtle; +// Radio and Checkbox: Checked +// ---------------------------------------------------------------- - font-size: globals.$ion-font-size-350; +.item-radio-checked, +.item-checkbox-checked { + --background: #{globals.$ion-bg-primary-subtle-default}; } diff --git a/core/src/components/select-popover/select-popover.ios.scss b/core/src/components/select-popover/select-popover.ios.scss index de3cfea6135..ab68689b837 100644 --- a/core/src/components/select-popover/select-popover.ios.scss +++ b/core/src/components/select-popover/select-popover.ios.scss @@ -1,2 +1,5 @@ +@use "../select-option/select-option.ios.overlay"; @import "./select-popover.native"; -@import "./select-popover.ios.vars"; + +// Select Popover: Select Option +// -------------------------------------------------- diff --git a/core/src/components/select-popover/select-popover.ios.vars.scss b/core/src/components/select-popover/select-popover.ios.vars.scss deleted file mode 100644 index 188e3f5f97b..00000000000 --- a/core/src/components/select-popover/select-popover.ios.vars.scss +++ /dev/null @@ -1,5 +0,0 @@ -@import "../../themes/native/native.globals.ios"; -@import "../item/item.ios.vars"; - -// iOS Select Popover -// -------------------------------------------------- diff --git a/core/src/components/select-popover/select-popover.md.scss b/core/src/components/select-popover/select-popover.md.scss index c7728bcaf04..1ea46b7c288 100644 --- a/core/src/components/select-popover/select-popover.md.scss +++ b/core/src/components/select-popover/select-popover.md.scss @@ -1,3 +1,4 @@ +@use "../select-option/select-option.md.overlay"; @import "./select-popover.native"; @import "./select-popover.md.vars"; diff --git a/core/src/components/select-popover/select-popover.native.scss b/core/src/components/select-popover/select-popover.native.scss index 0b52fafe932..79289a37ea9 100644 --- a/core/src/components/select-popover/select-popover.native.scss +++ b/core/src/components/select-popover/select-popover.native.scss @@ -1,19 +1,8 @@ @use "../../themes/native/native.theme.default" as native; @use "../../themes/mixins" as mixins; @use "../../themes/functions.font" as font; +@use "../select-option/select-option.native.overlay"; @use "./select-popover.common"; // Select Popover: Native // -------------------------------------------------- - -.select-option-label { - gap: 12px; -} - -.select-option-description { - @include mixins.padding(5px, 0, 0, 0); - - color: native.$text-color-step-300; - - font-size: font.dynamic-font(12px); -} diff --git a/core/src/components/select-popover/select-popover.tsx b/core/src/components/select-popover/select-popover.tsx index b7f0f9bb839..711b80bba1b 100644 --- a/core/src/components/select-popover/select-popover.tsx +++ b/core/src/components/select-popover/select-popover.tsx @@ -1,5 +1,6 @@ import type { ComponentInterface } from '@stencil/core'; import { Element, Component, Host, Prop, h, forceUpdate } from '@stencil/core'; +import { getOverlayLabelJustify, getOverlayLabelPlacement } from '@utils/overlay-control-label'; import { safeCall } from '@utils/overlays'; import { renderOptionLabel } from '@utils/select-option-render'; import { getClassMap } from '@utils/theme'; @@ -127,6 +128,7 @@ export class SelectPopover implements ComponentInterface { } renderCheckboxOptions(options: SelectPopoverOption[]) { + const theme = getIonTheme(this); return options.map((option, index) => { /** * Cast to `SelectOverlayOption` to access rich content @@ -135,6 +137,7 @@ export class SelectPopover implements ComponentInterface { * part of the public `SelectPopoverOption` interface. */ const richOption = option as SelectOverlayOption; + const hasRichContent = !!richOption.startContent || !!richOption.endContent || !!richOption.description; const optionLabelOptions = { id: `popover-option-${index}`, label: richOption.text, @@ -142,9 +145,13 @@ export class SelectPopover implements ComponentInterface { endContent: richOption.endContent, description: richOption.description, }; + const defaultLabelPlacement = getOverlayLabelPlacement(theme, 'checkbox'); + const defaultJustify = getOverlayLabelJustify(theme, 'checkbox'); return ( { this.setChecked(ev); this.callOptionHandler(ev); @@ -172,6 +182,7 @@ export class SelectPopover implements ComponentInterface { } renderRadioOptions(options: SelectPopoverOption[]) { + const theme = getIonTheme(this); const checked = options.filter((o) => o.checked).map((o) => o.value)[0]; return ( @@ -184,6 +195,7 @@ export class SelectPopover implements ComponentInterface { * part of the public `SelectPopoverOption` interface. */ const richOption = option as SelectOverlayOption; + const hasRichContent = !!richOption.startContent || !!richOption.endContent || !!richOption.description; const optionLabelOptions = { id: `popover-option-${index}`, label: richOption.text, @@ -194,6 +206,8 @@ export class SelectPopover implements ComponentInterface { return ( this.dismissParentPopover()} onKeyDown={(ev) => { if (ev.key === 'Enter' && !ev.repeat) { diff --git a/core/src/components/select-popover/test/basic/index.html b/core/src/components/select-popover/test/basic/index.html index 69b0e78ceba..679ec678d2c 100644 --- a/core/src/components/select-popover/test/basic/index.html +++ b/core/src/components/select-popover/test/basic/index.html @@ -2,7 +2,7 @@ - Select - Popover + Select Popover - Basic + + + + Select Popover - States + + + + + + + + + + + + + Select Popover - States + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts b/core/src/components/select-popover/test/states/select-popover.e2e.ts new file mode 100644 index 00000000000..6963342d39c --- /dev/null +++ b/core/src/components/select-popover/test/states/select-popover.e2e.ts @@ -0,0 +1,60 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * This behavior does not vary across directions. + */ +configs({ directions: ['ltr'], modes: ['ios', 'md', 'ionic-md'] }).forEach(({ config, screenshot, title }) => { + test.describe(title('select-popover: states'), () => { + /** + * `(any-hover: hover)` evaluates to "none" in all three mobile-emulated + * projects, suppressing the hover rules: + * + * - Chromium and WebKit suppress it because of hasTouch and isMobile. + * - Headless Firefox doesn't detect input devices and reports no hover + * capability regardless of context options, so override it via + * launchOptions.firefoxUserPrefs. Bit values: 4 = FINE (mouse), + * 8 = HOVER, 12 = FINE | HOVER. + * + * Viewport, userAgent, and scale factor remain mobile-sized. + */ + test.use({ + hasTouch: false, + isMobile: false, + }); + + test.beforeEach(async ({ page }) => { + await page.goto(`/src/components/select-popover/test/states`, config); + }); + + test('should render all radio states', async ({ page }) => { + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + await page.locator('#single').click(); + await ionPopoverDidPresent.next(); + + const popover = page.locator('#popover-single'); + const selectPopover = popover.locator('ion-select-popover'); + await expect(selectPopover).toBeVisible(); + + const defaultRow = selectPopover.locator('ion-item').first(); + await defaultRow.hover(); + + await expect(selectPopover).toHaveScreenshot(screenshot('select-popover-radio-states')); + }); + + test('should render all checkbox states', async ({ page }) => { + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + await page.locator('#multiple').click(); + await ionPopoverDidPresent.next(); + + const popover = page.locator('#popover-multiple'); + const selectPopover = popover.locator('ion-select-popover'); + await expect(selectPopover).toBeVisible(); + + const defaultRow = selectPopover.locator('ion-item').first(); + await defaultRow.hover(); + + await expect(selectPopover).toHaveScreenshot(screenshot('select-popover-checkbox-states')); + }); + }); +}); diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..78b8d52a9bc Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..3cfc3bcee8b Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..5eefab8505f Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..e2aaa6296c6 Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..647c77b193c Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..76753d37ccc Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..bb05fb3d9e8 Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..1933f822460 Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-md-ltr-Mobile-Safari-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..0c3f5a3f507 Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-checkbox-states-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..9cf11e2df24 Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..dc20aa1555e Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..64eeba29c94 Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..4814033cdfb Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..122e0a19fbc Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..7896d663311 Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..a0448e47d3f Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..94559c1724d Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-md-ltr-Mobile-Safari-linux.png b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..2af5fa8df3f Binary files /dev/null and b/core/src/components/select-popover/test/states/select-popover.e2e.ts-snapshots/select-popover-radio-states-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/select-interface.ts b/core/src/components/select/select-interface.ts index 7bca3390a15..af1a397c9e1 100644 --- a/core/src/components/select/select-interface.ts +++ b/core/src/components/select/select-interface.ts @@ -23,11 +23,19 @@ export interface SelectActionSheetButton extends Omit export interface SelectAlertInput extends Omit, RichContentOption { /** The main label for the option as a string or an HTMLElement. */ label?: string | HTMLElement; + /** Where the label sits relative to the option's selection control. */ + labelPlacement?: 'start' | 'end'; + /** How to pack the label and the option's selection control within a line. */ + justify?: 'start' | 'end' | 'space-between'; } export interface SelectOverlayOption extends Omit, RichContentOption { /** The main text for the option as a string or an HTMLElement. */ text?: string | HTMLElement; + /** Where the label sits relative to the option's selection control. */ + labelPlacement?: 'start' | 'end'; + /** How to pack the label and the option's selection control within a line. */ + justify?: 'start' | 'end' | 'space-between'; } export interface RichContentOption { diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index cde4a52850f..24b6024f051 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -4,13 +4,13 @@ import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h, import { ENABLE_HTML_CONTENT_DEFAULT } from '@utils/config'; import type { NotchController } from '@utils/forms'; import { compareOptions, createNotchController, isOptionSelected, checkInvalidState } from '@utils/forms'; -import { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers'; +import { suppressFocusVisible, renderHiddenInput, inheritAttributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; import { actionSheetController, alertController, popoverController, modalController } from '@utils/overlays'; import type { OverlaySelect } from '@utils/overlays-interface'; import { isRTL } from '@utils/rtl'; -import { sanitizeDOMString } from '@utils/sanitization'; +import { reflectPropertiesToAttributes, sanitizeDOMTree } from '@utils/sanitization'; import { createColorClasses, hostContext } from '@utils/theme'; import { watchForOptions } from '@utils/watch-options'; import { caretDownSharp, chevronExpand } from 'ionicons/icons'; @@ -436,54 +436,72 @@ export class Select implements ComponentInterface { // Add logic to scroll selected item into view before presenting const scrollSelectedIntoView = () => { const indexOfSelected = this.childOpts.findIndex((o) => o.value === this.value); + + /** + * Determine which option to focus when the overlay opens: the selected + * option if the select has a value, otherwise the first enabled option. + */ + let optionToFocus: HTMLElement | null = null; + if (indexOfSelected > -1) { const selectedItem = overlay.querySelector( `.select-interface-option:nth-of-type(${indexOfSelected + 1})` ); + /** + * If the option contains an `ion-radio` or `ion-checkbox`, focus + * that instead of the option element itself. This ensures that + * screen readers will announce the role and state of the element + * (e.g. "radio button, checked") rather than only the option text. + * Alert and action sheet options are plain buttons, so fall back to + * focusing the option element itself in those cases. + */ if (selectedItem) { - /** - * Browsers such as Firefox do not - * correctly delegate focus when manually - * focusing an element with delegatesFocus. - * We work around this by manually focusing - * the interactive element. - * ion-radio and ion-checkbox are the only - * elements that ion-select-popover uses, so - * we only need to worry about those two components - * when focusing. - */ - const interactiveEl = selectedItem.querySelector('ion-radio, ion-checkbox') as - | HTMLIonRadioElement - | HTMLIonCheckboxElement - | null; + const interactiveEl = selectedItem.querySelector('ion-radio, ion-checkbox'); if (interactiveEl) { selectedItem.scrollIntoView({ block: 'nearest' }); - // Needs to be called before `focusVisibleElement` to prevent issue with focus event bubbling - // and removing `ion-focused` style - interactiveEl.setFocus(); } - - focusVisibleElement(selectedItem); + optionToFocus = interactiveEl ?? selectedItem; } } else { /** * If no value is set then focus the first enabled option. */ - const firstEnabledOption = overlay.querySelector( + optionToFocus = overlay.querySelector( 'ion-radio:not(.radio-disabled), ion-checkbox:not(.checkbox-disabled)' - ) as HTMLIonRadioElement | HTMLIonCheckboxElement | null; + ); + } - if (firstEnabledOption) { - /** - * Focus the option for the same reason as we do above. - * - * Needs to be called before `focusVisibleElement` to prevent issue with focus event bubbling - * and removing `ion-focused` style - */ - firstEnabledOption.setFocus(); + if (optionToFocus) { + /** + * Focus the option directly (`setFocus()`/`focus()`) rather than + * with `focusVisibleElement()`. `focusVisibleElement()` forces the + * `ion-focused` focus ring on regardless of how the overlay was + * opened, whereas a plain focus lets the focus-visible utility + * decide: it shows the ring only when the overlay was opened with + * the keyboard and hides it for pointer opens. + * + * `ion-radio` and `ion-checkbox` expose `setFocus`, which correctly + * delegates focus to their inner focusable element. This is needed + * because browsers such as Firefox do not delegate focus correctly + * when focusing an element with delegatesFocus. When the option is + * a plain button it can be focused directly. + */ + if (optionToFocus.matches('ion-radio, ion-checkbox')) { + (optionToFocus as HTMLIonRadioElement | HTMLIonCheckboxElement).setFocus(); + } else { + optionToFocus.focus(); + } - focusVisibleElement(firstEnabledOption.closest('ion-item')!); + /** + * In the alert interface, when tabbing and pressing enter to open + * the select, the value that is currently selected flashes the + * focused state briefly before moving to its wrapper. This suppresses + * the focus visible state, so that the option will only show as + * focused when navigating with the keyboard. + */ + if (this.interface === 'alert') { + suppressFocusVisible(); } } }; @@ -591,16 +609,13 @@ export class Select implements ComponentInterface { .join(' '); const optClass = `${OPTION_CLASS} ${copyClasses}`; const isSelected = isOptionSelected(selectValue, value, this.compareWith); - const text = this.customHTMLEnabled ? getOptionContent(option) : getDefaultSlotPlainText(option); - const startContent = this.customHTMLEnabled - ? (getOptionContent(option, 'start') as HTMLElement | null) - : undefined; - const endContent = this.customHTMLEnabled ? (getOptionContent(option, 'end') as HTMLElement | null) : undefined; + const { content, startContent, endContent } = extractOptionContent(option, this.customHTMLEnabled); return { role: isSelected ? 'selected' : '', - text: text ?? '', + text: content ?? '', cssClass: optClass, + disabled: option.disabled, handler: () => { this.setValue(value); }, @@ -608,8 +623,8 @@ export class Select implements ComponentInterface { 'aria-checked': isSelected ? 'true' : 'false', role: 'radio', }, - startContent: startContent ?? undefined, - endContent: endContent ?? undefined, + startContent, + endContent, description: option.description, } as SelectActionSheetButton; }); @@ -639,22 +654,20 @@ export class Select implements ComponentInterface { .filter((cls) => cls !== 'hydrated') .join(' '); const optClass = `${OPTION_CLASS} ${copyClasses}`; - const label = this.customHTMLEnabled ? getOptionContent(option) : getDefaultSlotPlainText(option); - const startContent = this.customHTMLEnabled - ? (getOptionContent(option, 'start') as HTMLElement | null) - : undefined; - const endContent = this.customHTMLEnabled ? (getOptionContent(option, 'end') as HTMLElement | null) : undefined; + const { content, startContent, endContent } = extractOptionContent(option, this.customHTMLEnabled); return { type: inputType, cssClass: optClass, - label: label ?? '', + label: content ?? '', value, checked: isOptionSelected(selectValue, value, this.compareWith), disabled: option.disabled, - startContent: startContent ?? undefined, - endContent: endContent ?? undefined, + startContent, + endContent, description: option.description, + labelPlacement: option.labelPlacement, + justify: option.justify, }; }); @@ -670,14 +683,10 @@ export class Select implements ComponentInterface { .filter((cls) => cls !== 'hydrated') .join(' '); const optClass = `${OPTION_CLASS} ${copyClasses}`; - const text = this.customHTMLEnabled ? getOptionContent(option) : getDefaultSlotPlainText(option); - const startContent = this.customHTMLEnabled - ? (getOptionContent(option, 'start') as HTMLElement | null) - : undefined; - const endContent = this.customHTMLEnabled ? (getOptionContent(option, 'end') as HTMLElement | null) : undefined; + const { content, startContent, endContent } = extractOptionContent(option, this.customHTMLEnabled); return { - text: text ?? '', + text: content ?? '', cssClass: optClass, value, checked: isOptionSelected(selectValue, value, this.compareWith), @@ -688,9 +697,11 @@ export class Select implements ComponentInterface { this.close(); } }, - startContent: startContent ?? undefined, - endContent: endContent ?? undefined, + startContent, + endContent, description: option.description, + labelPlacement: option.labelPlacement, + justify: option.justify, }; }); @@ -1641,8 +1652,15 @@ const getOptionContent = ( // Default slot: get nodes without a slot attribute const defaultSlot = getOptionDefaultSlot(option) || []; nodes = defaultSlot.filter((node) => { - // Exclude whitespace-only text nodes to prevent empty container returns - return node.textContent?.trim().length !== 0; + /** + * Exclude whitespace-only text nodes (newline noise between + * markup elements). Element nodes are always kept, even when + * their textContent is empty (e.g. , ). + */ + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent?.trim().length !== 0; + } + return true; }); } @@ -1655,6 +1673,18 @@ const getOptionContent = ( return nodes.map((n) => n.textContent?.trim()).join(' ') || null; } + /** + * Mirror known custom-element properties (e.g. ion-icon's `icon`) + * onto attributes before cloning. Frameworks like Vue set these as + * DOM properties, which `cloneNode` doesn't copy, so without this + * step the cloned overlay copy renders without the prop's value. + */ + nodes.forEach((n) => { + if (n.nodeType === Node.ELEMENT_NODE) { + reflectPropertiesToAttributes(n as Element); + } + }); + // Clone each node into a temporary container const container = document.createElement('div'); nodes.forEach((n) => { @@ -1667,11 +1697,17 @@ const getOptionContent = ( container.appendChild(clone); }); + /** + * Sanitize the cloned DOM in place. Trusted attributes (size, color, + * shape, etc.) are preserved; event handlers, javascript: URLs, and + * blocked tags are stripped. + */ + sanitizeDOMTree(container); + if (useHTML) { - return sanitizeDOMString(container.innerHTML.trim()) || null; + return container.innerHTML.trim() || null; } - // Already sanitized through `renderOptionLabel` return container; }; @@ -1716,6 +1752,31 @@ const getDefaultSlotPlainText = (option: HTMLIonSelectOptionElement): string => return texts.join(' '); }; +/** + * Extracts the rich content from an `ion-select-option`. + * When `customHTMLEnabled` is `false`, only the plain text from the + * default slot is read and the start and end slots are skipped. + * + * @param option - The `ion-select-option` element to extract content from. + * @param customHTMLEnabled - Whether custom HTML rendering is enabled + * via the `innerHTMLTemplatesEnabled` config. + */ +const extractOptionContent = (option: HTMLIonSelectOptionElement, customHTMLEnabled: boolean) => { + if (!customHTMLEnabled) { + return { + content: getDefaultSlotPlainText(option), + startContent: undefined as HTMLElement | undefined, + endContent: undefined as HTMLElement | undefined, + }; + } + + return { + content: getOptionContent(option), + startContent: (getOptionContent(option, 'start') as HTMLElement | null) ?? undefined, + endContent: (getOptionContent(option, 'end') as HTMLElement | null) ?? undefined, + }; +}; + let selectIds = 0; const OPTION_CLASS = 'select-interface-option'; diff --git a/core/src/components/select/test/basic/index.html b/core/src/components/select/test/basic/index.html index 1048e1db3e6..d2cb7da9a4d 100644 --- a/core/src/components/select/test/basic/index.html +++ b/core/src/components/select/test/basic/index.html @@ -29,7 +29,7 @@ - + Apples Oranges Pears @@ -37,7 +37,7 @@ - + Apples Oranges Pears @@ -45,7 +45,7 @@ - + Apples Oranges Pears @@ -53,7 +53,7 @@ - + Apples Oranges Pears @@ -67,7 +67,12 @@ - + Apple Apricot Avocado @@ -105,12 +110,7 @@ - + Apple Apricot Avocado @@ -148,7 +148,7 @@ - + Apple Apricot Avocado @@ -186,7 +186,7 @@ - + Apple Apricot Avocado @@ -240,7 +240,7 @@ - + Bird Cat Dog @@ -249,7 +249,7 @@ - + Bird Cat Dog @@ -263,14 +263,12 @@ Custom Interface Options - + Pepperoni Bacon @@ -280,8 +278,15 @@ - - + + Pepperoni Bacon Extra Cheese @@ -290,13 +295,8 @@ - - + + Pepperoni Bacon Extra Cheese @@ -305,8 +305,8 @@ - - + + Pepperoni Bacon Extra Cheese @@ -318,38 +318,39 @@ diff --git a/core/src/components/select/test/basic/select.e2e.ts b/core/src/components/select/test/basic/select.e2e.ts index 6b797740b96..f157e3bd375 100644 --- a/core/src/components/select/test/basic/select.e2e.ts +++ b/core/src/components/select/test/basic/select.e2e.ts @@ -8,7 +8,7 @@ import { configs, test } from '@utils/test/playwright'; * does not. The overlay rendering is already tested in the respective * test files. */ -configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { +configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { test.describe(title('select: basic'), () => { test.beforeEach(async ({ page }) => { await page.goto('/src/components/select/test/basic', config); @@ -35,7 +35,11 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { await expect(alert).toHaveScreenshot(screenshot(`select-basic-alert-scroll-to-selected`)); }); - test('it should not focus any option when opened with no value', async ({ page }) => { + // On open, the alert focuses its wrapper (mirroring the native alert + // behavior) rather than an option, so no option is focused or shows the + // focus ring. Tabbing then moves focus into the radio group, focusing + // the first option and showing the focus ring. + test('it should focus the wrapper on open, then the first option on Tab', async ({ page, pageUtils }) => { // ion-app is required to apply the focused styles await page.setContent( ` @@ -60,12 +64,24 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { const alert = page.locator('ion-alert'); - // Verify that no option has the ion-focused class - const focusedOptions = alert.locator('.alert-radio-button.ion-focused'); - await expect(focusedOptions).toHaveCount(0); + // On open the wrapper is focused and no option has the focus ring. + await expect(alert.locator('.alert-wrapper')).toBeFocused(); + await expect(alert.locator('.alert-radio-button.ion-focused')).toHaveCount(0); + + // Tabbing moves focus into the radio group, focusing the first option + // and showing the focus ring. + await pageUtils.pressKeys('Tab'); + await page.waitForChanges(); + + const firstOption = alert.locator('.alert-radio-button').nth(0); + await expect(firstOption).toBeFocused(); + await expect(firstOption).toHaveClass(/ion-focused/); }); - test('it should not focus any option when opened with a value', async ({ page }) => { + // Same as above, but with a selected value: the wrapper is focused on + // open and tabbing focuses the selected option (the roving tabindex + // points at the checked radio) rather than the first option. + test('it should focus the wrapper on open, then the selected option on Tab', async ({ page, pageUtils }) => { // ion-app is required to apply the focused styles await page.setContent( ` @@ -90,9 +106,99 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { const alert = page.locator('ion-alert'); - // Alert interface doesn't apply ion-focused class to selected options - const focusedOptions = alert.locator('.alert-radio-button.ion-focused'); - await expect(focusedOptions).toHaveCount(0); + // On open the wrapper is focused and no option has the focus ring. + await expect(alert.locator('.alert-wrapper')).toBeFocused(); + await expect(alert.locator('.alert-radio-button.ion-focused')).toHaveCount(0); + + // Tabbing moves focus to the selected option and shows the focus ring. + await pageUtils.pressKeys('Tab'); + await page.waitForChanges(); + + const selectedOption = alert.locator('.alert-radio-button').nth(1); + await expect(selectedOption).toBeFocused(); + await expect(selectedOption).toHaveClass(/ion-focused/); + }); + + // When opening with the keyboard (Tab to the select, then Enter), the + // focus indicator should display on the alert wrapper. + test('it should show the focus indicator when opened with the keyboard', async ({ page, pageUtils, skip }) => { + // TODO (ROU-5437) + skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); + + // ion-app is required to apply the focused styles + await page.setContent( + ` + + + Apples + Bananas + Oranges + + + `, + config + ); + + const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent'); + + // Tab to the select and open it with the keyboard. + await pageUtils.pressKeys('Tab'); + await pageUtils.pressKeys('Enter'); + await ionAlertDidPresent.next(); + + await page.waitForChanges(); + + const alert = page.locator('ion-alert'); + + // The wrapper should have :focus-visible, and no option should + // have the .ion-focused class. + const wrapper = alert.locator('.alert-wrapper'); + await expect(wrapper).toBeFocused(); + expect(await wrapper.evaluate((el) => el.matches(':focus-visible'))).toBe(true); + await expect(alert.locator('.alert-radio-button.ion-focused')).toHaveCount(0); + }); + + // When opening with the keyboard and a value, the focus indicator + // still displays on the wrapper, not the selected option. + test('it should focus the wrapper when opened with the keyboard and a value', async ({ + page, + pageUtils, + skip, + }) => { + // TODO (ROU-5437) + skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); + + // ion-app is required to apply the focused styles + await page.setContent( + ` + + + Apples + Bananas + Oranges + + + `, + config + ); + + const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent'); + + // Tab to the select and open it with the keyboard. + await pageUtils.pressKeys('Tab'); + await pageUtils.pressKeys('Enter'); + await ionAlertDidPresent.next(); + + await page.waitForChanges(); + + const alert = page.locator('ion-alert'); + + // The wrapper (not the selected option) has :focus-visible, and no + // option has the .ion-focused class. + const wrapper = alert.locator('.alert-wrapper'); + await expect(wrapper).toBeFocused(); + expect(await wrapper.evaluate((el) => el.matches(':focus-visible'))).toBe(true); + await expect(alert.locator('.alert-radio-button.ion-focused')).toHaveCount(0); }); }); @@ -117,7 +223,10 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { await expect(actionSheet).toHaveScreenshot(screenshot(`select-basic-action-sheet-scroll-to-selected`)); }); - test('it should not focus any option when opened with no value', async ({ page }) => { + // On open, the action sheet focuses its dialog rather than an option, + // so no option is focused or shows the focus ring. Tabbing then moves + // focus to the first option and shows the focus ring. + test('it should focus the dialog on open, then the first option on Tab', async ({ page, pageUtils }) => { // ion-app is required to apply the focused styles await page.setContent( ` @@ -142,12 +251,25 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { const actionSheet = page.locator('ion-action-sheet'); - // Verify that none of the options have the ion-focused class - const focusedOptions = actionSheet.locator('.action-sheet-button.ion-focused'); - await expect(focusedOptions).toHaveCount(0); + // On open the dialog is focused and no option has the focus ring. + await expect(actionSheet).toBeFocused(); + await expect(actionSheet.locator('.action-sheet-button.ion-focused')).toHaveCount(0); + + // Tabbing moves focus to the first option and shows the focus ring. + await pageUtils.pressKeys('Tab'); + await page.waitForChanges(); + + const firstOption = actionSheet.locator('.action-sheet-button').nth(0); + await expect(firstOption).toBeFocused(); + await expect(firstOption).toHaveClass(/ion-focused/); }); - test('it should focus the second option when opened with a value', async ({ page }) => { + // Same as above, but with a selected value: the selected option is + // focused on open and tabbing moves focus to the Cancel button. + test('it should focus the selected option on open, then the Cancel button on Tab', async ({ + page, + pageUtils, + }) => { // ion-app is required to apply the focused styles await page.setContent( ` @@ -172,9 +294,17 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { const actionSheet = page.locator('ion-action-sheet'); - // Find the button containing "Bananas" and verify it has the ion-focused class + // On open the selected option is focused and no option has the focus ring. const bananasOption = actionSheet.locator('.action-sheet-button:has-text("Bananas")'); - await expect(bananasOption).toHaveClass(/ion-focused/); + await expect(bananasOption).toBeFocused(); + await expect(actionSheet.locator('.action-sheet-button.ion-focused')).toHaveCount(0); + + // Tabbing moves focus to the Cancel button and shows the focus ring. + await pageUtils.pressKeys('Tab'); + await page.waitForChanges(); + const cancelButton = actionSheet.getByRole('button', { name: 'Cancel' }); + await expect(cancelButton).toBeFocused(); + await expect(cancelButton).toHaveClass(/ion-focused/); }); test('it should focus the second option when opened with a value and a header', async ({ page }) => { @@ -213,36 +343,158 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { const actionSheet = page.locator('ion-action-sheet'); - // Find the option containing "Bananas" and verify it has the ion-focused class + // Find the option containing "Bananas" and verify it has focus + // so screen readers announce it const bananasOption = actionSheet.locator('.action-sheet-button:has-text("Bananas")'); - await expect(bananasOption).toHaveClass(/ion-focused/); + await expect(bananasOption).toBeFocused(); + + // Verify the focus indicator is not shown when the action sheet + // first opens. + await expect(actionSheet.locator('.action-sheet-button.ion-focused')).toHaveCount(0); }); - }); - test.describe('select: popover', () => { - test('it should open a popover select', async ({ page, skip }) => { + // When opening with the keyboard (Tab to the select, then Enter), no + // focus indicator should display on the action sheet, but we still + // need to ensure the dialog is focused. + test('it should add the focused class when opened with the keyboard', async ({ page, pageUtils, skip }) => { // TODO (ROU-5437) skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); + // ion-app is required to apply the focused styles + await page.setContent( + ` + + + Apples + Bananas + Oranges + + + `, + config + ); + + const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent'); + + // Tab to the select and open it with the keyboard. + await pageUtils.pressKeys('Tab'); + await pageUtils.pressKeys('Enter'); + await ionActionSheetDidPresent.next(); + + await page.waitForChanges(); + + const actionSheet = page.locator('ion-action-sheet'); + + // The action sheet should have :focus-visible, and no option should + // have the .ion-focused class. + await expect(actionSheet).toBeFocused(); + expect(await actionSheet.evaluate((el) => el.matches(':focus-visible'))).toBe(true); + await expect(actionSheet.locator('.action-sheet-button.ion-focused')).toHaveCount(0); + }); + + // When opening with the keyboard and a value, the focus indicator + // should display on the selected option. + test('it should focus the selected option when opened with the keyboard and a value', async ({ + page, + pageUtils, + skip, + }) => { + // TODO (ROU-5437) + skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); + + // ion-app is required to apply the focused styles + await page.setContent( + ` + + + Apples + Bananas + Oranges + + + `, + config + ); + + const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent'); + + // Tab to the select and open it with the keyboard. + await pageUtils.pressKeys('Tab'); + await pageUtils.pressKeys('Enter'); + await ionActionSheetDidPresent.next(); + + await page.waitForChanges(); + + const actionSheet = page.locator('ion-action-sheet'); + + // The selected option (Bananas) is focused and shows the focus ring. + const bananasOption = actionSheet.locator('.action-sheet-button:has-text("Bananas")'); + await expect(bananasOption).toBeFocused(); + await expect(bananasOption).toHaveClass(/ion-focused/); + }); + }); + + test.describe('select: popover', () => { + test('it should open a popover select', async ({ page }) => { const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); await page.click('#customPopoverSelect'); await ionPopoverDidPresent.next(); + await expect(page.locator('ion-popover')).toBeVisible(); + }); + + // On open, the popover focuses the first option (so screen readers + // announce it) rather than the dialog, but no option shows the focus + // ring. Arrowing then moves focus to the next option and shows the focus + // ring. + test('it should focus the first option on open, then the next option on Arrow', async ({ page, pageUtils }) => { + // ion-app is required to apply the focused styles + await page.setContent( + ` + + + Apples + Bananas + Oranges + + + `, + config + ); + + const select = page.locator('ion-select'); + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + + await select.click(); + await ionPopoverDidPresent.next(); + + await page.waitForChanges(); + const popover = page.locator('ion-popover'); - // select has no value, so first option should be focused by default - const popoverOption1 = popover.locator('.select-interface-option:first-of-type ion-radio'); - await expect(popoverOption1).toBeFocused(); + // On open the first option is focused and no option has the focus ring. + await expect(popover.locator('.select-interface-option').nth(0).locator('ion-radio')).toBeFocused(); + await expect(popover.locator('.select-interface-option.ion-focused')).toHaveCount(0); - await expect(popover).toBeVisible(); - }); + // Arrowing moves focus to the next option and shows the focus ring. + await pageUtils.pressKeys('ArrowDown'); + await page.waitForChanges(); - test('it should focus the second option when opened with a value', async ({ page, skip }) => { - // TODO (ROU-5437) - skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); + const secondOption = popover.locator('.select-interface-option').nth(1); + await expect(secondOption.locator('ion-radio')).toBeFocused(); + await expect(secondOption).toHaveClass(/ion-focused/); + }); + // Same as above, but with a selected value: the popover focuses the + // selected option (so screen readers announce it), but no option + // shows the focus ring. Arrowing then moves focus to the next option + // and shows the focus ring. + test('it should focus the selected option on open, then the next option on Arrow', async ({ + page, + pageUtils, + }) => { // ion-app is required to apply the focused styles await page.setContent( ` @@ -267,25 +519,110 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { const popover = page.locator('ion-popover'); - // Find the option containing "Bananas" and verify it has the ion-focused class + // On open the selected option is focused and no option has the focus ring. const bananasOption = popover.locator('.select-interface-option:has-text("Bananas")'); - await expect(bananasOption).toHaveClass(/ion-focused/); + await expect(bananasOption.locator('ion-radio')).toBeFocused(); + await expect(popover.locator('.select-interface-option.ion-focused')).toHaveCount(0); + + // Arrowing moves focus to the next option and shows the focus ring. + await pageUtils.pressKeys('ArrowDown'); + await page.waitForChanges(); + + const orangesOption = popover.locator('.select-interface-option:has-text("Oranges")'); + await expect(orangesOption.locator('ion-radio')).toBeFocused(); + await expect(orangesOption).toHaveClass(/ion-focused/); }); - test('it should scroll to selected option when opened', async ({ page }) => { + // When opening with the keyboard (Tab to the select, then Enter), the + // focus indicator should display on the focused option. + test('it should show the focus indicator when opened with the keyboard', async ({ page, pageUtils, skip }) => { + // TODO (ROU-5437) + skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); + + // ion-app is required to apply the focused styles + await page.setContent( + ` + + + Apples + Bananas + Oranges + + + `, + config + ); + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); - await page.click('#popover-select-scroll-to-selected'); + // Tab to the select and open it with the keyboard. + await pageUtils.pressKeys('Tab'); + await pageUtils.pressKeys('Enter'); await ionPopoverDidPresent.next(); + await page.waitForChanges(); + const popover = page.locator('ion-popover'); - await expect(popover).toHaveScreenshot(screenshot(`select-basic-popover-scroll-to-selected`)); + + // The focus indicator should be shown on the focused option. + await expect(popover.locator('.select-interface-option').nth(0)).toHaveClass(/ion-focused/); }); - test('opening a popover with Enter should not immediately dismiss it', async ({ page, skip }, testInfo) => { + // When opening with the keyboard and a value, the focus indicator + // should display on the selected option. + test('it should focus the selected option when opened with the keyboard and a value', async ({ + page, + pageUtils, + skip, + }) => { // TODO (ROU-5437) skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); + // ion-app is required to apply the focused styles + await page.setContent( + ` + + + Apples + Bananas + Oranges + + + `, + config + ); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + + // Tab to the select and open it with the keyboard. + await pageUtils.pressKeys('Tab'); + await pageUtils.pressKeys('Enter'); + await ionPopoverDidPresent.next(); + + await page.waitForChanges(); + + const popover = page.locator('ion-popover'); + + // The selected option (Bananas, the 2nd), not the first, is focused and + // shows the focus ring. + const firstOption = popover.locator('.select-interface-option').nth(0); + const selectedOption = popover.locator('.select-interface-option').nth(1); + await expect(selectedOption.locator('ion-radio')).toBeFocused(); + await expect(selectedOption).toHaveClass(/ion-focused/); + await expect(firstOption).not.toHaveClass(/ion-focused/); + }); + + test('it should scroll to selected option when opened', async ({ page }) => { + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + + await page.click('#popover-select-scroll-to-selected'); + await ionPopoverDidPresent.next(); + + const popover = page.locator('ion-popover'); + await expect(popover).toHaveScreenshot(screenshot(`select-basic-popover-scroll-to-selected`)); + }); + + test('opening a popover with Enter should not immediately dismiss it', async ({ page, pageUtils }, testInfo) => { testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/30561', @@ -307,7 +644,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { const ionPopoverDidDismiss = await page.spyOnEvent('ionPopoverDidDismiss'); await page.locator('ion-select button').focus(); - await page.keyboard.press('Enter'); + await pageUtils.pressKeys('Enter'); await ionPopoverDidPresent.next(); const popover = page.locator('ion-popover'); @@ -318,10 +655,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { await expect(popover).toBeVisible(); }); - test('holding Enter to open a popover should not immediately dismiss it', async ({ page, skip }, testInfo) => { - // TODO (ROU-5437) - skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); - + test('holding Enter to open a popover should not immediately dismiss it', async ({ page }, testInfo) => { testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/30561', @@ -371,16 +705,59 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { await ionModalDidPresent.next(); + await expect(page.locator('ion-modal')).toBeVisible(); + }); + + // On open, the modal focuses the first option (so screen readers + // announce it) rather than the dialog, but no option shows the focus + // ring. Arrowing then moves focus to the next option and shows the + // focus ring. + test('it should focus the first option on open, then the next option on Arrow', async ({ page, pageUtils }) => { + // ion-app is required to apply the focused styles + await page.setContent( + ` + + + Apples + Bananas + Oranges + + + `, + config + ); + + const select = page.locator('ion-select'); + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await select.click(); + await ionModalDidPresent.next(); + + await page.waitForChanges(); + const modal = page.locator('ion-modal'); - // select has no value, so first option should be focused by default - const modalOption1 = modal.locator('.select-interface-option:first-of-type ion-radio'); - await expect(modalOption1).toBeFocused(); + // On open the first option is focused and no option has the focus ring. + await expect(modal.locator('.select-interface-option').nth(0).locator('ion-radio')).toBeFocused(); + await expect(modal.locator('.select-interface-option.ion-focused')).toHaveCount(0); - await expect(modal).toBeVisible(); + // Arrowing moves focus to the next option and shows the focus ring. + await pageUtils.pressKeys('ArrowDown'); + await page.waitForChanges(); + + const secondOption = modal.locator('.select-interface-option').nth(1); + await expect(secondOption.locator('ion-radio')).toBeFocused(); + await expect(secondOption).toHaveClass(/ion-focused/); }); - test('it should focus the second option when opened with a value', async ({ page }) => { + // Same as above, but with a selected value: the modal focuses the + // selected option (so screen readers announce it), but no option shows + // the focus ring. Arrowing then moves focus to the next option and + // shows the focus ring. + test('it should focus the selected option on open, then the next option on Arrow', async ({ + page, + pageUtils, + }) => { // ion-app is required to apply the focused styles await page.setContent( ` @@ -405,9 +782,97 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { const modal = page.locator('ion-modal'); - // Find the option containing "Bananas" and verify it has the ion-focused class + // On open the selected option is focused and no option has the focus ring. const bananasOption = modal.locator('.select-interface-option:has-text("Bananas")'); - await expect(bananasOption).toHaveClass(/ion-focused/); + await expect(bananasOption.locator('ion-radio')).toBeFocused(); + await expect(modal.locator('.select-interface-option.ion-focused')).toHaveCount(0); + + // Arrowing moves focus to the next option and shows the focus ring. + await pageUtils.pressKeys('ArrowDown'); + await page.waitForChanges(); + + const orangesOption = modal.locator('.select-interface-option:has-text("Oranges")'); + await expect(orangesOption.locator('ion-radio')).toBeFocused(); + await expect(orangesOption).toHaveClass(/ion-focused/); + }); + + // When opening with the keyboard (Tab to the select, then Enter), the + // focus indicator should display on the focused option. + test('it should show the focus indicator when opened with the keyboard', async ({ page, pageUtils, skip }) => { + // TODO (ROU-5437) + skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); + + // ion-app is required to apply the focused styles + await page.setContent( + ` + + + Apples + Bananas + Oranges + + + `, + config + ); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + // Tab to the select and open it with the keyboard. + await pageUtils.pressKeys('Tab'); + await pageUtils.pressKeys('Enter'); + await ionModalDidPresent.next(); + + await page.waitForChanges(); + + const modal = page.locator('ion-modal'); + + // The focus indicator should be shown on the focused option. + await expect(modal.locator('.select-interface-option').nth(0)).toHaveClass(/ion-focused/); + }); + + // When opening with the keyboard and a value, the focus indicator + // should display on the selected option. + test('it should focus the selected option when opened with the keyboard and a value', async ({ + page, + pageUtils, + skip, + }) => { + // TODO (ROU-5437) + skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); + + // ion-app is required to apply the focused styles + await page.setContent( + ` + + + Apples + Bananas + Oranges + + + `, + config + ); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + // Tab to the select and open it with the keyboard. + await pageUtils.pressKeys('Tab'); + await pageUtils.pressKeys('Enter'); + await ionModalDidPresent.next(); + + await page.waitForChanges(); + + const modal = page.locator('ion-modal'); + + // The selected option (Bananas, the 2nd), not the first, is focused and + // shows the focus ring. + const firstOption = modal.locator('.select-interface-option').nth(0); + const selectedOption = modal.locator('.select-interface-option').nth(1); + await expect(selectedOption.locator('ion-radio')).toBeFocused(); + await expect(selectedOption).toHaveClass(/ion-focused/); + await expect(firstOption).not.toHaveClass(/ion-focused/); }); test('it should scroll to selected option when opened', async ({ page }) => { @@ -419,6 +884,89 @@ configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { const modal = page.locator('ion-modal'); await expect(modal).toHaveScreenshot(screenshot(`select-basic-modal-scroll-to-selected`)); }); + + test('it should support keyboard focus cycling between list, handle, and cancel', async ({ page, pageUtils }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#customModalSelect'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + const firstOption = modal.locator('.select-interface-option:first-of-type ion-radio'); + const secondOption = modal.locator('.select-interface-option:nth-of-type(2) ion-radio'); + const handle = modal.locator('.modal-handle'); + const cancelButton = modal.getByRole('button', { name: 'Cancel' }); + + await expect(firstOption).toBeFocused(); + + // After moving focus with arrow keys, Tab should still visit the handle + // before the cancel button + await pageUtils.pressKeys('ArrowDown'); + await expect(secondOption).toBeFocused(); + await page.waitForChanges(); + await pageUtils.pressKeys('Tab'); + await expect(handle).toBeFocused(); + await pageUtils.pressKeys('Shift+Tab'); + await expect(secondOption).toBeFocused(); + + await pageUtils.pressKeys('ArrowUp'); + await expect(firstOption).toBeFocused(); + + // Forward cycle: list option -> handle -> cancel -> list option + await pageUtils.pressKeys('Tab'); + await expect(handle).toBeFocused(); + + await pageUtils.pressKeys('Tab'); + await expect(cancelButton).toBeFocused(); + + await pageUtils.pressKeys('Tab'); + await expect(firstOption).toBeFocused(); + + // Reverse cycle: list option -> cancel -> handle -> list option + await pageUtils.pressKeys('Shift+Tab'); + await expect(cancelButton).toBeFocused(); + + await pageUtils.pressKeys('Shift+Tab'); + await expect(handle).toBeFocused(); + + await pageUtils.pressKeys('Shift+Tab'); + await expect(firstOption).toBeFocused(); + }); + + test('it should tab through cancel using the last arrow-highlighted option', async ({ page, pageUtils }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#customModalSelect'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + const thirdOption = modal.locator('.select-interface-option:nth-of-type(3) ion-radio'); + const handle = modal.locator('.modal-handle'); + const cancelButton = modal.getByRole('button', { name: 'Cancel' }); + + await pageUtils.pressKeys('ArrowDown'); + await pageUtils.pressKeys('ArrowDown'); + await expect(thirdOption).toBeFocused(); + await page.waitForChanges(); + + await pageUtils.pressKeys('Tab'); + await expect(handle).toBeFocused(); + + await pageUtils.pressKeys('Tab'); + await expect(cancelButton).toBeFocused(); + + await pageUtils.pressKeys('Tab'); + await expect(thirdOption).toBeFocused(); + + await pageUtils.pressKeys('Shift+Tab'); + await expect(cancelButton).toBeFocused(); + + await pageUtils.pressKeys('Shift+Tab'); + await expect(handle).toBeFocused(); + + await pageUtils.pressKeys('Shift+Tab'); + await expect(thirdOption).toBeFocused(); + }); }); }); }); @@ -530,10 +1078,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { expect(ionChange).toHaveReceivedEventTimes(1); }); - test('should fire ionChange when confirming a popover value with Enter', async ({ page, skip }, testInfo) => { - // TODO (ROU-5437) - skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); - + test('should fire ionChange when confirming a popover value with Enter', async ({ page, pageUtils }, testInfo) => { testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/30561', @@ -563,7 +1108,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const secondRadio = popover.locator('ion-radio').nth(1); await secondRadio.focus(); - await page.keyboard.press('Enter'); + await pageUtils.pressKeys('Enter'); await ionChange.next(); await ionPopoverDidDismiss.next(); @@ -603,11 +1148,8 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test('should fire ionChange exactly once when confirming a popover value with Space', async ({ page, - skip, + pageUtils, }, testInfo) => { - // TODO (ROU-5437) - skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); - testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/30561', @@ -637,7 +1179,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const secondRadio = popover.locator('ion-radio').nth(1); await secondRadio.focus(); - await page.keyboard.press('Space'); + await pageUtils.pressKeys('Space'); await ionChange.next(); await ionPopoverDidDismiss.next(); @@ -649,11 +1191,8 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test('should not fire ionChange when confirming the already-selected popover option with Enter', async ({ page, - skip, + pageUtils, }, testInfo) => { - // TODO (ROU-5437) - skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); - testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/26789', @@ -683,7 +1222,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const selectedRadio = popover.locator('ion-radio').nth(0); await selectedRadio.focus(); - await page.keyboard.press('Enter'); + await pageUtils.pressKeys('Enter'); await ionPopoverDidDismiss.next(); @@ -694,11 +1233,8 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test('should not fire ionChange when confirming the already-selected popover option with Space', async ({ page, - skip, + pageUtils, }, testInfo) => { - // TODO (ROU-5437) - skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); - testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/26789', @@ -728,7 +1264,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const selectedRadio = popover.locator('ion-radio').nth(0); await selectedRadio.focus(); - await page.keyboard.press('Space'); + await pageUtils.pressKeys('Space'); await ionPopoverDidDismiss.next(); @@ -739,11 +1275,8 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test('should fire ionChange exactly once when confirming a modal value with Enter', async ({ page, - skip, + pageUtils, }, testInfo) => { - // TODO (ROU-5437) - skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); - testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/30561', @@ -773,7 +1306,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const secondRadio = modal.locator('ion-radio').nth(1); await secondRadio.focus(); - await page.keyboard.press('Enter'); + await pageUtils.pressKeys('Enter'); await ionChange.next(); await ionModalDidDismiss.next(); @@ -785,11 +1318,8 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test('should fire ionChange exactly once when confirming a modal value with Space', async ({ page, - skip, + pageUtils, }, testInfo) => { - // TODO (ROU-5437) - skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); - testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/30561', @@ -819,7 +1349,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const secondRadio = modal.locator('ion-radio').nth(1); await secondRadio.focus(); - await page.keyboard.press('Space'); + await pageUtils.pressKeys('Space'); await ionChange.next(); await ionModalDidDismiss.next(); @@ -831,11 +1361,8 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test('should not fire ionChange when confirming the already-selected modal option with Enter', async ({ page, - skip, + pageUtils, }, testInfo) => { - // TODO (ROU-5437) - skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); - testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/26789', @@ -865,7 +1392,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const selectedRadio = modal.locator('ion-radio').nth(0); await selectedRadio.focus(); - await page.keyboard.press('Enter'); + await pageUtils.pressKeys('Enter'); await ionModalDidDismiss.next(); @@ -876,11 +1403,8 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test('should not fire ionChange when confirming the already-selected modal option with Space', async ({ page, - skip, + pageUtils, }, testInfo) => { - // TODO (ROU-5437) - skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); - testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/26789', @@ -910,7 +1434,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const selectedRadio = modal.locator('ion-radio').nth(0); await selectedRadio.focus(); - await page.keyboard.press('Space'); + await pageUtils.pressKeys('Space'); await ionModalDidDismiss.next(); diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..c9b8ff901ff Binary files /dev/null and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..6a610ed52df Binary files /dev/null and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..9f0d3c8b3c2 Binary files /dev/null and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png index b1a10395d7c..ad0020a7f32 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png index b55a80fa526..6c89ef1b952 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png index 4c235db6b42..6fe7e332b5c 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png index b67c3ff807a..3498eb9ddfc 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png index b2753efbf69..09a35219e1b 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-md-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-md-ltr-Mobile-Safari-linux.png index 59581a60761..57602452646 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-md-ltr-Mobile-Safari-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-action-sheet-scroll-to-selected-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..9c157400b39 Binary files /dev/null and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..252126e97f7 Binary files /dev/null and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..1bb7d4b89c7 Binary files /dev/null and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png index 8ce21e9a05e..cf94b7493e1 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png index 3394b979256..fd00e3047cb 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png index 40af8b073d5..d5869e74cbb 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png index 92064755809..16c11b07e63 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png index 09dbd660ee0..ed5197a95bc 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-md-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-md-ltr-Mobile-Safari-linux.png index ed6becd6e68..93e0f922f04 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-md-ltr-Mobile-Safari-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-alert-scroll-to-selected-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..e51a1956b4f Binary files /dev/null and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..4469ae5fdbe Binary files /dev/null and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..c82ff67b404 Binary files /dev/null and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png index 43fcd9ef709..c34076f7793 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png index b25f9c98d2f..ee7886439c3 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png index 85abf17ca89..86ddf038b4a 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png index a75ada58efb..b4af5cf9108 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png index 81dd78d1d86..8e269f81bdb 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-md-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-md-ltr-Mobile-Safari-linux.png index 5010cc499ea..5fecf379ead 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-md-ltr-Mobile-Safari-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-modal-scroll-to-selected-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..f48320a721f Binary files /dev/null and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..eba655575b6 Binary files /dev/null and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..23aefebc5e8 Binary files /dev/null and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png index 058e9eb36b8..d0c359b271c 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png index f6dda21ddea..9d6096270a3 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png index a9540533623..07af1b2f5e1 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png index 48f5106e004..d08e3f38d77 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png index e13afdfc587..75ec154c2fc 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Safari-linux.png index 82b3f630513..a1cc24cbe96 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Safari-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/disabled/select.e2e.ts b/core/src/components/select/test/disabled/select.e2e.ts index 621ed048d3e..e567e2ff82e 100644 --- a/core/src/components/select/test/disabled/select.e2e.ts +++ b/core/src/components/select/test/disabled/select.e2e.ts @@ -1,8 +1,83 @@ import { expect } from '@playwright/test'; +import type { E2ELocator } from '@utils/test/playwright'; import { configs, test } from '@utils/test/playwright'; +const DISABLED_OPTION_INTERFACES = [ + { + name: 'action-sheet', + overlayTag: 'ion-action-sheet', + didPresent: 'ionActionSheetDidPresent', + didDismiss: 'ionActionSheetDidDismiss', + // The option itself is the interactive button. + controlSuffix: '', + }, + { + name: 'alert', + overlayTag: 'ion-alert', + didPresent: 'ionAlertDidPresent', + didDismiss: 'ionAlertDidDismiss', + // The option itself is the interactive radio button. + controlSuffix: '', + }, + { + name: 'popover', + overlayTag: 'ion-popover', + didPresent: 'ionPopoverDidPresent', + didDismiss: 'ionPopoverDidDismiss', + // The interactive control is the nested ion-radio. + controlSuffix: ' ion-radio', + }, + { + name: 'modal', + overlayTag: 'ion-modal', + didPresent: 'ionModalDidPresent', + didDismiss: 'ionModalDidDismiss', + // The interactive control is the nested ion-radio. + controlSuffix: ' ion-radio', + }, +] as const; + configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('select: disabled options'), () => { + for (const { name, overlayTag, didPresent, didDismiss, controlSuffix } of DISABLED_OPTION_INTERFACES) { + test(`${name}: clicking a disabled option should not change the value or dismiss the overlay`, async ({ + page, + }) => { + await page.setContent( + ` + + Oranges + + `, + config + ); + + const select = page.locator('ion-select') as E2ELocator; + + const ionChange = await select.spyOnEvent('ionChange'); + const ionDidPresent = await page.spyOnEvent(didPresent); + const ionDidDismiss = await page.spyOnEvent(didDismiss); + + await select.click(); + + await ionDidPresent.next(); + + const overlay = page.locator(overlayTag); + const disabledOption = overlay.locator(`.select-interface-option${controlSuffix}`); + + await disabledOption.click({ force: true }); + + await page.waitForChanges(); + + const value = await select.evaluate((el: HTMLIonSelectElement) => el.value); + expect(value).toBeUndefined(); + + expect(ionChange).toHaveReceivedEventTimes(0); + expect(ionDidDismiss).toHaveReceivedEventTimes(0); + await expect(overlay).toBeVisible(); + }); + } + test('should not focus a disabled option when no value is set', async ({ page, skip }) => { // TODO (ROU-5437) skip.browser('webkit', 'Safari 16 only allows text fields and pop-up menus to be focused.'); diff --git a/core/src/components/select/test/rich-content-option/index.html b/core/src/components/select/test/rich-content-option/index.html index 7bdf2881d3a..78d3f3987c9 100644 --- a/core/src/components/select/test/rich-content-option/index.html +++ b/core/src/components/select/test/rich-content-option/index.html @@ -54,14 +54,13 @@ - + NEW - + Full Content - This is a span element @@ -98,18 +97,60 @@ NEW + + + + + This is a very long option label that demonstrates how the start and end slots stay at their intrinsic + widths while the content area absorbs the remaining row width + NEW + + + + + + SVGs + + + + + + + + + + + Icons + + + + + Images + + + + + + + Thumbnails + + + + - + NEW Full Content - This is a span element @@ -146,18 +187,60 @@ NEW + + + + + This is a very long option label that demonstrates how the start and end slots stay at their intrinsic + widths while the content area absorbs the remaining row width + NEW + + + + + + SVGs + + + + + + + + + + + Icons + + + + + Images + + + + + + + Thumbnails + + + + - + NEW Full Content - This is a span element @@ -194,18 +277,60 @@ NEW + + + + + This is a very long option label that demonstrates how the start and end slots stay at their intrinsic + widths while the content area absorbs the remaining row width + NEW + + + + + + SVGs + + + + + + + + + + + Icons + + + + + Images + + + + + + + Thumbnails + + + + - + NEW Full Content - This is a span element @@ -242,6 +367,49 @@ NEW + + + + + This is a very long option label that demonstrates how the start and end slots stay at their intrinsic + widths while the content area absorbs the remaining row width + NEW + + + + + + SVGs + + + + + + + + + + + Icons + + + + + Images + + + + + + + Thumbnails + + + + @@ -259,7 +427,6 @@ Full Content - This is a span element @@ -296,18 +463,60 @@ NEW + + + + + This is a very long option label that demonstrates how the start and end slots stay at their intrinsic + widths while the content area absorbs the remaining row width + NEW + + + + + + SVGs + + + + + + + + + + + Icons + + + + + Images + + + + + + + Thumbnails + + + + - + NEW Full Content - This is a span element @@ -344,18 +553,61 @@ NEW + + + + + This is a very long option label that demonstrates how the start and end slots stay at their intrinsic + widths while the content area absorbs the remaining row width + NEW + + + + + + SVGs + + + + + + + SVG + + + + + Icons + + + + + Images + + + + + + + Thumbnails + + + + - + NEW Full Content - This is a span element @@ -392,6 +644,49 @@ NEW + + + + + This is a very long option label that demonstrates how the start and end slots stay at their intrinsic + widths while the content area absorbs the remaining row width + NEW + + + + + + SVGs + + + + + + + + + + + Icons + + + + + Images + + + + + + + Thumbnails + + + + diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts b/core/src/components/select/test/rich-content-option/select.e2e.ts index 78a2fa4e4a1..2929c00c790 100644 --- a/core/src/components/select/test/rich-content-option/select.e2e.ts +++ b/core/src/components/select/test/rich-content-option/select.e2e.ts @@ -2,28 +2,94 @@ import { expect } from '@playwright/test'; import { configs, test } from '@utils/test/playwright'; /** - * This behavior does not vary across modes/directions + * This behavior does not vary across directions */ -configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { +configs({ directions: ['ltr'], modes: ['ionic-md', 'md', 'ios'] }).forEach(({ title, screenshot, config }) => { test.describe(title('select: rich content options'), () => { test.beforeEach(async ({ page }) => { await page.goto('/src/components/select/test/rich-content-option', config); }); - test('it should render for alert interface and single selection', async ({ page }) => { + test('should not have visual regressions for the action sheet interface', async ({ page }) => { + const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent'); + + await page.locator('#action-sheet-select').click(); + await ionActionSheetDidPresent.next(); + + // Move mouse to away from the alert so hover styles don't interfere with screenshots + await page.mouse.move(0, 0); + + const firstOption = page.locator('ion-action-sheet .select-interface-option').first(); + + await expect(firstOption).toHaveScreenshot(screenshot(`select-rich-content-action-sheet`)); + }); + + test('should not have visual regressions for the alert interface', async ({ page }) => { const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent'); - const ionAlertDidDismiss = await page.spyOnEvent('ionAlertDidDismiss'); - const select = page.locator('#alert-select'); + await page.locator('#alert-select').click(); + await ionAlertDidPresent.next(); + + // Move mouse to away from the alert so hover styles don't interfere with screenshots + await page.mouse.move(0, 0); + + const firstOption = page.locator('ion-alert .select-interface-option').first(); + + await expect(firstOption).toHaveScreenshot(screenshot(`select-rich-content-alert`)); + }); + + test('should not have visual regressions for the modal interface', async ({ page }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.locator('#modal-select').click(); + await ionModalDidPresent.next(); + + // Move mouse to away from the alert so hover styles don't interfere with screenshots + await page.mouse.move(0, 0); + + const firstOption = page.locator('ion-modal .select-interface-option').first(); + + await expect(firstOption).toHaveScreenshot(screenshot(`select-rich-content-modal`)); + }); + + test('should not have visual regressions for the popover interface', async ({ page }) => { + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + + await page.locator('#popover-select').click(); + await ionPopoverDidPresent.next(); + + // Move mouse to away from the alert so hover styles don't interfere with screenshots + await page.mouse.move(0, 0); + + const firstOption = page.locator('ion-popover .select-interface-option').first(); + + await expect(firstOption).toHaveScreenshot(screenshot(`select-rich-content-popover`)); + }); + }); +}); + +/** + * This behavior does not vary across modes/directions + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('select: rich content option functionality'), () => { + test.beforeEach(async ({ page }) => { + await page.goto('/src/components/select/test/rich-content-option', config); + }); + + test('it should render for action sheet interface', async ({ page }) => { + const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent'); + const ionActionSheetDidDismiss = await page.spyOnEvent('ionActionSheetDidDismiss'); + + const select = page.locator('#action-sheet-select'); await select.click(); - await ionAlertDidPresent.next(); + await ionActionSheetDidPresent.next(); - const alert = page.locator('ion-alert'); - const firstOption = alert.locator('.alert-radio-label').first(); + const actionSheet = page.locator('ion-action-sheet'); + const firstOption = actionSheet.locator('.select-interface-option').first(); const avatar = firstOption.locator('ion-avatar'); - const spanText = await firstOption.locator('.span-style').textContent(); const firstOptionText = 'Full Content'; await expect(firstOption).toContainText(firstOptionText); @@ -32,17 +98,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { // Click on the first option await firstOption.click(); - // Confirm the selection - const confirmButton = alert.locator('.alert-button:not(.alert-button-role-cancel)'); - await confirmButton.click(); - - await ionAlertDidDismiss.next(); + await ionActionSheetDidDismiss.next(); // Verify that the select text includes the option text const selectText = await select.locator('.select-text').textContent(); expect(selectText).toContain(firstOptionText); - expect(selectText).toContain(spanText); // Verify that the select text does not include the avatar and badge const selectTextAvatar = select.locator('.select-text ion-avatar'); @@ -52,20 +113,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(selectTextBadge).toHaveCount(0); }); - test('it should render for alert interface and multiple selection', async ({ page }) => { + test('it should render for alert interface and single selection', async ({ page }) => { const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent'); const ionAlertDidDismiss = await page.spyOnEvent('ionAlertDidDismiss'); - const select = page.locator('#alert-select-multiple'); + const select = page.locator('#alert-select'); await select.click(); await ionAlertDidPresent.next(); const alert = page.locator('ion-alert'); - const firstOption = alert.locator('.alert-checkbox-label').first(); + const firstOption = alert.locator('.select-interface-option').first(); const avatar = firstOption.locator('ion-avatar'); - const spanText = await firstOption.locator('.span-style').textContent(); const firstOptionText = 'Full Content'; await expect(firstOption).toContainText(firstOptionText); @@ -84,7 +144,6 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const selectText = await select.locator('.select-text').textContent(); expect(selectText).toContain(firstOptionText); - expect(selectText).toContain(spanText); // Verify that the select text does not include the avatar and badge const selectTextAvatar = select.locator('.select-text ion-avatar'); @@ -94,20 +153,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(selectTextBadge).toHaveCount(0); }); - test('it should render for action sheet interface', async ({ page }) => { - const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent'); - const ionActionSheetDidDismiss = await page.spyOnEvent('ionActionSheetDidDismiss'); + test('it should render for alert interface and multiple selection', async ({ page }) => { + const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent'); + const ionAlertDidDismiss = await page.spyOnEvent('ionAlertDidDismiss'); - const select = page.locator('#action-sheet-select'); + const select = page.locator('#alert-select-multiple'); await select.click(); - await ionActionSheetDidPresent.next(); + await ionAlertDidPresent.next(); - const actionSheet = page.locator('ion-action-sheet'); - const firstOption = actionSheet.locator('.action-sheet-button-label').first(); + const alert = page.locator('ion-alert'); + const firstOption = alert.locator('.select-interface-option').first(); const avatar = firstOption.locator('ion-avatar'); - const spanText = await firstOption.locator('.span-style').textContent(); const firstOptionText = 'Full Content'; await expect(firstOption).toContainText(firstOptionText); @@ -116,13 +174,16 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { // Click on the first option await firstOption.click(); - await ionActionSheetDidDismiss.next(); + // Confirm the selection + const confirmButton = alert.locator('.alert-button:not(.alert-button-role-cancel)'); + await confirmButton.click(); + + await ionAlertDidDismiss.next(); // Verify that the select text includes the option text const selectText = await select.locator('.select-text').textContent(); expect(selectText).toContain(firstOptionText); - expect(selectText).toContain(spanText); // Verify that the select text does not include the avatar and badge const selectTextAvatar = select.locator('.select-text ion-avatar'); @@ -132,20 +193,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(selectTextBadge).toHaveCount(0); }); - test('it should render for popover interface and single selection', async ({ page }) => { - const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); - const ionPopoverDidDismiss = await page.spyOnEvent('ionPopoverDidDismiss'); + test('it should render for modal interface and single selection', async ({ page }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); - const select = page.locator('#popover-select'); + const select = page.locator('#modal-select'); await select.click(); - await ionPopoverDidPresent.next(); + await ionModalDidPresent.next(); - const popover = page.locator('ion-popover'); - const firstOption = popover.locator('.select-option-label').first(); + const modal = page.locator('ion-modal'); + const firstOption = modal.locator('.select-interface-option').first(); const avatar = firstOption.locator('ion-avatar'); - const spanText = await firstOption.locator('.span-style').textContent(); const firstOptionText = 'Full Content'; await expect(firstOption).toContainText(firstOptionText); @@ -154,13 +214,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { // Click on the first option await firstOption.click(); - await ionPopoverDidDismiss.next(); + await ionModalDidDismiss.next(); // Verify that the select text includes the option text const selectText = await select.locator('.select-text').textContent(); expect(selectText).toContain(firstOptionText); - expect(selectText).toContain(spanText); // Verify that the select text does not include the avatar and badge const selectTextAvatar = select.locator('.select-text ion-avatar'); @@ -170,20 +229,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(selectTextBadge).toHaveCount(0); }); - test('it should render for popover interface and multiple selection', async ({ page }) => { - const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); - const ionPopoverDidDismiss = await page.spyOnEvent('ionPopoverDidDismiss'); + test('it should render for modal interface and multiple selection', async ({ page }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); - const select = page.locator('#popover-select-multiple'); + const select = page.locator('#modal-select-multiple'); await select.click(); - await ionPopoverDidPresent.next(); + await ionModalDidPresent.next(); - const popover = page.locator('ion-popover'); - const firstOption = popover.locator('.select-option-label').first(); + const modal = page.locator('ion-modal'); + const firstOption = modal.locator('.select-interface-option').first(); const avatar = firstOption.locator('ion-avatar'); - const spanText = await firstOption.locator('.span-style').textContent(); const firstOptionText = 'Full Content'; await expect(firstOption).toContainText(firstOptionText); @@ -193,16 +251,15 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await firstOption.click(); // Confirm the selection - const backdrop = page.locator('ion-backdrop'); - await backdrop.click({ position: { x: 10, y: 10 } }); + const cancelButton = modal.getByRole('button', { name: 'Cancel' }); - await ionPopoverDidDismiss.next(); + await cancelButton.click(); + await ionModalDidDismiss.next(); // Verify that the select text includes the option text const selectText = await select.locator('.select-text').textContent(); expect(selectText).toContain(firstOptionText); - expect(selectText).toContain(spanText); // Verify that the select text does not include the avatar and badge const selectTextAvatar = select.locator('.select-text ion-avatar'); @@ -212,20 +269,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(selectTextBadge).toHaveCount(0); }); - test('it should render for modal interface and single selection', async ({ page }) => { - const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); - const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + test('it should render for popover interface and single selection', async ({ page }) => { + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + const ionPopoverDidDismiss = await page.spyOnEvent('ionPopoverDidDismiss'); - const select = page.locator('#modal-select'); + const select = page.locator('#popover-select'); await select.click(); - await ionModalDidPresent.next(); + await ionPopoverDidPresent.next(); - const modal = page.locator('ion-modal'); - const firstOption = modal.locator('.select-option-label').first(); + const popover = page.locator('ion-popover'); + const firstOption = popover.locator('.select-interface-option').first(); const avatar = firstOption.locator('ion-avatar'); - const spanText = await firstOption.locator('.span-style').textContent(); const firstOptionText = 'Full Content'; await expect(firstOption).toContainText(firstOptionText); @@ -234,13 +290,12 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { // Click on the first option await firstOption.click(); - await ionModalDidDismiss.next(); + await ionPopoverDidDismiss.next(); // Verify that the select text includes the option text const selectText = await select.locator('.select-text').textContent(); expect(selectText).toContain(firstOptionText); - expect(selectText).toContain(spanText); // Verify that the select text does not include the avatar and badge const selectTextAvatar = select.locator('.select-text ion-avatar'); @@ -250,20 +305,19 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(selectTextBadge).toHaveCount(0); }); - test('it should render for modal interface and multiple selection', async ({ page }) => { - const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); - const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + test('it should render for popover interface and multiple selection', async ({ page }) => { + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + const ionPopoverDidDismiss = await page.spyOnEvent('ionPopoverDidDismiss'); - const select = page.locator('#modal-select-multiple'); + const select = page.locator('#popover-select-multiple'); await select.click(); - await ionModalDidPresent.next(); + await ionPopoverDidPresent.next(); - const modal = page.locator('ion-modal'); - const firstOption = modal.locator('.select-option-label').first(); + const popover = page.locator('ion-popover'); + const firstOption = popover.locator('.select-interface-option').first(); const avatar = firstOption.locator('ion-avatar'); - const spanText = await firstOption.locator('.span-style').textContent(); const firstOptionText = 'Full Content'; await expect(firstOption).toContainText(firstOptionText); @@ -273,16 +327,15 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await firstOption.click(); // Confirm the selection - const cancelButton = modal.getByRole('button', { name: 'Cancel' }); + const backdrop = page.locator('ion-backdrop'); + await backdrop.click({ position: { x: 10, y: 10 } }); - await cancelButton.click(); - await ionModalDidDismiss.next(); + await ionPopoverDidDismiss.next(); // Verify that the select text includes the option text const selectText = await select.locator('.select-text').textContent(); expect(selectText).toContain(firstOptionText); - expect(selectText).toContain(spanText); // Verify that the select text does not include the avatar and badge const selectTextAvatar = select.locator('.select-text ion-avatar'); @@ -302,13 +355,13 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await ionAlertDidPresent.next(); + // The "no-text" option only has a for its label content, + // so its aria label should be the span's plain text. const alert = page.locator('ion-alert'); - const firstOption = alert.locator('.alert-radio-label').first(); + const spanOption = alert.locator('.alert-radio-button', { hasText: 'This is a span element' }); - // Click on the first option - await firstOption.click(); + await spanOption.click(); - // Confirm the selection const confirmButton = alert.locator('.alert-button:not(.alert-button-role-cancel)'); await confirmButton.click(); @@ -317,7 +370,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { const nativeButton = select.locator('button'); const ariaLabel = await nativeButton.getAttribute('aria-label'); - expect(ariaLabel).toContain('Full Content This is a span element'); + expect(ariaLabel).toContain('This is a span element'); }); }); }); @@ -341,7 +394,7 @@ configs({ modes: ['md'] }).forEach(({ title, config }) => { await ionAlertDidPresent.next(); const alert = page.locator('ion-alert'); - const firstOption = alert.locator('.alert-radio-label').first(); + const firstOption = alert.locator('.alert-radio-button').first(); const startContainer = firstOption.locator('.select-option-start'); const endContainer = firstOption.locator('.select-option-end'); @@ -404,7 +457,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await ionAlertDidPresent.next(); const alert = page.locator('ion-alert'); - const firstOption = alert.locator('.alert-radio-label').first(); + const firstOption = alert.locator('.alert-radio-button').first(); const startContainer = firstOption.locator('.select-option-start'); const endContainer = firstOption.locator('.select-option-end'); const span = firstOption.locator('.span-style'); diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..75ff364fd3e Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..36c430cd23e Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..1995450a8e3 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..7503888f67f Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..d6625ccf422 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..2c5204debbb Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..77e92a499c8 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..2e713a5f737 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-md-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..684348ecc9d Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-action-sheet-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..e13b18f1370 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..f6e54e16005 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..3829c45f8fa Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..9912c6cdba8 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..ecf6fbee848 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..2973921a7d7 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..c5dec9c7ffa Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..9c702c6e995 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-md-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..f6fc54e221d Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-alert-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..1b316f262e4 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..1f4b4f3e867 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..7d1a658edda Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..41240e2098e Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..2099bc63068 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..fcadf4bbcc8 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..aa5a3fa1172 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..404974a14d7 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-md-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..d0622ec6607 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-modal-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ionic-md-ltr-light-Mobile-Chrome-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ionic-md-ltr-light-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..c4cbc725bca Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ionic-md-ltr-light-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ionic-md-ltr-light-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..50469bb8ae4 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ionic-md-ltr-light-Mobile-Safari-linux.png new file mode 100644 index 00000000000..d3bb47a4dc9 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..21b5f31c823 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..e816445d8fc Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..cdfc43a9069 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..b13d3dee92d Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..cce6a40bcc0 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-md-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..05a5e779c19 Binary files /dev/null and b/core/src/components/select/test/rich-content-option/select.e2e.ts-snapshots/select-rich-content-popover-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/select.spec.tsx b/core/src/components/select/test/select.spec.tsx index f00c4c66884..ad7c0d3d050 100644 --- a/core/src/components/select/test/select.spec.tsx +++ b/core/src/components/select/test/select.spec.tsx @@ -1,6 +1,8 @@ import { h } from '@stencil/core'; import { newSpecPage } from '@stencil/core/testing'; +import { config } from '../../../global/config'; +import { SelectOption } from '../../select-option/select-option'; import { Select } from '../select'; describe('ion-select', () => { @@ -157,3 +159,56 @@ describe('ion-select: required', () => { expect(nativeButton.getAttribute('aria-required')).toBe('false'); }); }); + +describe('ion-select: option content property reflection', () => { + beforeEach(() => { + // Cloning rich option content into the select text only happens when + // custom HTML rendering is enabled. + config.reset({ innerHTMLTemplatesEnabled: true }); + }); + + afterEach(() => { + config.reset({}); + }); + + it('should reflect ion-icon DOM properties onto attributes so they survive cloning into the select text', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `Star`, + }); + + const select = page.body.querySelector('ion-select')!; + const sourceIcon = select.querySelector('ion-icon')!; + + /** + * Frameworks such as Vue set `icon` as a DOM property rather than an + * attribute. `cloneNode` only copies attributes, so without reflection + * the cloned copy in the select text would lose the icon value. + */ + (sourceIcon as any).icon = 'logo-ionic'; + + // Selecting the option rebuilds the displayed text from the option content. + select.value = 'star'; + await page.waitForChanges(); + + const renderedIcon = select.shadowRoot!.querySelector('.select-text ion-icon'); + expect(renderedIcon).not.toBeNull(); + expect(renderedIcon!.getAttribute('icon')).toBe('logo-ionic'); + }); + + it('should preserve an ion-icon attribute that is already set when cloning into the select text', async () => { + const page = await newSpecPage({ + components: [Select, SelectOption], + html: `Star`, + }); + + const select = page.body.querySelector('ion-select')!; + + select.value = 'star'; + await page.waitForChanges(); + + const renderedIcon = select.shadowRoot!.querySelector('.select-text ion-icon'); + expect(renderedIcon).not.toBeNull(); + expect(renderedIcon!.getAttribute('icon')).toBe('logo-ionic'); + }); +}); diff --git a/core/src/components/tab-bar/test/basic/tab-bar.e2e.ts-snapshots/tab-bar-default-ionic-md-ltr-light-Mobile-Firefox-linux.png b/core/src/components/tab-bar/test/basic/tab-bar.e2e.ts-snapshots/tab-bar-default-ionic-md-ltr-light-Mobile-Firefox-linux.png index d37dda904ff..feeebdf4cd6 100644 Binary files a/core/src/components/tab-bar/test/basic/tab-bar.e2e.ts-snapshots/tab-bar-default-ionic-md-ltr-light-Mobile-Firefox-linux.png and b/core/src/components/tab-bar/test/basic/tab-bar.e2e.ts-snapshots/tab-bar-default-ionic-md-ltr-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/tab-bar/test/basic/tab-bar.e2e.ts-snapshots/tab-bar-default-ionic-md-rtl-light-Mobile-Firefox-linux.png b/core/src/components/tab-bar/test/basic/tab-bar.e2e.ts-snapshots/tab-bar-default-ionic-md-rtl-light-Mobile-Firefox-linux.png index 25508f2990f..53277af1f83 100644 Binary files a/core/src/components/tab-bar/test/basic/tab-bar.e2e.ts-snapshots/tab-bar-default-ionic-md-rtl-light-Mobile-Firefox-linux.png and b/core/src/components/tab-bar/test/basic/tab-bar.e2e.ts-snapshots/tab-bar-default-ionic-md-rtl-light-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toast/test/a11y/index.html b/core/src/components/toast/test/a11y/index.html index 58157330fb1..9651f056386 100644 --- a/core/src/components/toast/test/a11y/index.html +++ b/core/src/components/toast/test/a11y/index.html @@ -10,7 +10,7 @@ diff --git a/core/src/components/toast/test/a11y/toast.e2e.ts b/core/src/components/toast/test/a11y/toast.e2e.ts index 23cdd9501e2..2e0cfd85a2f 100644 --- a/core/src/components/toast/test/a11y/toast.e2e.ts +++ b/core/src/components/toast/test/a11y/toast.e2e.ts @@ -34,7 +34,7 @@ configs({ directions: ['ltr'], palettes: ['dark', 'light'] }).forEach(({ title, await page.setContent( ` diff --git a/core/src/components/toast/test/basic/index.html b/core/src/components/toast/test/basic/index.html index 5f9d030b706..c392a2727de 100644 --- a/core/src/components/toast/test/basic/index.html +++ b/core/src/components/toast/test/basic/index.html @@ -14,7 +14,7 @@ diff --git a/core/src/components/toast/test/basic/toast.e2e.ts-snapshots/toast-start-end-ionic-md-ltr-light-Mobile-Safari-linux.png b/core/src/components/toast/test/basic/toast.e2e.ts-snapshots/toast-start-end-ionic-md-ltr-light-Mobile-Safari-linux.png index 40d1d7119c8..dcc929c282b 100644 Binary files a/core/src/components/toast/test/basic/toast.e2e.ts-snapshots/toast-start-end-ionic-md-ltr-light-Mobile-Safari-linux.png and b/core/src/components/toast/test/basic/toast.e2e.ts-snapshots/toast-start-end-ionic-md-ltr-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/toast/test/basic/toast.e2e.ts-snapshots/toast-start-end-ionic-md-rtl-light-Mobile-Safari-linux.png b/core/src/components/toast/test/basic/toast.e2e.ts-snapshots/toast-start-end-ionic-md-rtl-light-Mobile-Safari-linux.png index 52f70ce258c..dd725236295 100644 Binary files a/core/src/components/toast/test/basic/toast.e2e.ts-snapshots/toast-start-end-ionic-md-rtl-light-Mobile-Safari-linux.png and b/core/src/components/toast/test/basic/toast.e2e.ts-snapshots/toast-start-end-ionic-md-rtl-light-Mobile-Safari-linux.png differ diff --git a/core/src/components/toast/test/buttons/index.html b/core/src/components/toast/test/buttons/index.html index 42aa9a5554a..964480689c6 100644 --- a/core/src/components/toast/test/buttons/index.html +++ b/core/src/components/toast/test/buttons/index.html @@ -14,7 +14,7 @@ diff --git a/core/src/components/toast/test/layout/index.html b/core/src/components/toast/test/layout/index.html index 227539c7369..5f435d6e1d3 100644 --- a/core/src/components/toast/test/layout/index.html +++ b/core/src/components/toast/test/layout/index.html @@ -14,7 +14,7 @@ diff --git a/core/src/components/toast/test/standalone/index.html b/core/src/components/toast/test/standalone/index.html index 82b4e108522..11f2565d674 100644 --- a/core/src/components/toast/test/standalone/index.html +++ b/core/src/components/toast/test/standalone/index.html @@ -21,7 +21,7 @@ diff --git a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-ios-ltr-Mobile-Safari-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-ios-ltr-Mobile-Safari-linux.png index a48f114c2e3..c20935c06b8 100644 Binary files a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-in-item-focus-ios-ltr-Mobile-Safari-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-in-item-focus-ios-ltr-Mobile-Safari-linux.png index 50bca49651c..19bd30c2be0 100644 Binary files a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-in-item-focus-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-in-item-focus-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-ltr-Mobile-Chrome-linux.png index 41868dc7a7c..e0329b36125 100644 Binary files a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-ltr-dark-Mobile-Chrome-linux.png b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-ltr-dark-Mobile-Chrome-linux.png index 40cf9acb46b..1b9ce49c285 100644 Binary files a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-ltr-dark-Mobile-Chrome-linux.png and b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-ltr-dark-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-rtl-Mobile-Chrome-linux.png index 6451895309f..2fda73f438f 100644 Binary files a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-rtl-Mobile-Chrome-linux.png and b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-rtl-dark-Mobile-Chrome-linux.png b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-rtl-dark-Mobile-Chrome-linux.png index 5d091d0cf3f..cff9195471b 100644 Binary files a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-rtl-dark-Mobile-Chrome-linux.png and b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-color-ios-rtl-dark-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-ltr-Mobile-Chrome-linux.png index 325ce8a11ba..45d2af6358e 100644 Binary files a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-ltr-dark-Mobile-Chrome-linux.png b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-ltr-dark-Mobile-Chrome-linux.png index fb5eaa52dca..1a0137ea7e1 100644 Binary files a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-ltr-dark-Mobile-Chrome-linux.png and b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-ltr-dark-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-rtl-Mobile-Chrome-linux.png index 200fb61bbc3..9248bcf5636 100644 Binary files a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-rtl-Mobile-Chrome-linux.png and b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-rtl-dark-Mobile-Chrome-linux.png b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-rtl-dark-Mobile-Chrome-linux.png index 1cf8830d5fb..f4949337d98 100644 Binary files a/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-rtl-dark-Mobile-Chrome-linux.png and b/core/src/components/toggle/test/enable-on-off-labels/toggle.e2e.ts-snapshots/toggle-on-off-labels-ios-rtl-dark-Mobile-Chrome-linux.png differ diff --git a/core/src/global/config.ts b/core/src/global/config.ts index d9795de6495..9fcfdb43219 100644 --- a/core/src/global/config.ts +++ b/core/src/global/config.ts @@ -30,7 +30,7 @@ export class Config { if (firstKey.startsWith('Ion')) { const customTheme = this.m.get('customTheme'); - root = customTheme ? customTheme.config?.components[firstKey as keyof IonicConfig] : undefined; + root = customTheme?.config?.components?.[firstKey as keyof IonicConfig]; } else { // Otherwise, get the value directly from the global config root = this.m.get(firstKey as keyof IonicConfig); diff --git a/core/src/global/test/config-controller.spec.ts b/core/src/global/test/config-controller.spec.ts index 0be7fec0aea..537a9f0dccf 100644 --- a/core/src/global/test/config-controller.spec.ts +++ b/core/src/global/test/config-controller.spec.ts @@ -83,6 +83,36 @@ describe('Config', () => { expect(config.get('text0' as any, 'HEY')).toEqual('hola'); }); + describe('getObjectValue', () => { + it('should read a nested component value from the custom theme', () => { + const config = new Config(); + config.set('customTheme', { config: { components: { IonBadge: { size: 'large' } } } } as any); + expect(config.getObjectValue('IonBadge.size', 'small')).toEqual('large'); + }); + + it('should return the fallback when the custom theme has no components', () => { + /** + * The base theme config only carries global values (e.g. formHighlight) + * and has no `components` key. Reading a component value must fall back + * gracefully instead of throwing. + */ + const config = new Config(); + config.set('customTheme', { config: { formHighlight: false } } as any); + expect(config.getObjectValue('IonBadge.size', 'small')).toEqual('small'); + }); + + it('should return the fallback when the custom theme is unset', () => { + const config = new Config(); + expect(config.getObjectValue('IonBadge.size', 'small')).toEqual('small'); + }); + + it('should read a non-component value directly from the global config', () => { + const config = new Config(); + config.set('mode' as any, 'ios'); + expect(config.getObjectValue('mode')).toEqual('ios'); + }); + }); + it('should not throw an exception with a malformed URI', () => { // https://github.com/ionic-team/ionic-framework/issues/29479 diff --git a/core/src/utils/css-value-validation.ts b/core/src/utils/css-value-validation.ts index b53c933ae85..e82e9c6a7a1 100644 --- a/core/src/utils/css-value-validation.ts +++ b/core/src/utils/css-value-validation.ts @@ -9,6 +9,10 @@ const LENGTH_PERCENTAGE_PATTERN = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:%|[a-z]+)$/i; // Matches simple `calc` / `min` / `max` / `clamp(...)` functions. const MATH_FUNCTION_PATTERN = /^(calc|min|max|clamp)\s*\(.+\)$/i; +// Matches a `var(--name)` reference with an optional fallback, e.g. +// `var(--my-gap)` or `var(--my-gap, 16px)`. +const VAR_FUNCTION_PATTERN = /^var\(\s*--[^\s,)]+\s*(?:,[\s\S]*)?\)$/i; + /** * Returns whether `value` matches the [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) * syntax. Accepts `` (`` + unit identifier) or `` (`%`). @@ -24,3 +28,15 @@ export function isValidLengthPercentage(value: string): boolean { return MATH_FUNCTION_PATTERN.test(v) || LENGTH_PERCENTAGE_PATTERN.test(v); } + +/** + * Returns whether `value` is a single [`var()`](https://developer.mozilla.org/en-US/docs/Web/CSS/var) + * reference, e.g. `var(--my-token)` or `var(--my-token, 16px)`. The referenced + * custom property is resolved by the browser, so the resolved value is not + * validated here. + * + * @param value String value to validate. + */ +export function isCssVariable(value: string): boolean { + return VAR_FUNCTION_PATTERN.test(value.trim()); +} diff --git a/core/src/utils/helpers.ts b/core/src/utils/helpers.ts index ba02b61d8cc..1c95a1c9ae8 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -314,6 +314,20 @@ export const focusVisibleElement = (el: HTMLElement) => { } }; +/** + * Clears the keyboard focus ring (`ion-focused`) that the focus-visible + * utility may have applied to elements during a programmatic focus. + * + * Use this after moving DOM focus to an element for assistive technologies + * (e.g. when an overlay opens) where the option should be focused so screen + * readers announce it, but the focus ring should not be shown until the user + * navigates with the keyboard (Tab/Arrow). The focus-visible utility will add + * `ion-focused` again on the next keyboard-driven focus change. + */ +export const suppressFocusVisible = () => { + focusElements([]); +}; + /** * This method is used to add a hidden input to a host element that contains * a Shadow DOM. It does not add the input inside of the Shadow root which diff --git a/core/src/utils/overlay-control-label.ts b/core/src/utils/overlay-control-label.ts new file mode 100644 index 00000000000..cc95d41f010 --- /dev/null +++ b/core/src/utils/overlay-control-label.ts @@ -0,0 +1,58 @@ +// TODO(FW-6886, FW-6892, FW-6891): Remove this file in favor of the Modular Ionic component config. Each overlay will be able to select its own defaults for label placement and justify based on the interface and theme, so this utility will no longer be necessary. + +import type { Theme } from '../interface'; + +/** + * Returns the default `labelPlacement` for a radio or checkbox option + * rendered inside an overlay. Defaults follow each theme's established + * option-row layout: + * - `ionic`: always `"start"`. + * - `ios`: `"start"` for radio in `alert` and `popover`. The `modal` + * interface flips iOS radio back to `"end"`. Checkbox is always + * `"end"` on iOS. + * - everything else (e.g. `md`): `"end"`. + * + * `interfaceType` is optional; only `"modal"` changes the result, so + * callers that aren't a modal can omit it. + * + * Used by `alert`, `select-popover`, and `select-modal` as the fallback + * when an option doesn't explicitly set `labelPlacement`. + */ +export const getOverlayLabelPlacement = ( + theme: Theme, + control: 'radio' | 'checkbox', + interfaceType?: 'alert' | 'popover' | 'modal' +): 'start' | 'end' => { + if (theme === 'ionic' || (theme === 'ios' && control === 'radio' && interfaceType !== 'modal')) { + return 'start'; + } + + return 'end'; +}; + +/** + * Returns the default `justify` for a radio or checkbox option rendered + * inside an overlay. Defaults follow each theme's option-row layout: + * - `ionic`: always `"space-between"`. + * - `ios`: `"space-between"` for radio in `alert` and `popover`. The + * `modal` interface falls back to `"start"`. Checkbox is always `"start"` + * on iOS. + * - everything else (e.g. `md`): `"start"`. + * + * `interfaceType` is optional; only `"modal"` changes the result, so + * callers that aren't a modal can omit it. + * + * Used by `alert`, `select-popover`, and `select-modal` as the fallback when + * an option doesn't explicitly set `justify`. + */ +export const getOverlayLabelJustify = ( + theme: Theme, + control: 'radio' | 'checkbox', + interfaceType?: 'alert' | 'popover' | 'modal' +): 'start' | 'end' | 'space-between' => { + if (theme === 'ionic' || (theme === 'ios' && control === 'radio' && interfaceType !== 'modal')) { + return 'space-between'; + } + + return 'start'; +}; diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 764f2e53a08..541347cb6a7 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -44,8 +44,133 @@ type OverlayWithFocusTrapProps = HTMLIonOverlayElement & { backdropBreakpoint?: number; }; +const OVERLAY_FOCUS_TRAP_SELECTOR = 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker-legacy,ion-popover'; + +/** + * The focus trap is generic and knows nothing about which components are + * inside an overlay. Components mark their elements with these `data-*` + * attributes, which the trap reads, to control how Tab moves through them. + * This keeps private component class names (e.g. `.modal-handle`, + * `.action-sheet-button`) out of the trap, so changing a component does + * not silently break Tab order. + * + * - `data-focus-ignore`: skip this element as a tab stop. + * - `data-focus-order`: explicit numeric placement in the tab sequence. + * - `data-roving-focus`: a container whose focusable descendants share a + * single tab stop via a roving tabindex. + */ +const FOCUS_IGNORE_SELECTOR = '[data-focus-ignore]'; +const FOCUS_ORDER_SELECTOR = '[data-focus-order]'; +const ROVING_FOCUS_SELECTOR = '[data-roving-focus]'; + +/** + * Used to restore focus to the correct member when Tab would land on + * a roving group's tabbable slot (e.g. Shift+Tab from the sheet handle + * back into the option list). + */ +type OverlayWithRovingFocusState = HTMLIonOverlayElement & { + trapLastRovingFocus?: HTMLElement; +}; + +/** + * Returns the roving-focus group an element belongs to, or `null`. + * A member is a descendant of a `[data-roving-focus]` container. The + * container itself is not a member. Members share a single tab stop. + */ +const getRovingFocusGroup = (el: HTMLElement): HTMLElement | null => { + const group = el.closest(ROVING_FOCUS_SELECTOR); + return group !== null && group !== el ? group : null; +}; + +/** + * Whether an element participates in a roving-focus group. + */ +const isRovingFocusItem = (el: HTMLElement) => getRovingFocusGroup(el) !== null; + +/** + * Returns the currently focused element for keyboard focus checks. + * + * Starts from `document.activeElement` (non-shadow / light DOM focus). + * If focus is inside one or more open shadow roots + * (e.g. native control inside `ion-radio`), walks through nested + * `shadowRoot.activeElement` values until the innermost focused node is reached. + */ +const getActiveElement = (ownerDoc: Document): HTMLElement | null => { + let active = ownerDoc.activeElement as HTMLElement | null; + if (!active) { + return null; + } + while (active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement as HTMLElement; + } + return active; +}; + +/** + * Walks from a focused node (possibly deep inside shadow roots) up to + * the nearest roving-focus host (e.g. `ion-radio` inside an + * `ion-radio-group`, or an action sheet `'; + + sanitizeDOMTree(root); + + const button = root.querySelector('button')!; + expect(button.hasAttribute('onclick')).toBe(false); + expect(button.hasAttribute('onmouseover')).toBe(false); + }); + + it('should strip javascript: URLs while keeping the element', () => { + const root = document.createElement('div'); + root.innerHTML = 'link'; + + sanitizeDOMTree(root); + + const anchor = root.querySelector('a')!; + expect(anchor).not.toBeNull(); + expect(anchor.hasAttribute('href')).toBe(false); + }); + + it('should preserve component attributes like size, color, and shape', () => { + const root = document.createElement('div'); + root.innerHTML = 'button'; + + sanitizeDOMTree(root); + + const button = root.querySelector('ion-button')!; + expect(button.getAttribute('size')).toBe('small'); + expect(button.getAttribute('color')).toBe('primary'); + expect(button.getAttribute('shape')).toBe('round'); + }); + + it('should strip the style attribute', () => { + const root = document.createElement('div'); + root.innerHTML = 'text'; + + sanitizeDOMTree(root); + + expect(root.querySelector('span')!.hasAttribute('style')).toBe(false); + }); + + it('should strip form/navigation-hijack attributes', () => { + const root = document.createElement('div'); + root.innerHTML = ''; + + sanitizeDOMTree(root); + + const button = root.querySelector('button')!; + expect(button.hasAttribute('formaction')).toBe(false); + expect(button.hasAttribute('action')).toBe(false); + expect(button.hasAttribute('target')).toBe(false); + }); + + it('should preserve inline SVG presentation attributes', () => { + const root = document.createElement('div'); + root.innerHTML = + '' + + ''; + + sanitizeDOMTree(root); + + const svg = root.querySelector('svg')!; + expect(svg.getAttribute('viewBox')).toBe('0 0 24 24'); + expect(svg.getAttribute('width')).toBe('24'); + const circle = root.querySelector('circle')!; + expect(circle.getAttribute('cx')).toBe('12'); + expect(circle.getAttribute('r')).toBe('10'); + expect(circle.getAttribute('fill')).toBe('red'); + expect(circle.getAttribute('stroke-width')).toBe('2'); + }); + + it('should preserve aria-* and data-* attributes', () => { + const root = document.createElement('div'); + root.innerHTML = ''; + + sanitizeDOMTree(root); + + const icon = root.querySelector('ion-icon')!; + expect(icon.getAttribute('aria-hidden')).toBe('true'); + expect(icon.getAttribute('data-value')).toBe('star'); + }); + + it('should strip namespaced attributes such as xlink:href', () => { + const root = document.createElement('div'); + root.innerHTML = 'x'; + + sanitizeDOMTree(root); + + const anchor = root.querySelector('a')!; + expect(anchor.hasAttribute('xlink:href')).toBe(false); + }); + + it('should strip entity-obfuscated javascript: schemes', () => { + const root = document.createElement('div'); + // The parser decodes to a tab, hiding the scheme from a naive + // substring check; normalization must still catch it. + root.innerHTML = 'link'; + + sanitizeDOMTree(root); + + expect(root.querySelector('a')!.hasAttribute('href')).toBe(false); + }); + + it('should strip vbscript: schemes', () => { + const root = document.createElement('div'); + root.innerHTML = 'link'; + + sanitizeDOMTree(root); + + expect(root.querySelector('a')!.hasAttribute('href')).toBe(false); + }); + + it('should keep safe image data: URIs', () => { + const root = document.createElement('div'); + const png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNgAAAAAgAB'; + root.innerHTML = ``; + + sanitizeDOMTree(root); + + expect(root.querySelector('img')!.getAttribute('src')).toBe(png); + }); + + it('should strip document-bearing data: URIs', () => { + const root = document.createElement('div'); + root.innerHTML = + 'html' + + ''; + + sanitizeDOMTree(root); + + expect(root.querySelector('a')!.hasAttribute('href')).toBe(false); + expect(root.querySelector('img')!.hasAttribute('src')).toBe(false); + }); + + it('should be a no-op when the sanitizer is disabled', () => { + enableSanitizer(false); + const root = document.createElement('div'); + root.innerHTML = ''; + + sanitizeDOMTree(root); + + expect(root.querySelector('script')).not.toBeNull(); + expect(root.querySelector('button')!.hasAttribute('onclick')).toBe(true); + }); +}); + +describe('reflectPropertiesToAttributes', () => { + it('should reflect a known DOM property onto its attribute', () => { + const icon = document.createElement('ion-icon'); + (icon as any).name = 'star'; + + reflectPropertiesToAttributes(icon); + + expect(icon.getAttribute('name')).toBe('star'); + }); + + it('should reflect properties on a nested element', () => { + const root = document.createElement('div'); + const icon = document.createElement('ion-icon'); + (icon as any).icon = 'logo-ionic'; + root.appendChild(icon); + + reflectPropertiesToAttributes(root); + + expect(icon.getAttribute('icon')).toBe('logo-ionic'); + }); + + it('should not overwrite an attribute that is already present', () => { + const icon = document.createElement('ion-icon'); + icon.setAttribute('name', 'existing'); + (icon as any).name = 'from-property'; + + reflectPropertiesToAttributes(icon); + + expect(icon.getAttribute('name')).toBe('existing'); + }); + + it('should ignore empty-string and non-string property values', () => { + const icon = document.createElement('ion-icon'); + (icon as any).name = ''; + (icon as any).icon = 42; + + reflectPropertiesToAttributes(icon); + + expect(icon.hasAttribute('name')).toBe(false); + expect(icon.hasAttribute('icon')).toBe(false); + }); + + it('should leave elements without reflected properties untouched', () => { + const div = document.createElement('div'); + (div as any).name = 'value'; + + reflectPropertiesToAttributes(div); + + expect(div.hasAttribute('name')).toBe(false); + }); +}); + const enableSanitizer = (enable = true) => { (window as any).Ionic = {}; (window as any).Ionic.config = {}; diff --git a/core/src/utils/select-option-render.tsx b/core/src/utils/select-option-render.tsx index a8e11e3302f..e9fa1f59da6 100644 --- a/core/src/utils/select-option-render.tsx +++ b/core/src/utils/select-option-render.tsx @@ -1,9 +1,9 @@ +import type { VNode } from '@stencil/core'; import { h } from '@stencil/core'; +import { sanitizeDOMTree } from '@utils/sanitization'; import type { RichContentOption as RichContentOpt } from '../components/select/select-interface'; -import { sanitizeDOMString } from './sanitization'; - interface RichContentOption extends RichContentOpt { /** Unique identifier for stable virtual DOM keys across re-renders. */ id: string; @@ -12,18 +12,66 @@ interface RichContentOption extends RichContentOpt { } /** - * Cache that maps rendered span elements to the source HTMLElement - * they were cloned from. This prevents flickering when a user - * selects an option that has rich content, as the content will only be - * re-rendered if the source HTMLElement changes. + * Converts a DOM node into a Stencil VNode (or text string) so the + * resulting tree is rendered through the component's normal render + * path. Rendering through Stencil ensures that scoped CSS classes + * (e.g. `sc-ion-action-sheet-ionic`) are applied to every element. + * + * Highly recommended to pre-sanitize the source DOM (see + * `getOptionContent` in select.tsx). This function performs pure + * structural conversion — no security filtering. + * + * Preserves attributes only — properties set imperatively on the source + * element (e.g. `input.value` after a user types) won't carry through. + * In practice this isn't a concern: interactive controls shouldn't + * appear in select-option rich content since they'd nest inside the + * overlay's button/radio/checkbox wrapper, which is invalid HTML and + * an accessibility issue. + * + * @param node - The DOM node to convert. Text nodes become strings, + * element nodes become VNodes, and any other node types are skipped. + * @param keyPrefix - String prefix used to build a stable VNode key, + * so Stencil's diff can preserve elements across re-renders. + * @param index - Position of this node among its siblings. Combined + * with `keyPrefix` to form the final unique key. + * @returns The converted VNode, a text string, or `null` if the node + * type isn't supported. + * + * @internal Exported only so it can be unit tested; not part of the + * public API. */ -const contentCache = new WeakMap(); +export const cloneToVNode = (node: Node, keyPrefix: string, index: number): VNode | string | null => { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent ?? ''; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return null; + } + + const el = node as Element; + const tag = el.tagName.toLowerCase(); + const key = `${keyPrefix}-${index}`; + + const attrs: Record = { key }; + for (let i = 0; i < el.attributes.length; i++) { + const attr = el.attributes.item(i)!; + attrs[attr.name] = attr.value; + } + + const children = Array.from(el.childNodes) + .map((child, i) => cloneToVNode(child, key, i)) + .filter((c): c is VNode | string => c !== null); + + return h(tag as any, attrs, children as any); +}; /** - * Renders cloned DOM content into an element via a ref callback. - * The content is only cloned when the source element changes, - * preventing flicker caused by destroying and recreating web - * components (e.g., ion-avatar) on every re-render cycle. + * Renders cloned DOM content as Stencil JSX. Walking the source DOM + * into VNodes (rather than injecting it via innerHTML) keeps the + * content inside Stencil's render path, so scoped CSS classes are + * applied automatically and component attributes like `size` or + * `color` survive intact. * * Span elements should be used when this content renders within buttons, * depending on the select interface. Buttons can only have phrasing @@ -31,29 +79,19 @@ const contentCache = new WeakMap(); * * @param id - Unique identifier for generating stable virtual DOM keys. * @param content - The HTMLElement container whose child nodes will be cloned. - * @param className - CSS class applied to the wrapper span. + * @param className - CSS class applied to the wrapper element. * @param useSpan - Whether to use a span element instead of a div for the wrapper. */ const renderClonedContent = (id: string, content: HTMLElement, className: string, useSpan = false) => { const Tag = useSpan ? 'span' : 'div'; + const keyPrefix = `${className}-${id}`; + + sanitizeDOMTree(content); + return ( - { - if (el) { - const cached = contentCache.get(el); - // Skip if this element already has clones from the same source - if (cached === content) { - return; - } - - const sanitized = sanitizeDOMString(content.innerHTML); - el.innerHTML = sanitized ?? ''; - contentCache.set(el, content); - } - }} - > + + {Array.from(content.childNodes).map((child, i) => cloneToVNode(child, keyPrefix, i))} + ); }; @@ -109,7 +147,7 @@ export const renderOptionLabel = ( // Render label with rich content (start, end, description) return ( - + {startContent && renderClonedContent(id, startContent, 'select-option-start', useSpan)} {labelEl} diff --git a/core/src/utils/test/playwright/page/utils/goto.ts b/core/src/utils/test/playwright/page/utils/goto.ts index 763a0718108..9356a513bea 100644 --- a/core/src/utils/test/playwright/page/utils/goto.ts +++ b/core/src/utils/test/playwright/page/utils/goto.ts @@ -99,7 +99,16 @@ configs().forEach(({ config, title }) => { }); const result = await Promise.all([ - page.waitForFunction(() => (window as any).testAppLoaded === true, { timeout: 4750 }), + /* + * Wait for the app to load and for any asynchronous theme token loading to + * finish injecting the component theme CSS. `__ionicTestThemeReady` is only + * set by the test scripts that load themes; when it is undefined (e.g. pages + * that don't run those scripts) the `!== false` check lets the test proceed. + */ + page.waitForFunction( + () => (window as any).testAppLoaded === true && (window as any).__ionicTestThemeReady !== false, + { timeout: 4750 } + ), originalFn(formattedUrl, options), ]); diff --git a/core/src/utils/test/select-option-render.spec.tsx b/core/src/utils/test/select-option-render.spec.tsx new file mode 100644 index 00000000000..d6b1cb8dc07 --- /dev/null +++ b/core/src/utils/test/select-option-render.spec.tsx @@ -0,0 +1,170 @@ +import type { VNode } from '@stencil/core'; + +import { cloneToVNode, renderOptionLabel } from '../select-option-render'; + +/** + * `cloneToVNode` returns Stencil's internal VNode object, whose fields are + * name-mangled (`$tag$`, `$attrs$`, etc.). Casting to this shape keeps the + * assertions readable without depending on the public `VNode` type, which + * does not expose those runtime fields. + */ +interface RuntimeVNode { + $tag$: string | null; + $text$: string | null; + $attrs$: Record | null; + $children$: RuntimeVNode[] | null; + $key$: string | null; +} + +const asVNode = (value: VNode | string | null): RuntimeVNode => value as unknown as RuntimeVNode; + +describe('cloneToVNode', () => { + describe('text nodes', () => { + it('should return the text content of a text node as a string', () => { + const node = document.createTextNode('hello world'); + + expect(cloneToVNode(node, 'prefix', 0)).toBe('hello world'); + }); + + it('should return an empty string when text content is empty', () => { + const node = document.createTextNode(''); + + expect(cloneToVNode(node, 'prefix', 0)).toBe(''); + }); + }); + + describe('unsupported nodes', () => { + it('should return null for a comment node', () => { + const node = document.createComment('a comment'); + + expect(cloneToVNode(node, 'prefix', 0)).toBeNull(); + }); + }); + + describe('element nodes', () => { + it('should convert an element to a VNode with the lowercased tag name', () => { + const el = document.createElement('SPAN'); + + const vnode = asVNode(cloneToVNode(el, 'prefix', 0)); + + expect(vnode.$tag$).toBe('span'); + }); + + it('should build a stable key from the prefix and index', () => { + const el = document.createElement('div'); + + const vnode = asVNode(cloneToVNode(el, 'prefix', 3)); + + expect(vnode.$key$).toBe('prefix-3'); + expect(vnode.$attrs$?.key).toBe('prefix-3'); + }); + + it('should copy all attributes from the source element', () => { + const el = document.createElement('span'); + el.setAttribute('class', 'foo bar'); + el.setAttribute('data-value', '42'); + el.setAttribute('aria-label', 'label'); + + const vnode = asVNode(cloneToVNode(el, 'prefix', 0)); + + expect(vnode.$attrs$).toEqual({ + key: 'prefix-0', + class: 'foo bar', + 'data-value': '42', + 'aria-label': 'label', + }); + }); + + it('should recursively convert child element nodes', () => { + const el = document.createElement('div'); + el.appendChild(document.createElement('span')); + el.appendChild(document.createElement('img')); + + const vnode = asVNode(cloneToVNode(el, 'prefix', 0)); + + expect(vnode.$children$).toHaveLength(2); + expect(vnode.$children$?.[0].$tag$).toBe('span'); + expect(vnode.$children$?.[1].$tag$).toBe('img'); + }); + + it('should derive child keys from the parent key', () => { + const el = document.createElement('div'); + el.appendChild(document.createElement('span')); + + const vnode = asVNode(cloneToVNode(el, 'prefix', 2)); + + // Parent key is `prefix-2`, so the first child key is `prefix-2-0` + expect(vnode.$children$?.[0].$key$).toBe('prefix-2-0'); + }); + + it('should preserve text child content', () => { + const el = document.createElement('span'); + el.appendChild(document.createTextNode('inner text')); + + const vnode = asVNode(cloneToVNode(el, 'prefix', 0)); + + // `cloneToVNode` returns the text as a string, which `h` wraps into a + // text VNode (`$tag$` null, content on `$text$`). + expect(vnode.$children$).toHaveLength(1); + expect(vnode.$children$?.[0].$tag$).toBeNull(); + expect(vnode.$children$?.[0].$text$).toBe('inner text'); + }); + + it('should filter out unsupported child nodes', () => { + const el = document.createElement('div'); + el.appendChild(document.createElement('span')); + el.appendChild(document.createComment('skip me')); + el.appendChild(document.createTextNode('keep me')); + + const vnode = asVNode(cloneToVNode(el, 'prefix', 0)); + + // The comment node is dropped, leaving the span and the text + expect(vnode.$children$).toHaveLength(2); + expect(vnode.$children$?.[0].$tag$).toBe('span'); + expect(vnode.$children$?.[1].$text$).toBe('keep me'); + }); + + it('should convert a deeply nested structure', () => { + const el = document.createElement('div'); + const child = document.createElement('span'); + const grandchild = document.createElement('strong'); + grandchild.appendChild(document.createTextNode('deep')); + child.appendChild(grandchild); + el.appendChild(child); + + const vnode = asVNode(cloneToVNode(el, 'prefix', 0)); + + const span = vnode.$children$?.[0]; + const strong = span?.$children$?.[0]; + expect(span?.$tag$).toBe('span'); + expect(strong?.$tag$).toBe('strong'); + expect(strong?.$children$?.[0].$text$).toBe('deep'); + }); + + it('should produce no children for an empty element', () => { + const el = document.createElement('div'); + + const vnode = asVNode(cloneToVNode(el, 'prefix', 0)); + + // `h` normalizes an empty children array to `null` + expect(vnode.$children$).toBeNull(); + }); + }); +}); + +describe('renderOptionLabel', () => { + it('should sanitize an HTMLElement label that bypassed ion-select', () => { + // Mirrors a vanilla JS caller passing DOM straight to an overlay, which + // never runs through `getOptionContent`'s `sanitizeDOMTree`. + const label = document.createElement('span'); + label.innerHTML = 'hi'; + + const result = asVNode(renderOptionLabel({ id: '1', label }, 'select-option') as unknown as VNode); + + const anchor = result.$children$?.[0].$children$?.[0]; + expect(anchor?.$tag$).toBe('a'); + expect(anchor?.$attrs$?.href).toBeUndefined(); + expect(anchor?.$attrs$?.onclick).toBeUndefined(); + expect(anchor?.$attrs$?.class).toBe('safe'); + }); +}); diff --git a/docs/component-guide.md b/docs/component-guide.md index aa731410d69..c1013afd7a5 100644 --- a/docs/component-guide.md +++ b/docs/component-guide.md @@ -13,6 +13,7 @@ * [Checkbox](#checkbox) * [Switch](#switch) * [Accordion](#accordion) +- [Overlay Focus Trap](#overlay-focus-trap) - [Rendering Anchor or Button](#rendering-anchor-or-button) * [Example Components](#example-components-4) * [Component Structure](#component-structure-1) @@ -645,6 +646,33 @@ In order to use the arrow keys to navigate the accordions, users must be in "Foc You can either wrap your `ion-accordion-group` in a form, or manually toggle Focus Mode using NVDA's keyboard shortcut. +## Overlay Focus Trap + +Overlays (`ion-alert`, `ion-action-sheet`, `ion-loading`, `ion-modal`, `ion-popover`) trap keyboard focus so that `Tab`/`Shift+Tab` cycle only through the controls inside the open overlay. This trap lives in [`core/src/utils/overlays.ts`](../core/src/utils/overlays.ts) and is intentionally generic. It does not reference any component's private class names or tag names. + +Instead, components mark their elements with one of three `data-*` attributes that the trap reads. This keeps the contract explicit and decoupled. Restyling or restructuring a component cannot silently break Tab order, and the trap never needs to know which components are inside it. + +| Attribute | Applies to | Example components | Description | +| --- | --- | --- | --- | +| `data-roving-focus` | the **group container** | [`ion-radio-group`](https://github.com/ionic-team/ionic-framework/tree/main/core/src/components/radio-group), [`ion-action-sheet`](https://github.com/ionic-team/ionic-framework/tree/main/core/src/components/action-sheet) | Marks a group whose focusable descendants share a single roving `tabindex`. The trap treats the whole group as one tab stop and resolves focus to its active member, so `Tab` moves on to the next control instead of cycling within the group. | +| `data-focus-order="N"` | an **individual item** whose visual order differs from DOM order | [`ion-modal`](https://github.com/ionic-team/ionic-framework/tree/main/core/src/components/modal) (drag handle), [`ion-select-modal`](https://github.com/ionic-team/ionic-framework/tree/main/core/src/components/select-modal) (Cancel button) | Gives the element an explicit position in the tab sequence. Elements without the attribute keep their visual order and are visited first; elements with it follow, sorted by ascending numeric value. | +| `data-focus-ignore` | a **focusable wrapper** that is not a real tab stop | [`ion-select-modal`](https://github.com/ionic-team/ionic-framework/tree/main/core/src/components/select-modal) (option wrappers) | Removes the element from the tab sequence. The real control inside it (the radio or checkbox) remains the tab stop. | + +_The `data-roving-focus` and `data-focus-ignore` attributes are booleans that take no value, so the bare attribute is enough. Only `data-focus-order` takes a value, which is its numeric position._ + +### Guidelines + +- Set `data-roving-focus` on the container that manages the roving tabindex (e.g. the radio group), not on each member. In a roving group only one member is tabbable at a time (`tabIndex="0"`) while the rest are `tabIndex="-1"` and skipped by the focusable query. The marker tells the trap to treat the whole group as a single Tab stop, represented by whichever member currently holds `tabIndex="0"`. +- `data-focus-order` values only need to be relative (e.g. Modal sheet handle `1`, Cancel button `2`). They are numeric so the trap stays component-agnostic. +- Use `data-focus-ignore` instead of removing `ion-focusable`/`tabindex` when an element must keep its focus styling (e.g. an `ion-item` carrying `ion-focusable` on behalf of an inner control) but should not be an independent tab stop. +- These attributes have no associated CSS, so adding them does not affect rendering. + +### References + +- [Focus-trap implementation](../core/src/utils/overlays.ts) +- [WAI-ARIA APG roving tabindex pattern](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex) + + ## Rendering Anchor or Button Certain components can render an `` or a `