diff --git a/.github/actions/publish-npm/action.yml b/.github/actions/publish-npm/action.yml index d3e06d5fb01..6913b493ef1 100644 --- a/.github/actions/publish-npm/action.yml +++ b/.github/actions/publish-npm/action.yml @@ -22,7 +22,7 @@ runs: using: 'composite' steps: - name: 🟒 Configure Node for Publish - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ inputs.node-version }} registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/actions/build-angular-server/action.yml b/.github/workflows/actions/build-angular-server/action.yml index b5d37c5a9ac..e21a39669bf 100644 --- a/.github/workflows/actions/build-angular-server/action.yml +++ b/.github/workflows/actions/build-angular-server/action.yml @@ -3,7 +3,7 @@ description: 'Build Ionic Angular Server' runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/build-angular/action.yml b/.github/workflows/actions/build-angular/action.yml index 5224f9741e0..12e1db1f5dc 100644 --- a/.github/workflows/actions/build-angular/action.yml +++ b/.github/workflows/actions/build-angular/action.yml @@ -3,7 +3,7 @@ description: 'Build Ionic Angular' runs: using: 'composite' steps: - - uses: actions/setup-node@v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/build-core-stencil-prerelease/action.yml b/.github/workflows/actions/build-core-stencil-prerelease/action.yml index e23d9119831..8cc7016349f 100644 --- a/.github/workflows/actions/build-core-stencil-prerelease/action.yml +++ b/.github/workflows/actions/build-core-stencil-prerelease/action.yml @@ -8,8 +8,8 @@ inputs: runs: using: 'composite' steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x @@ -25,6 +25,10 @@ runs: run: npm run build -- --ci --debug --verbose working-directory: ./core shell: bash + - name: πŸ” Verify Lazy Imports + run: npm run test.lazy-imports + working-directory: ./core + shell: bash - uses: ./.github/workflows/actions/upload-archive with: name: ionic-core diff --git a/.github/workflows/actions/build-core/action.yml b/.github/workflows/actions/build-core/action.yml index 7524c8a97b3..523b303139a 100644 --- a/.github/workflows/actions/build-core/action.yml +++ b/.github/workflows/actions/build-core/action.yml @@ -8,8 +8,8 @@ inputs: runs: using: 'composite' steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - name: πŸ•ΈοΈ Install Dependencies @@ -27,6 +27,10 @@ runs: run: npm run build -- --ci working-directory: ./core shell: bash + - name: πŸ” Verify Lazy Imports + run: npm run test.lazy-imports + working-directory: ./core + shell: bash - uses: ./.github/workflows/actions/upload-archive with: name: ionic-core diff --git a/.github/workflows/actions/build-react-router/action.yml b/.github/workflows/actions/build-react-router/action.yml index c8083494b0a..b104ebd429d 100644 --- a/.github/workflows/actions/build-react-router/action.yml +++ b/.github/workflows/actions/build-react-router/action.yml @@ -3,7 +3,7 @@ description: 'Build Ionic React Router' runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/build-react/action.yml b/.github/workflows/actions/build-react/action.yml index 5899335ad3e..5335ea47af2 100644 --- a/.github/workflows/actions/build-react/action.yml +++ b/.github/workflows/actions/build-react/action.yml @@ -3,7 +3,7 @@ description: 'Build Ionic React' runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/build-vue-router/action.yml b/.github/workflows/actions/build-vue-router/action.yml index 9b07ce64973..bf47bb0dec3 100644 --- a/.github/workflows/actions/build-vue-router/action.yml +++ b/.github/workflows/actions/build-vue-router/action.yml @@ -3,7 +3,7 @@ description: 'Builds Ionic Vue Router' runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/build-vue/action.yml b/.github/workflows/actions/build-vue/action.yml index 5c7497ec359..4a7375786d2 100644 --- a/.github/workflows/actions/build-vue/action.yml +++ b/.github/workflows/actions/build-vue/action.yml @@ -3,7 +3,7 @@ description: 'Build Ionic Vue' runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/download-archive/action.yml b/.github/workflows/actions/download-archive/action.yml index 2acddf9713f..97f07f8627a 100644 --- a/.github/workflows/actions/download-archive/action.yml +++ b/.github/workflows/actions/download-archive/action.yml @@ -10,7 +10,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ inputs.name }} path: ${{ inputs.path }} diff --git a/.github/workflows/actions/test-angular-e2e/action.yml b/.github/workflows/actions/test-angular-e2e/action.yml index a4835a0210a..8a15bfb5037 100644 --- a/.github/workflows/actions/test-angular-e2e/action.yml +++ b/.github/workflows/actions/test-angular-e2e/action.yml @@ -6,7 +6,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive @@ -33,7 +33,7 @@ runs: shell: bash working-directory: ./packages/angular/test/build/${{ inputs.app }} - name: πŸ“¦ Install Playwright Browsers - run: npx playwright install + run: npx playwright install chromium shell: bash working-directory: ./packages/angular/test/build/${{ inputs.app }} - name: πŸ”„ Sync Built Changes diff --git a/.github/workflows/actions/test-core-clean-build/action.yml b/.github/workflows/actions/test-core-clean-build/action.yml index 96abc90121c..25c9a202652 100644 --- a/.github/workflows/actions/test-core-clean-build/action.yml +++ b/.github/workflows/actions/test-core-clean-build/action.yml @@ -3,7 +3,7 @@ description: 'Test Core Clean Build' runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x diff --git a/.github/workflows/actions/test-core-lint/action.yml b/.github/workflows/actions/test-core-lint/action.yml index f9f0011719a..66a3036ef04 100644 --- a/.github/workflows/actions/test-core-lint/action.yml +++ b/.github/workflows/actions/test-core-lint/action.yml @@ -3,7 +3,7 @@ description: 'Test Core Lint' runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - name: πŸ•ΈοΈ Install Dependencies diff --git a/.github/workflows/actions/test-core-screenshot/action.yml b/.github/workflows/actions/test-core-screenshot/action.yml index 1f8699e66d4..379ce934edc 100644 --- a/.github/workflows/actions/test-core-screenshot/action.yml +++ b/.github/workflows/actions/test-core-screenshot/action.yml @@ -13,7 +13,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive @@ -66,7 +66,7 @@ runs: working-directory: ./core - name: πŸ“¦ Archive Updated Screenshots if: inputs.update == 'true' && steps.test-and-update.outputs.hasUpdatedScreenshots == 'true' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: updated-screenshots-${{ inputs.shard }}-${{ inputs.totalShards }} path: UpdatedScreenshots-${{ inputs.shard }}-${{ inputs.totalShards }}.zip diff --git a/.github/workflows/actions/test-core-spec/action.yml b/.github/workflows/actions/test-core-spec/action.yml index 2aab4b1be94..f33ab0aa3e3 100644 --- a/.github/workflows/actions/test-core-spec/action.yml +++ b/.github/workflows/actions/test-core-spec/action.yml @@ -6,7 +6,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - name: πŸ•ΈοΈ Install Dependencies diff --git a/.github/workflows/actions/test-react-e2e/action.yml b/.github/workflows/actions/test-react-e2e/action.yml index a1bcbf7a4db..af6100bdb07 100644 --- a/.github/workflows/actions/test-react-e2e/action.yml +++ b/.github/workflows/actions/test-react-e2e/action.yml @@ -6,7 +6,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/test-react-router-e2e/action.yml b/.github/workflows/actions/test-react-router-e2e/action.yml index 034cfdce747..663ae0be575 100644 --- a/.github/workflows/actions/test-react-router-e2e/action.yml +++ b/.github/workflows/actions/test-react-router-e2e/action.yml @@ -6,7 +6,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/test-vue-e2e/action.yml b/.github/workflows/actions/test-vue-e2e/action.yml index 191cd193c8a..5f7de702d08 100644 --- a/.github/workflows/actions/test-vue-e2e/action.yml +++ b/.github/workflows/actions/test-vue-e2e/action.yml @@ -6,7 +6,7 @@ inputs: runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - uses: ./.github/workflows/actions/download-archive diff --git a/.github/workflows/actions/update-reference-screenshots/action.yml b/.github/workflows/actions/update-reference-screenshots/action.yml index 6ee56689b10..844f4e6d2ac 100644 --- a/.github/workflows/actions/update-reference-screenshots/action.yml +++ b/.github/workflows/actions/update-reference-screenshots/action.yml @@ -7,10 +7,10 @@ on: runs: using: 'composite' steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: ./artifacts - name: πŸ”Ž Extract Archives diff --git a/.github/workflows/actions/upload-archive/action.yml b/.github/workflows/actions/upload-archive/action.yml index c09397345f9..3cfff8853fc 100644 --- a/.github/workflows/actions/upload-archive/action.yml +++ b/.github/workflows/actions/upload-archive/action.yml @@ -13,7 +13,7 @@ runs: - name: πŸ—„οΈ Create Archive run: zip -q -r ${{ inputs.output }} ${{ inputs.paths }} shell: bash - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.name }} path: ${{ inputs.output }} 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..540d873c5db 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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..f17a5f9a264 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -14,8 +14,8 @@ jobs: permissions: security-events: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: github/codeql-action/init@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: languages: javascript - - uses: github/codeql-action/analyze@v4 + - uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml index 8c0bbdebb37..65c026de5eb 100644 --- a/.github/workflows/conventional-commit.yml +++ b/.github/workflows/conventional-commit.yml @@ -12,7 +12,7 @@ jobs: if: | !contains(github.event.pull_request.title, 'release') && !contains(github.event.pull_request.title, 'chore') - uses: amannn/action-semantic-pull-request@v6 + uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index 51806128a8e..29b499e1855 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # 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/label.yml b/.github/workflows/label.yml index 7f5d8de9787..49606e47e90 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -13,7 +13,7 @@ jobs: triage: runs-on: ubuntu-latest steps: - - uses: actions/labeler@v6 + - uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" sync-labels: true diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2f64d9f9bd5..3f81651868f 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # 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..9671a057c28 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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..b4228d33841 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # 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..a9be239edfa 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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..acfe30ef553 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # 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 8ef8dae114d..fcd32072979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,144 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [8.8.18](https://github.com/ionic-team/ionic-framework/compare/v8.8.17...v8.8.18) (2026-08-12) + + +### Bug Fixes + +* **core:** resolve lazy component chunks under Vite 8 ([#31341](https://github.com/ionic-team/ionic-framework/issues/31341)) ([a73e183](https://github.com/ionic-team/ionic-framework/commit/a73e1831d389e6dc968f646c0322b3984c1d3224)), closes [#31333](https://github.com/ionic-team/ionic-framework/issues/31333) +* **core:** update offsets when safe-area insets arrive late ([#31343](https://github.com/ionic-team/ionic-framework/issues/31343)) ([bc4fce2](https://github.com/ionic-team/ionic-framework/commit/bc4fce218d6c3aaac69f11d416b9fc8c8dacdfa4)), closes [#31337](https://github.com/ionic-team/ionic-framework/issues/31337) +* **datetime:** tear down ready state when an overlay dismisses ([#31335](https://github.com/ionic-team/ionic-framework/issues/31335)) ([d59e341](https://github.com/ionic-team/ionic-framework/commit/d59e3415df06cb26a7dea688f6720ba228b775d5)), closes [#31155](https://github.com/ionic-team/ionic-framework/issues/31155) [#31143](https://github.com/ionic-team/ionic-framework/issues/31143) +* **modal:** allow pull-to-refresh with a custom scroll host ([#31339](https://github.com/ionic-team/ionic-framework/issues/31339)) ([d6202a8](https://github.com/ionic-team/ionic-framework/commit/d6202a8933ad77735fceca05cb4b0fe6b33bfb55)), closes [#31332](https://github.com/ionic-team/ionic-framework/issues/31332) + + + + + +## [8.8.17](https://github.com/ionic-team/ionic-framework/compare/v8.8.16...v8.8.17) (2026-08-05) + + +### Bug Fixes + +* **checkbox,radio,toggle:** add missing keyboard focus indicators ([#31295](https://github.com/ionic-team/ionic-framework/issues/31295)) ([2f9c0b7](https://github.com/ionic-team/ionic-framework/commit/2f9c0b7d8fa6371785cfdf1d99857eece4835e4d)) +* **fab-button:** restore submit, reset, and disabled form functionality ([#31249](https://github.com/ionic-team/ionic-framework/issues/31249)) ([93ee380](https://github.com/ionic-team/ionic-framework/commit/93ee38015125619509b9aa844d6f0025be0a71b7)), closes [#18550](https://github.com/ionic-team/ionic-framework/issues/18550) +* **react-router:** symmetric pathname+search url comparison ([#31153](https://github.com/ionic-team/ionic-framework/issues/31153)) ([ffef25d](https://github.com/ionic-team/ionic-framework/commit/ffef25d2e2597bee5fdb8833d9e50ff9234d74e0)), closes [#31152](https://github.com/ionic-team/ionic-framework/issues/31152) [#31152](https://github.com/ionic-team/ionic-framework/issues/31152) [#25534](https://github.com/ionic-team/ionic-framework/issues/25534) +* **refresher:** avoid gesture setup after disconnect ([#31316](https://github.com/ionic-team/ionic-framework/issues/31316)) ([d9b877f](https://github.com/ionic-team/ionic-framework/commit/d9b877f6059992808cccec503dbe2a30f6ccdb48)), closes [#31315](https://github.com/ionic-team/ionic-framework/issues/31315) +* **tab:** retry lazy load after a failed attach ([#31324](https://github.com/ionic-team/ionic-framework/issues/31324)) ([81b52e2](https://github.com/ionic-team/ionic-framework/commit/81b52e25bd8fa55b905da2791d84b381a2db630b)) + + + + + +## [8.8.16](https://github.com/ionic-team/ionic-framework/compare/v8.8.15...v8.8.16) (2026-07-29) + + +### Bug Fixes + +* **angular:** guard transition against destroyed router outlet ([#31297](https://github.com/ionic-team/ionic-framework/issues/31297)) ([fdef61c](https://github.com/ionic-team/ionic-framework/commit/fdef61cc74285caff0549ba8bdac70cedaaba865)), closes [#31296](https://github.com/ionic-team/ionic-framework/issues/31296) +* **modal:** keep focus on dialog for cycle sheet modals ([#31293](https://github.com/ionic-team/ionic-framework/issues/31293)) ([b4b2603](https://github.com/ionic-team/ionic-framework/commit/b4b2603210b313fc2f2ee2c88b9112751e24b338)) +* **select:** prevent floating label and placeholder from overlapping when focused ([#31276](https://github.com/ionic-team/ionic-framework/issues/31276)) ([e987009](https://github.com/ionic-team/ionic-framework/commit/e9870091c2f7460a1dc12c4b407ddfde39e1b156)), closes [#30401](https://github.com/ionic-team/ionic-framework/issues/30401) + + + + + +## [8.8.15](https://github.com/ionic-team/ionic-framework/compare/v8.8.14...v8.8.15) (2026-07-22) + + +### Bug Fixes + +* **angular-server:** fallback to scoped+annotations when SSR DOM lacks attachShadow ([#30967](https://github.com/ionic-team/ionic-framework/issues/30967)) ([ae6911f](https://github.com/ionic-team/ionic-framework/commit/ae6911f6c8d46467ae54114295688bbba5ccbf5e)), closes [#29751](https://github.com/ionic-team/ionic-framework/issues/29751) +* **modal:** focus the dialog wrapper on present so screen readers can enter the modal ([#31260](https://github.com/ionic-team/ionic-framework/issues/31260)) ([6251eb8](https://github.com/ionic-team/ionic-framework/commit/6251eb85db0e5b43b9e09604248dc8451e1664c0)) + + + + + +## [8.8.14](https://github.com/ionic-team/ionic-framework/compare/v8.8.13...v8.8.14) (2026-07-15) + + +### Bug Fixes + +* **tab-bar:** fire tab change events on tab click ([#31262](https://github.com/ionic-team/ionic-framework/issues/31262)) ([44461dd](https://github.com/ionic-team/ionic-framework/commit/44461ddda5ef44030f477931dd94cce4674801a6)), closes [#30145](https://github.com/ionic-team/ionic-framework/issues/30145) + + + + + +## [8.8.13](https://github.com/ionic-team/ionic-framework/compare/v8.8.12...v8.8.13) (2026-07-01) + + +### Bug Fixes + +* **button:** sync disabled state and type in renderHiddenButton ([#31225](https://github.com/ionic-team/ionic-framework/issues/31225)) ([0c76135](https://github.com/ionic-team/ionic-framework/commit/0c76135f8c373dfad66a65717092ffd4d52461c5)), closes [#30968](https://github.com/ionic-team/ionic-framework/issues/30968) +* **datetime-button:** respect datetime constraints in initial text ([#31218](https://github.com/ionic-team/ionic-framework/issues/31218)) ([044f358](https://github.com/ionic-team/ionic-framework/commit/044f3589f184b51f093419556a38d1a0ff2413d6)), closes [#30183](https://github.com/ionic-team/ionic-framework/issues/30183) +* **datetime:** don't setActiveParts from month/year wheels when multiple=true ([#31248](https://github.com/ionic-team/ionic-framework/issues/31248)) ([bf46a3d](https://github.com/ionic-team/ionic-framework/commit/bf46a3d7405e77c28659dbfc0a094023a9e8bd87)), closes [#29673](https://github.com/ionic-team/ionic-framework/issues/29673) +* **datetime:** respect config animated setting when paging month calendar ([#31227](https://github.com/ionic-team/ionic-framework/issues/31227)) ([a35f8a9](https://github.com/ionic-team/ionic-framework/commit/a35f8a98e31f5895e36c845484207f3ed4c74689)), closes [#30484](https://github.com/ionic-team/ionic-framework/issues/30484) + + + + + +## [8.8.12](https://github.com/ionic-team/ionic-framework/compare/v8.8.11...v8.8.12) (2026-06-24) + + +### Bug Fixes + +* **angular:** honor modifier-click on routerLink ([#31230](https://github.com/ionic-team/ionic-framework/issues/31230)) ([2ac9851](https://github.com/ionic-team/ionic-framework/commit/2ac98512c78f6d47e3a6a17ff0159047ba14a5cd)), closes [#26394](https://github.com/ionic-team/ionic-framework/issues/26394) +* **angular:** run change detection on attached overlay views ([#31235](https://github.com/ionic-team/ionic-framework/issues/31235)) ([8e76fd0](https://github.com/ionic-team/ionic-framework/commit/8e76fd0cceeb878ad4b071515acb593892512111)), closes [#31220](https://github.com/ionic-team/ionic-framework/issues/31220) + + + + + +## [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) + + +### Bug Fixes + +* **react:** bind events properly for overlays rendered within a nav ([#31159](https://github.com/ionic-team/ionic-framework/issues/31159)) ([fa4593d](https://github.com/ionic-team/ionic-framework/commit/fa4593d8a4d61a583dbf6fa551cd846fe258624f)), closes [#27843](https://github.com/ionic-team/ionic-framework/issues/27843) +* **tabs:** preserve query params and fragment from tab button href ([#31154](https://github.com/ionic-team/ionic-framework/issues/31154)) ([0182bba](https://github.com/ionic-team/ionic-framework/commit/0182bba06d6171dd2faf80556fd2131abf03fa93)), closes [#25470](https://github.com/ionic-team/ionic-framework/issues/25470) +* **vue-router:** prevent out-of-bounds index when popping across tabs ([#31148](https://github.com/ionic-team/ionic-framework/issues/31148)) ([c88c0de](https://github.com/ionic-team/ionic-framework/commit/c88c0de3ade92469fa1f37e1b8220911adf113e0)), closes [#29413](https://github.com/ionic-team/ionic-framework/issues/29413) + + + + + ## [8.8.7](https://github.com/ionic-team/ionic-framework/compare/v8.8.6...v8.8.7) (2026-05-13) diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 3b3bfa8d395..84ed9ee1068 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -3,6 +3,128 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [8.8.18](https://github.com/ionic-team/ionic-framework/compare/v8.8.17...v8.8.18) (2026-08-12) + + +### Bug Fixes + +* **core:** resolve lazy component chunks under Vite 8 ([#31341](https://github.com/ionic-team/ionic-framework/issues/31341)) ([a73e183](https://github.com/ionic-team/ionic-framework/commit/a73e1831d389e6dc968f646c0322b3984c1d3224)), closes [#31333](https://github.com/ionic-team/ionic-framework/issues/31333) +* **core:** update offsets when safe-area insets arrive late ([#31343](https://github.com/ionic-team/ionic-framework/issues/31343)) ([bc4fce2](https://github.com/ionic-team/ionic-framework/commit/bc4fce218d6c3aaac69f11d416b9fc8c8dacdfa4)), closes [#31337](https://github.com/ionic-team/ionic-framework/issues/31337) +* **datetime:** tear down ready state when an overlay dismisses ([#31335](https://github.com/ionic-team/ionic-framework/issues/31335)) ([d59e341](https://github.com/ionic-team/ionic-framework/commit/d59e3415df06cb26a7dea688f6720ba228b775d5)), closes [#31155](https://github.com/ionic-team/ionic-framework/issues/31155) [#31143](https://github.com/ionic-team/ionic-framework/issues/31143) +* **modal:** allow pull-to-refresh with a custom scroll host ([#31339](https://github.com/ionic-team/ionic-framework/issues/31339)) ([d6202a8](https://github.com/ionic-team/ionic-framework/commit/d6202a8933ad77735fceca05cb4b0fe6b33bfb55)), closes [#31332](https://github.com/ionic-team/ionic-framework/issues/31332) + + + + + +## [8.8.17](https://github.com/ionic-team/ionic-framework/compare/v8.8.16...v8.8.17) (2026-08-05) + + +### Bug Fixes + +* **checkbox,radio,toggle:** add missing keyboard focus indicators ([#31295](https://github.com/ionic-team/ionic-framework/issues/31295)) ([2f9c0b7](https://github.com/ionic-team/ionic-framework/commit/2f9c0b7d8fa6371785cfdf1d99857eece4835e4d)) +* **fab-button:** restore submit, reset, and disabled form functionality ([#31249](https://github.com/ionic-team/ionic-framework/issues/31249)) ([93ee380](https://github.com/ionic-team/ionic-framework/commit/93ee38015125619509b9aa844d6f0025be0a71b7)), closes [#18550](https://github.com/ionic-team/ionic-framework/issues/18550) +* **refresher:** avoid gesture setup after disconnect ([#31316](https://github.com/ionic-team/ionic-framework/issues/31316)) ([d9b877f](https://github.com/ionic-team/ionic-framework/commit/d9b877f6059992808cccec503dbe2a30f6ccdb48)), closes [#31315](https://github.com/ionic-team/ionic-framework/issues/31315) +* **tab:** retry lazy load after a failed attach ([#31324](https://github.com/ionic-team/ionic-framework/issues/31324)) ([81b52e2](https://github.com/ionic-team/ionic-framework/commit/81b52e25bd8fa55b905da2791d84b381a2db630b)) + + + + + +## [8.8.16](https://github.com/ionic-team/ionic-framework/compare/v8.8.15...v8.8.16) (2026-07-29) + + +### Bug Fixes + +* **modal:** keep focus on dialog for cycle sheet modals ([#31293](https://github.com/ionic-team/ionic-framework/issues/31293)) ([b4b2603](https://github.com/ionic-team/ionic-framework/commit/b4b2603210b313fc2f2ee2c88b9112751e24b338)) +* **select:** prevent floating label and placeholder from overlapping when focused ([#31276](https://github.com/ionic-team/ionic-framework/issues/31276)) ([e987009](https://github.com/ionic-team/ionic-framework/commit/e9870091c2f7460a1dc12c4b407ddfde39e1b156)), closes [#30401](https://github.com/ionic-team/ionic-framework/issues/30401) + + + + + +## [8.8.15](https://github.com/ionic-team/ionic-framework/compare/v8.8.14...v8.8.15) (2026-07-22) + + +### Bug Fixes + +* **modal:** focus the dialog wrapper on present so screen readers can enter the modal ([#31260](https://github.com/ionic-team/ionic-framework/issues/31260)) ([6251eb8](https://github.com/ionic-team/ionic-framework/commit/6251eb85db0e5b43b9e09604248dc8451e1664c0)) + + + + + +## [8.8.14](https://github.com/ionic-team/ionic-framework/compare/v8.8.13...v8.8.14) (2026-07-15) + +**Note:** Version bump only for package @ionic/core + + + + + +## [8.8.13](https://github.com/ionic-team/ionic-framework/compare/v8.8.12...v8.8.13) (2026-07-01) + + +### Bug Fixes + +* **button:** sync disabled state and type in renderHiddenButton ([#31225](https://github.com/ionic-team/ionic-framework/issues/31225)) ([0c76135](https://github.com/ionic-team/ionic-framework/commit/0c76135f8c373dfad66a65717092ffd4d52461c5)), closes [#30968](https://github.com/ionic-team/ionic-framework/issues/30968) +* **datetime-button:** respect datetime constraints in initial text ([#31218](https://github.com/ionic-team/ionic-framework/issues/31218)) ([044f358](https://github.com/ionic-team/ionic-framework/commit/044f3589f184b51f093419556a38d1a0ff2413d6)), closes [#30183](https://github.com/ionic-team/ionic-framework/issues/30183) +* **datetime:** don't setActiveParts from month/year wheels when multiple=true ([#31248](https://github.com/ionic-team/ionic-framework/issues/31248)) ([bf46a3d](https://github.com/ionic-team/ionic-framework/commit/bf46a3d7405e77c28659dbfc0a094023a9e8bd87)), closes [#29673](https://github.com/ionic-team/ionic-framework/issues/29673) +* **datetime:** respect config animated setting when paging month calendar ([#31227](https://github.com/ionic-team/ionic-framework/issues/31227)) ([a35f8a9](https://github.com/ionic-team/ionic-framework/commit/a35f8a98e31f5895e36c845484207f3ed4c74689)), closes [#30484](https://github.com/ionic-team/ionic-framework/issues/30484) + + + + + +## [8.8.12](https://github.com/ionic-team/ionic-framework/compare/v8.8.11...v8.8.12) (2026-06-24) + +**Note:** Version bump only for package @ionic/core + + + + + +## [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 + + + + + ## [8.8.7](https://github.com/ionic-team/ionic-framework/compare/v8.8.6...v8.8.7) (2026-05-13) diff --git a/core/Dockerfile b/core/Dockerfile index b5a20843d1b..3865b2e3c21 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.62.1 # Set the working directory WORKDIR /ionic diff --git a/core/api.txt b/core/api.txt index d9e586ffd33..3087e65b8be 100644 --- a/core/api.txt +++ b/core/api.txt @@ -601,6 +601,7 @@ ion-fab-button,prop,closeIcon,string,close,false,false ion-fab-button,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record | undefined,undefined,false,true ion-fab-button,prop,disabled,boolean,false,false,false ion-fab-button,prop,download,string | undefined,undefined,false,false +ion-fab-button,prop,form,HTMLFormElement | string | undefined,undefined,false,false ion-fab-button,prop,href,string | undefined,undefined,false,false ion-fab-button,prop,mode,"ios" | "md",undefined,false,false ion-fab-button,prop,rel,string | undefined,undefined,false,false @@ -1833,7 +1834,7 @@ ion-spinner,css-prop,--color ion-split-pane,shadow ion-split-pane,prop,contentId,string | undefined,undefined,false,true ion-split-pane,prop,disabled,boolean,false,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,ios ion-split-pane,css-prop,--border,md diff --git a/core/package-lock.json b/core/package-lock.json index df01b3af51b..3e47702a493 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,27 +1,27 @@ { "name": "@ionic/core", - "version": "8.8.7", + "version": "8.8.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/core", - "version": "8.8.7", + "version": "8.8.18", "license": "MIT", "dependencies": { - "@stencil/core": "4.43.0", + "@stencil/core": "4.43.5", "ionicons": "^8.0.13", "tslib": "^2.1.0" }, "devDependencies": { - "@axe-core/playwright": "^4.11.3", + "@axe-core/playwright": "^4.12.1", "@capacitor/core": "^8.0.0", "@capacitor/haptics": "^8.0.0", "@capacitor/keyboard": "^8.0.0", "@capacitor/status-bar": "^8.0.0", "@ionic/eslint-config": "^0.3.0", "@ionic/prettier-config": "^2.0.0", - "@playwright/test": "^1.59.1", + "@playwright/test": "^1.62.1", "@rollup/plugin-node-resolve": "^8.4.0", "@rollup/plugin-virtual": "^2.0.3", "@stencil/angular-output-target": "^0.10.0", @@ -32,7 +32,7 @@ "@types/node": "^16.18.126", "@typescript-eslint/eslint-plugin": "^6.7.2", "@typescript-eslint/parser": "^6.7.2", - "chalk": "^5.3.0", + "chalk": "^6.0.0", "clean-css-cli": "^5.6.1", "domino": "^2.1.6", "eslint": "^7.32.0", @@ -42,7 +42,7 @@ "fs-extra": "^9.0.1", "jest": "^29.7.0", "jest-cli": "^29.7.0", - "playwright-core": "^1.59.1", + "playwright-core": "^1.62.1", "prettier": "^2.6.1", "rollup": "^2.26.4", "sass": "^1.33.0", @@ -60,13 +60,13 @@ "dev": true }, "node_modules/@axe-core/playwright": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz", - "integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", "dev": true, "license": "MPL-2.0", "dependencies": { - "axe-core": "~4.11.4" + "axe-core": "~4.12.1" }, "peerDependencies": { "playwright-core": ">= 1.0.0" @@ -629,9 +629,9 @@ "license": "MIT" }, "node_modules/@capacitor/core": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz", - "integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.0.tgz", + "integrity": "sha512-Ca4krtqH1hothjtBIwf2J2TW7IhYq1ujp8QeItTiJohNsqij8ja2DYYH3DU0l8RmxCWaBAFTGA2TgOgOMCSNsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -649,9 +649,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": { @@ -659,9 +659,9 @@ } }, "node_modules/@capacitor/status-bar": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.2.tgz", - "integrity": "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.3.tgz", + "integrity": "sha512-csSpfNeN49Hx9JaQBSJEIiEbOLtXg3kcc2IpScq2fu5L520h3AWEvsxoH8Srk1jxfRKepaJ4S4sqSC3foI4AgA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1598,19 +1598,19 @@ } }, "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.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.59.1" + "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@rollup/plugin-node-resolve": { @@ -1658,7 +1658,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" ], @@ -1669,9 +1671,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" ], @@ -1682,9 +1684,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" ], @@ -1695,9 +1697,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" ], @@ -1708,9 +1710,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" ], @@ -1721,9 +1723,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" ], @@ -1734,9 +1736,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" ], @@ -1747,9 +1749,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" ], @@ -1789,9 +1791,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", "bin": { "stencil": "bin/stencil" @@ -1801,14 +1803,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": { @@ -2838,9 +2840,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", - "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3066,6 +3068,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/boxen/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/boxen/node_modules/string-width": { "version": "5.1.2", "dev": true, @@ -3247,11 +3262,13 @@ "license": "CC-BY-4.0" }, "node_modules/chalk": { - "version": "5.6.2", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", "dev": true, "license": "MIT", "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": ">=22" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" @@ -7942,35 +7959,35 @@ } }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "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.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/postcss": { diff --git a/core/package.json b/core/package.json index 56246c0dc82..c3f01339760 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/core", - "version": "8.8.7", + "version": "8.8.18", "description": "Base components for Ionic", "engines": { "node": ">= 16" @@ -34,19 +34,19 @@ "loader/" ], "dependencies": { - "@stencil/core": "4.43.0", + "@stencil/core": "4.43.5", "ionicons": "^8.0.13", "tslib": "^2.1.0" }, "devDependencies": { - "@axe-core/playwright": "^4.11.3", + "@axe-core/playwright": "^4.12.1", "@capacitor/core": "^8.0.0", "@capacitor/haptics": "^8.0.0", "@capacitor/keyboard": "^8.0.0", "@capacitor/status-bar": "^8.0.0", "@ionic/eslint-config": "^0.3.0", "@ionic/prettier-config": "^2.0.0", - "@playwright/test": "^1.59.1", + "@playwright/test": "^1.62.1", "@rollup/plugin-node-resolve": "^8.4.0", "@rollup/plugin-virtual": "^2.0.3", "@stencil/angular-output-target": "^0.10.0", @@ -57,7 +57,7 @@ "@types/node": "^16.18.126", "@typescript-eslint/eslint-plugin": "^6.7.2", "@typescript-eslint/parser": "^6.7.2", - "chalk": "^5.3.0", + "chalk": "^6.0.0", "clean-css-cli": "^5.6.1", "domino": "^2.1.6", "eslint": "^7.32.0", @@ -67,7 +67,7 @@ "fs-extra": "^9.0.1", "jest": "^29.7.0", "jest-cli": "^29.7.0", - "playwright-core": "^1.59.1", + "playwright-core": "^1.62.1", "prettier": "^2.6.1", "rollup": "^2.26.4", "sass": "^1.33.0", @@ -99,7 +99,8 @@ "test.e2e.update-snapshots": "npm run test.e2e -- --update-snapshots='changed'", "test.watch": "jest --watch --no-cache", "test.treeshake": "node scripts/treeshaking.js dist/index.js", - "validate": "npm run lint && npm run test && npm run build && npm run test.treeshake", + "test.lazy-imports": "node scripts/verify/lazy-imports.js", + "validate": "npm run lint && npm run test && npm run build && npm run test.lazy-imports && npm run test.treeshake", "docker.build": "docker build -t ionic-playwright .", "test.e2e.docker": "npm run docker.build && node ./scripts/docker.mjs", "test.e2e.docker.update-snapshots": "npm run test.e2e.docker -- --update-snapshots='changed'", diff --git a/core/scripts/verify/lazy-imports.js b/core/scripts/verify/lazy-imports.js new file mode 100644 index 00000000000..d0008130474 --- /dev/null +++ b/core/scripts/verify/lazy-imports.js @@ -0,0 +1,56 @@ +/** + * Fails if the built lazy loader is missing a literal import path for any component bundle. + * Not in `scripts/testing/` because `vercel-build.sh` publishes that directory. + * https://github.com/ionic-team/ionic-framework/issues/31333 + */ +const fs = require('fs'); +const path = require('path'); + +const PACKAGE_ROOT = path.resolve(__dirname, '..', '..'); +const OUTPUT_DIRS = ['dist/esm', 'dist/cjs']; + +function main() { + const failures = OUTPUT_DIRS.map(verify).filter((message) => message !== null); + + if (failures.length > 0) { + failures.forEach((message) => console.error(message)); + process.exit(1); + } + + console.error(`Success! Every lazy bundle is imported by path in ${OUTPUT_DIRS.join(' and ')}`); +} + +function verify(dir) { + const resolved = path.join(PACKAGE_ROOT, dir); + + if (!fs.existsSync(resolved)) { + return `${dir} does not exist. Run npm run build first.`; + } + + const files = fs.readdirSync(resolved).filter((file) => file.endsWith('.js')); + const entries = files.filter((file) => file.endsWith('.entry.js')); + + if (entries.length === 0) { + return `${dir} does not contain any .entry.js files.`; + } + + // A non-loader chunk that happens to name an entry file would be a false pass. + const loader = files + .filter((file) => !file.endsWith('.entry.js')) + .map((file) => fs.readFileSync(path.join(resolved, file), 'utf-8')) + .find((source) => /switch\s*\(bundleId\)/.test(source)); + + if (loader === undefined) { + return `${dir} has no lazy loader with a bundle id switch. Check that extras.enableImportInjection is still set in core/stencil.config.ts.`; + } + + const missing = entries.filter((entry) => !loader.includes(`'./${entry}'`) && !loader.includes(`"./${entry}"`)); + + if (missing.length > 0) { + return `${dir} is missing an import for ${missing.length} of ${entries.length} lazy bundles: ${missing.join(', ')}`; + } + + return null; +} + +main(); diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 37223c4f77a..102f7472b98 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -15,7 +15,7 @@ import { RouteID, RouterDirection, RouterEventDetail, RouteWrite } from "./compo import { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface"; import { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface"; import { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface"; -import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; +import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimeParts, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; import { SpinnerTypes } from "./components/spinner/spinner-configs"; import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; import { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface"; @@ -53,7 +53,7 @@ export { RouteID, RouterDirection, RouterEventDetail, RouteWrite } from "./compo export { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface"; export { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface"; export { ScrollBaseDetail, ScrollDetail } from "./components/content/content-interface"; -export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; +export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimeParts, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; export { SpinnerTypes } from "./components/spinner/spinner-configs"; export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; export { InputOtpChangeEventDetail, InputOtpCompleteEventDetail, InputOtpInputEventDetail } from "./components/input-otp/input-otp-interface"; @@ -929,7 +929,7 @@ export namespace Components { */ "clearText": string; /** - * The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). + * The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, refer to [theming](/docs/theming/basics). * @default 'primary' */ "color"?: Color; @@ -961,6 +961,10 @@ export namespace Components { * Formatting options for dates and times. Should include a 'date' and/or 'time' object, each of which is of type [Intl.DateTimeFormatOptions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options). */ "formatOptions"?: FormatOptions; + /** + * Returns the default parts the datetime falls back to when no value is set: today's date and time snapped to the closest value allowed by the component's constraints (`min`, `max`, and the `*Values` props). + */ + "getDefaultPart": () => Promise; /** * Used to apply custom text and background colors to specific dates. Can be either an array of objects containing ISO strings and colors, or a callback that receives an ISO string and returns the colors. Only applies to the `date`, `date-time`, and `time-date` presentations, with `preferWheel="false"`. */ @@ -1147,6 +1151,10 @@ export namespace Components { * This attribute instructs browsers to download a URL instead of navigating to it, so the user will be prompted to save it as a local file. If the attribute has a value, it is used as the pre-filled file name in the Save prompt (the user can still change the file name if they want). */ "download": string | undefined; + /** + * The HTML form element or form element id. Used to submit a form when the button is not a child of the form. + */ + "form"?: string | HTMLFormElement; /** * Contains a URL or a URL fragment that the hyperlink points to. If this property is set, an anchor tag will be rendered. */ @@ -2654,7 +2662,7 @@ export namespace Components { */ "disabled": boolean; /** - * Show two knobs. + * If `true`, the range shows two knobs and `value` is an object with `lower` and `upper` properties. If `false`, the range shows one knob and `value` is a number. * @default false */ "dualKnobs": boolean; @@ -3313,7 +3321,7 @@ export namespace Components { "isVisible": () => Promise; /** * 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; } @@ -6190,7 +6198,7 @@ declare namespace LocalJSX { */ "clearText"?: string; /** - * The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, see [theming](/docs/theming/basics). + * The color to use from your application's color palette. Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. For more information on colors, refer to [theming](/docs/theming/basics). * @default 'primary' */ "color"?: Color; @@ -6418,6 +6426,10 @@ declare namespace LocalJSX { * This attribute instructs browsers to download a URL instead of navigating to it, so the user will be prompted to save it as a local file. If the attribute has a value, it is used as the pre-filled file name in the Save prompt (the user can still change the file name if they want). */ "download"?: string | undefined; + /** + * The HTML form element or form element id. Used to submit a form when the button is not a child of the form. + */ + "form"?: string | HTMLFormElement; /** * Contains a URL or a URL fragment that the hyperlink points to. If this property is set, an anchor tag will be rendered. */ @@ -7927,7 +7939,7 @@ declare namespace LocalJSX { */ "disabled"?: boolean; /** - * Show two knobs. + * If `true`, the range shows two knobs and `value` is an object with `lower` and `upper` properties. If `false`, the range shows one knob and `value` is a number. * @default false */ "dualKnobs"?: boolean; @@ -8683,7 +8695,7 @@ declare namespace LocalJSX { "onIonSplitPaneVisible"?: (event: IonSplitPaneCustomEvent<{ visible: boolean }>) => void; /** * 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; } @@ -9393,6 +9405,7 @@ declare namespace LocalJSX { "show": boolean; "translucent": boolean; "type": 'submit' | 'reset' | 'button'; + "form": string | HTMLFormElement; "size": 'small'; "closeIcon": string; } diff --git a/core/src/components/action-sheet/test/a11y/index.html b/core/src/components/action-sheet/test/a11y/index.html index 181ada7fba7..7a6190d3073 100644 --- a/core/src/components/action-sheet/test/a11y/index.html +++ b/core/src/components/action-sheet/test/a11y/index.html @@ -11,7 +11,7 @@ diff --git a/core/src/components/action-sheet/test/basic/index.html b/core/src/components/action-sheet/test/basic/index.html index b95d43b42c7..640801b2ddf 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 @@ 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/translucent/index.html b/core/src/components/action-sheet/test/translucent/index.html index cfae303e7e6..c2fcc48dcd6 100644 --- a/core/src/components/action-sheet/test/translucent/index.html +++ b/core/src/components/action-sheet/test/translucent/index.html @@ -58,7 +58,7 @@ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-ios-ltr-Mobile-Chrome-linux.png index f67d7697e94..9f777bfbe8e 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-ios-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-ios-ltr-Mobile-Safari-linux.png index 14b67bab55b..3132f10c7c1 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-md-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-md-ltr-Mobile-Chrome-linux.png index 82f5a675ff2..5a36d4b7306 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-md-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-md-ltr-Mobile-Safari-linux.png index b08f7f705b0..ac7fcd60656 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-md-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-scale-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-text-wrap-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-text-wrap-ios-ltr-Mobile-Chrome-linux.png index 0e4c7296e26..702aac68103 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-text-wrap-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-text-wrap-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-text-wrap-ios-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-text-wrap-ios-ltr-Mobile-Safari-linux.png index f9700b524ca..0cdb0d34ddf 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-text-wrap-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-checkbox-text-wrap-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-ios-ltr-Mobile-Chrome-linux.png index 5345dedf71f..2af165c1e6a 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-ios-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-ios-ltr-Mobile-Safari-linux.png index 872d9b5bc3b..6533588b233 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-md-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-md-ltr-Mobile-Chrome-linux.png index fbab7b3c127..def97fcd144 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-md-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-md-ltr-Mobile-Safari-linux.png index bf093626e16..2b87b888ccc 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-md-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-scale-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-text-wrap-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-text-wrap-ios-ltr-Mobile-Chrome-linux.png index 39e52cb8c99..cc17202dcde 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-text-wrap-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-text-wrap-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-text-wrap-ios-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-text-wrap-ios-ltr-Mobile-Safari-linux.png index 99524420f87..8eb4942b639 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-text-wrap-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-radio-text-wrap-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-scale-ios-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-scale-ios-ltr-Mobile-Safari-linux.png index 7da490d5876..3ac086d15e4 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-scale-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-scale-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-scale-md-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-scale-md-ltr-Mobile-Safari-linux.png index 43bbc8a1033..bdf6ff7664d 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-scale-md-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-scale-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-text-fields-scale-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-text-fields-scale-ios-ltr-Mobile-Firefox-linux.png index f1b9c9c95a9..b83fae1e42b 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-text-fields-scale-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-text-fields-scale-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-text-fields-scale-ios-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-text-fields-scale-ios-ltr-Mobile-Safari-linux.png index 4e73498dcbc..985d9cc5858 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-text-fields-scale-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-text-fields-scale-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-text-fields-scale-md-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-text-fields-scale-md-ltr-Mobile-Safari-linux.png index 5fe7a165ca1..43b7f9973c2 100644 Binary files a/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-text-fields-scale-md-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/a11y/alert.e2e.ts-snapshots/alert-text-fields-scale-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/a11y/index.html b/core/src/components/alert/test/a11y/index.html index cb137ba7314..e9dda8ccd32 100644 --- a/core/src/components/alert/test/a11y/index.html +++ b/core/src/components/alert/test/a11y/index.html @@ -11,7 +11,7 @@ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-Mobile-Chrome-linux.png index 5909d31177b..422dc78df14 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-Mobile-Safari-linux.png index e3cc49273c4..3db4cc6f6ae 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-dark-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-dark-Mobile-Chrome-linux.png index 3a25efb2319..ace7921fe82 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-dark-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-dark-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-dark-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-dark-Mobile-Safari-linux.png index d6f27beb40c..ecc5caaf58b 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-dark-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-ltr-dark-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-Mobile-Chrome-linux.png index 28906d81ae7..c1f3aa6076c 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-Mobile-Safari-linux.png index bbc71f6ed27..c51380a9daf 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-dark-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-dark-Mobile-Chrome-linux.png index c1a9f818693..0ca5b035758 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-dark-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-dark-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-dark-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-dark-Mobile-Safari-linux.png index d6ee4953275..f3b526c748a 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-dark-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-ios-rtl-dark-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-Mobile-Chrome-linux.png index 4afb9ec90d7..64dc50f3a51 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-Mobile-Safari-linux.png index 4d515af5061..99b52f26e9d 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-dark-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-dark-Mobile-Chrome-linux.png index b50cef0cb16..ed8ccf71c5d 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-dark-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-dark-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-dark-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-dark-Mobile-Safari-linux.png index 8f2138c058b..abc154963b2 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-dark-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-ltr-dark-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-Mobile-Chrome-linux.png index aaf682977d2..88035ef5b4e 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-Mobile-Safari-linux.png index 1932799c790..7f7a9fb2f66 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-dark-Mobile-Chrome-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-dark-Mobile-Chrome-linux.png index be51a5bd790..a6dde39d863 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-dark-Mobile-Chrome-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-dark-Mobile-Chrome-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-dark-Mobile-Safari-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-dark-Mobile-Safari-linux.png index f06f6122133..97bc8c5c013 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-dark-Mobile-Safari-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-multipleButtons-md-rtl-dark-Mobile-Safari-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Firefox-linux.png index 550db70f5bf..b8c706fb344 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Firefox-linux.png index 181b4f1fdfd..59c03881daf 100644 Binary files a/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Firefox-linux.png and b/core/src/components/alert/test/basic/alert.e2e.ts-snapshots/alert-prompt-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/alert/test/basic/index.html b/core/src/components/alert/test/basic/index.html index 171666ee3d8..e9ec5413a04 100644 --- a/core/src/components/alert/test/basic/index.html +++ b/core/src/components/alert/test/basic/index.html @@ -14,7 +14,7 @@ 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-Safari-linux.png b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-ltr-Mobile-Safari-linux.png index effe36becf6..a81b5cbc533 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-Safari-linux.png b/core/src/components/alert/test/standalone/alert.e2e.ts-snapshots/alert-standalone-md-rtl-Mobile-Safari-linux.png index 8811a5009a2..37593c15e62 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/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/button/button.tsx b/core/src/components/button/button.tsx index 0e4741f4acd..a1e7f72bf01 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -195,9 +195,13 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf /** * If the form already has a rendered form button - * then do not append a new one again. + * then do not append a new one again. Sync the + * disabled state and type if it changes after button + * creation (e.g., runtime property updates). */ if (formButtonEl !== null && formEl.contains(formButtonEl)) { + formButtonEl.disabled = this.disabled; + formButtonEl.type = this.type; return; } diff --git a/core/src/components/button/test/form-reference/button.e2e.ts b/core/src/components/button/test/form-reference/button.e2e.ts index 5710f52da67..c85cd20760d 100644 --- a/core/src/components/button/test/form-reference/button.e2e.ts +++ b/core/src/components/button/test/form-reference/button.e2e.ts @@ -153,6 +153,41 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, config }) => expect(submitEvent).toHaveReceivedEvent(); }); + + test('should keep hidden button type in sync with visible button', async ({ page }) => { + // Set the form to have a submit button + await page.setContent( + ` +
+ + Submit + + `, + config + ); + + // Get visible button + const button = page.locator('ion-button'); + + // Get type of the hidden button + const getHiddenType = () => + page.evaluate(() => { + const hidden = document.querySelector('form button[style*="display: none"]') as HTMLButtonElement; + + return hidden?.type; + }); + + // Type of hidden button should be submit to start + expect(await getHiddenType()).toBe('submit'); + + // Set type of visible button to reset + await button.evaluate((el: HTMLIonButtonElement) => { + el.type = 'reset'; + }); + + // Expect hidden button type to be reset + await expect.poll(async () => await getHiddenType()).toBe('reset'); + }); }); test.describe(title('should throw a warning if the form cannot be found'), () => { diff --git a/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-ios-ltr-Mobile-Chrome-linux.png index 03e84a57c35..75ac590fdb3 100644 Binary files a/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-ios-rtl-Mobile-Chrome-linux.png index d986ef49e68..90014c08489 100644 Binary files a/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-ios-rtl-Mobile-Chrome-linux.png and b/core/src/components/button/test/icon/button.e2e.ts-snapshots/button-icon-ios-rtl-Mobile-Chrome-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 6aea0270e88..949ff795373 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 f393e6f367e..fc19f212bbc 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 8a8bbcfd99c..785048f644c 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 747e33643b1..37eca67a82e 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 18c1ee8d031..4b7d0f2aff5 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.scss b/core/src/components/checkbox/checkbox.scss index 438add273d0..b99260dd8ed 100644 --- a/core/src/components/checkbox/checkbox.scss +++ b/core/src/components/checkbox/checkbox.scss @@ -1,4 +1,3 @@ -@import "../../themes/ionic.globals"; @import "./checkbox.vars.scss"; // Checkbox @@ -119,9 +118,16 @@ input { display: none; } +// Hide the native focus ring since we draw our own focus indicator. +:host(:focus) { + outline: none; +} + .native-wrapper { display: flex; + position: relative; + align-items: center; } @@ -152,6 +158,28 @@ input { opacity: 0; } +// Checkbox: Keyboard Focus +// --------------------------------------------- + +:host(.ion-focused) .native-wrapper::after { + @include border-radius(50%); + + display: block; + position: absolute; + + width: $checkbox-focus-ring-size; + height: $checkbox-focus-ring-size; + + background: $checkbox-background-color-focused; + + content: ""; + opacity: 0.2; + + // Centered with insets because `transform` is physical and breaks in RTL. + inset-block-start: calc(50% - #{$checkbox-focus-ring-size} / 2); + inset-inline-start: calc(50% - #{$checkbox-focus-ring-size} / 2); +} + // Checkbox Bottom Content // ---------------------------------------------------------------- diff --git a/core/src/components/checkbox/checkbox.tsx b/core/src/components/checkbox/checkbox.tsx index 99cb13c474a..a13bb3580be 100644 --- a/core/src/components/checkbox/checkbox.tsx +++ b/core/src/components/checkbox/checkbox.tsx @@ -1,6 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Build, Component, Element, Event, Host, Method, Prop, State, h } from '@stencil/core'; -import { checkInvalidState } from '@utils/forms'; +import { Build, Component, Element, Event, Host, Method, Prop, State, forceUpdate, h } from '@stencil/core'; +import { checkInvalidState, createItemMultipleInputsObserver } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes, renderHiddenInput } from '@utils/helpers'; import { createColorClasses, hostContext } from '@utils/theme'; @@ -37,6 +37,7 @@ export class Checkbox implements ComponentInterface { private errorTextId = `${this.inputId}-error-text`; private inheritedAttributes: Attributes = {}; private validationObserver?: MutationObserver; + private itemFocusObserver?: MutationObserver; @Element() el!: HTMLIonCheckboxElement; @@ -199,6 +200,8 @@ export class Checkbox implements ComponentInterface { // Always set initial state this.isInvalid = checkInvalidState(el); this.hasLabelContent = this.el.textContent !== ''; + + this.itemFocusObserver = createItemMultipleInputsObserver(el, () => forceUpdate(this)); } componentWillLoad() { @@ -210,11 +213,14 @@ export class Checkbox implements ComponentInterface { } disconnectedCallback() { - // Clean up validation observer to prevent memory leaks. if (this.validationObserver) { this.validationObserver.disconnect(); this.validationObserver = undefined; } + if (this.itemFocusObserver) { + this.itemFocusObserver.disconnect(); + this.itemFocusObserver = undefined; + } } /** @internal */ @@ -338,6 +344,8 @@ export class Checkbox implements ComponentInterface { } = this; const mode = getIonMode(this); const path = getSVGPath(mode, indeterminate); + const inItem = hostContext('ion-item', el); + const inMultipleInputsItem = hostContext('ion-item.item-multiple-inputs', el); renderHiddenInput(true, el, name, checked ? value : '', disabled); @@ -360,11 +368,14 @@ export class Checkbox implements ComponentInterface { onClick={this.onClick} class={createColorClasses(color, { [mode]: true, - 'in-item': hostContext('ion-item', el), + 'in-item': inItem, 'checkbox-checked': checked, 'checkbox-disabled': disabled, 'checkbox-indeterminate': indeterminate, interactive: true, + // A single-input item has the input cover and draws the indicator itself. + // A multi-input item has no cover, so each control draws its own. + 'ion-focusable': !inItem || inMultipleInputsItem, [`checkbox-justify-${justify}`]: justify !== undefined, [`checkbox-alignment-${alignment}`]: alignment !== undefined, [`checkbox-label-placement-${labelPlacement}`]: true, diff --git a/core/src/components/checkbox/checkbox.vars.scss b/core/src/components/checkbox/checkbox.vars.scss index 8c73dff6f8f..551eb479b38 100644 --- a/core/src/components/checkbox/checkbox.vars.scss +++ b/core/src/components/checkbox/checkbox.vars.scss @@ -1,5 +1,12 @@ +@import "../../themes/ionic.globals"; + /// @prop - Top margin of checkbox's label when in an item $checkbox-item-label-margin-top: 10px; /// @prop - Bottom margin of checkbox's label when in an item -$checkbox-item-label-margin-bottom: 10px; \ No newline at end of file +$checkbox-item-label-margin-bottom: 10px; + +/// @prop - Background color of focus indicator for checkbox when focused +$checkbox-background-color-focused: ion-color(primary, tint); + +$checkbox-focus-ring-size: 36px; diff --git a/core/src/components/checkbox/test/a11y/checkbox.e2e.ts b/core/src/components/checkbox/test/a11y/checkbox.e2e.ts index 6d0b6645ae5..989a7f97a96 100644 --- a/core/src/components/checkbox/test/a11y/checkbox.e2e.ts +++ b/core/src/components/checkbox/test/a11y/checkbox.e2e.ts @@ -25,6 +25,64 @@ configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, }); }); +/** + * These assert `ion-focusable`, not the rendered ring, because `ion-focused` + * relies on keyboard-mode detection that is flaky on WebKit. Gating is mode-independent. + */ +configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => { + test.describe(title('checkbox: focus indicator'), () => { + test('standalone checkbox should be focusable', async ({ page }) => { + await page.setContent( + ` + + Checkbox + + `, + config + ); + + const checkbox = page.locator('ion-checkbox'); + await expect(checkbox).toHaveClass(/ion-focusable/); + }); + + test('checkbox in a single-input item should not show its own focus indicator', async ({ page }) => { + await page.setContent( + ` + + + Checkbox + + + `, + config + ); + + const checkbox = page.locator('ion-checkbox'); + const item = page.locator('ion-item'); + await expect(checkbox).not.toHaveClass(/ion-focusable/); + await expect(item).toHaveClass(/ion-focusable/); + }); + + test('checkbox in a multi-input item should be focusable', async ({ page }) => { + await page.setContent( + ` + + + Checkbox 1 + Checkbox 2 + + + `, + config + ); + + const checkboxes = page.locator('ion-checkbox'); + await expect(checkboxes.nth(0)).toHaveClass(/ion-focusable/); + await expect(checkboxes.nth(1)).toHaveClass(/ion-focusable/); + }); + }); +}); + configs({ directions: ['ltr'] }).forEach(({ title, config, screenshot }) => { test.describe(title('checkbox: a11y'), () => { test.describe(title('checkbox: font scaling'), () => { diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts b/core/src/components/checkbox/test/basic/checkbox.e2e.ts index 733f229e4b6..61c5cf58ff6 100644 --- a/core/src/components/checkbox/test/basic/checkbox.e2e.ts +++ b/core/src/components/checkbox/test/basic/checkbox.e2e.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { configs, test } from '@utils/test/playwright'; +import { applyKeyboardFocus, configs, test } from '@utils/test/playwright'; configs().forEach(({ title, screenshot, config }) => { test.describe(title('checkbox: basic visual tests'), () => { @@ -47,7 +47,7 @@ configs().forEach(({ title, screenshot, config }) => { /** * This behavior does not vary across modes/directions */ -configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('checkbox: ionChange'), () => { test('should fire ionChange when interacting with checkbox', async ({ page }) => { await page.setContent( @@ -138,48 +138,6 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c }); test.describe(title('checkbox: ionFocus'), () => { - test('should not have visual regressions', async ({ page, pageUtils }) => { - await page.setContent( - ` - - -
- Unchecked -
- `, - config - ); - - await pageUtils.pressKeys('Tab'); - - const container = page.locator('#container'); - - await expect(container).toHaveScreenshot(screenshot(`checkbox-focus`)); - }); - - test('should not have visual regressions when interacting with checkbox in item', async ({ page, pageUtils }) => { - await page.setContent( - ` - - Unchecked - - `, - config - ); - - // Test focus with keyboard navigation - await pageUtils.pressKeys('Tab'); - - const item = page.locator('ion-item'); - - await expect(item).toHaveScreenshot(screenshot(`checkbox-in-item-focus`)); - }); - test('should fire ionFocus when checkbox is focused', async ({ page, pageUtils }) => { await page.setContent( ` @@ -328,3 +286,66 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c }); }); }); + +/** + * This behavior does not vary across directions + */ +configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('checkbox: focus visual'), () => { + test('should render focus indicator when unchecked', async ({ page }) => { + await page.setContent( + ` + + + +
+ Unchecked +
+
+ `, + config + ); + + const checkbox = page.locator('ion-checkbox'); + + await applyKeyboardFocus(page, checkbox); + + const container = page.locator('#container'); + + await expect(container).toHaveScreenshot(screenshot(`checkbox-focus`)); + }); + + test('should render focus indicator when checked', async ({ page }) => { + await page.setContent( + ` + + + +
+ Checked +
+
+ `, + config + ); + + const checkbox = page.locator('ion-checkbox'); + + await applyKeyboardFocus(page, checkbox); + + const container = page.locator('#container'); + + await expect(container).toHaveScreenshot(screenshot(`checkbox-focus-checked`)); + }); + }); +}); diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..9e8caf8ee43 Binary files /dev/null and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..a24d128c04b Binary files /dev/null and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-ios-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..5f268bb2df0 Binary files /dev/null and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-md-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..769ebd955ad Binary files /dev/null and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-md-ltr-Mobile-Firefox-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..b794065ad98 Binary files /dev/null and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-md-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..e96f191e15d Binary files /dev/null and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-checked-md-ltr-Mobile-Safari-linux.png differ 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..86c807e630c 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..e328672a417 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..fdfb755ea67 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-focus-md-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..112e7ada86f Binary files /dev/null and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-md-ltr-Mobile-Firefox-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..b3cc4169e01 Binary files /dev/null and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-md-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..5c86d926c0e Binary files /dev/null and b/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-focus-md-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 deleted file mode 100644 index 2c0dcdc0baa..00000000000 Binary files a/core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Chrome-linux.png and /dev/null differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts b/core/src/components/checkbox/test/item/checkbox.e2e.ts index 341839739b1..d2963d81204 100644 --- a/core/src/components/checkbox/test/item/checkbox.e2e.ts +++ b/core/src/components/checkbox/test/item/checkbox.e2e.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { configs, test } from '@utils/test/playwright'; +import { applyKeyboardFocus, configs, test } from '@utils/test/playwright'; configs().forEach(({ title, screenshot, config }) => { test.describe(title('checkbox: item with list'), () => { @@ -126,6 +126,113 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { await expect(list).toHaveScreenshot(screenshot(`checkbox-stacked-label-in-item`)); }); }); + + test.describe(title('checkbox: multiple inputs in item'), () => { + test('should not have visual regressions with multiple checkboxes in an item', async ({ page }) => { + await page.setContent( + ` + + + Checkbox 1 + Checkbox 2 + + + `, + config + ); + const list = page.locator('ion-list'); + await expect(list).toHaveScreenshot(screenshot(`checkbox-multiple-in-item`)); + }); + }); +}); + +/** + * This behavior does not vary across directions + */ +configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('checkbox: focus in item'), () => { + test('should render focus indicator in an item', async ({ page }) => { + await page.setContent( + ` + + + Unchecked + + + `, + config + ); + + const checkbox = page.locator('ion-checkbox'); + const item = page.locator('ion-item'); + + await applyKeyboardFocus(page, checkbox, item); + + await expect(item).toHaveScreenshot(screenshot(`checkbox-in-item-focus`)); + }); + + test('should render focus indicator for a checked checkbox in an item', async ({ page }) => { + await page.setContent( + ` + + + Checked + + + `, + config + ); + + const checkbox = page.locator('ion-checkbox'); + const item = page.locator('ion-item'); + + await applyKeyboardFocus(page, checkbox, item); + + await expect(item).toHaveScreenshot(screenshot(`checkbox-checked-in-item-focus`)); + }); + + test('should render focus indicator for a checkbox in a clickable item', async ({ page }) => { + await page.setContent( + ` + + + Unchecked + + + `, + config + ); + + const checkbox = page.locator('ion-checkbox'); + const item = page.locator('ion-item'); + + await applyKeyboardFocus(page, checkbox); + + await expect(item).toHaveScreenshot(screenshot(`checkbox-in-clickable-item-focus`)); + }); + + test('should render focus indicator for a checkbox in a multi-input item', async ({ page }) => { + await page.setContent( + ` + + + Checkbox 1 + Checkbox 2 + + + `, + config + ); + + const checkbox = page.locator('ion-checkbox').first(); + + await applyKeyboardFocus(page, checkbox); + + const item = page.locator('ion-item'); + + await expect(item).toHaveScreenshot(screenshot(`checkbox-multiple-in-item-focus`)); + }); + }); }); configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..1be06a94b4a Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..84b9edb9c57 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-ios-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..e84bc18b245 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-md-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..b47ebef469d Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-md-ltr-Mobile-Firefox-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c5e3a593471 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-md-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..8413dbc03de Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-checked-in-item-focus-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..5195cd52528 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..a8475f7d878 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-ios-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..e6fa988e194 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-md-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..252ec2fcbad Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-md-ltr-Mobile-Firefox-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..de728a0078b Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-md-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..7585764f621 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-clickable-item-focus-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..b7c449e6f9e Binary files /dev/null and b/core/src/components/checkbox/test/item/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-Firefox-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Firefox-linux.png similarity index 100% rename from core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Firefox-linux.png rename to core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Firefox-linux.png 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/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Safari-linux.png similarity index 100% rename from core/src/components/checkbox/test/basic/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Safari-linux.png rename to core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-ios-ltr-Mobile-Safari-linux.png diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-md-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..40d72cf339f Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-md-ltr-Mobile-Firefox-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..3101f22b855 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-md-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..247aba9eb13 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-in-item-focus-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..3d9e7f4f7a8 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..d7fc1740783 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-ios-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..987bc5d9b19 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-md-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..e919e470843 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-md-ltr-Mobile-Firefox-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..d5a636afb4a Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-md-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..393c0e0bcd6 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-focus-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..de660abf936 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..f5f925dabe6 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-ios-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..40be000b83a Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-md-ltr-Mobile-Chrome-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..82895bd4516 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-md-ltr-Mobile-Firefox-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..723a11e9d84 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-md-ltr-Mobile-Safari-linux.png b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..c5297bc2d64 Binary files /dev/null and b/core/src/components/checkbox/test/item/checkbox.e2e.ts-snapshots/checkbox-multiple-in-item-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/checkbox/test/item/index.html b/core/src/components/checkbox/test/item/index.html index 2548cbfaad0..60582e73abc 100644 --- a/core/src/components/checkbox/test/item/index.html +++ b/core/src/components/checkbox/test/item/index.html @@ -15,7 +15,7 @@ + + + + Header + + + + +

Content

+
+
+ `, + config + ); + + const scrollRegion = page.locator('ion-content .inner-scroll'); + const expectFlushWithTop = () => + expect(async () => { + expect(Math.abs((await scrollRegion.boundingBox())!.y)).toBeLessThanOrEqual(1); + }).toPass({ timeout: 5000 }); + + await expectFlushWithTop(); + + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '24px')); + + await expectFlushWithTop(); + }); + + test('should keep the scroll region flush with the viewport when the safe area bottom changes after load', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31337', + }); + + await page.setContent( + ` + + + +

Content

+
+ + + + Footer + + +
+ `, + config + ); + + const scrollRegion = page.locator('ion-content .inner-scroll'); + const viewportHeight = page.viewportSize()!.height; + const expectFlushWithBottom = () => + expect(async () => { + const box = (await scrollRegion.boundingBox())!; + expect(Math.abs(box.y + box.height - viewportHeight)).toBeLessThanOrEqual(1); + }).toPass({ timeout: 5000 }); + + await expectFlushWithBottom(); + + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-bottom', '24px')); + + await expectFlushWithBottom(); + }); + + test('should recompute the offsets when fullscreen is enabled after load', async ({ page }) => { + await page.setContent( + ` + + + + Header + + + + +

Content

+
+
+ `, + config + ); + + const content = page.locator('ion-content'); + const scrollRegion = page.locator('ion-content .inner-scroll'); + const expectFlushWithTop = () => + expect(async () => { + expect(Math.abs((await scrollRegion.boundingBox())!.y)).toBeLessThanOrEqual(1); + }).toPass({ timeout: 5000 }); + + await content.evaluate((el: HTMLIonContentElement) => (el.fullscreen = true)); + await expectFlushWithTop(); + + // Only the observer created by the fullscreen watcher can catch this. + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '24px')); + + await expectFlushWithTop(); + }); + + test('should leave the offsets at zero when fullscreen is disabled after load', async ({ page }) => { + await page.setContent( + ` + + + + Header + + + + +

Content

+
+
+ `, + config + ); + + const content = page.locator('ion-content'); + const offsetTop = () => content.evaluate((el) => el.style.getPropertyValue('--offset-top')); + + await content.evaluate((el: HTMLIonContentElement) => (el.fullscreen = false)); + + await expect(async () => { + expect(await offsetTop()).toBe('0px'); + }).toPass({ timeout: 5000 }); + + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '24px')); + await page.waitForTimeout(300); + + expect(await offsetTop()).toBe('0px'); + }); + + test('should keep the offsets while the page is hidden', async ({ page }) => { + await page.setContent( + ` + +
+ + + Header + + + + +

Content

+
+
+
+ `, + config + ); + + const content = page.locator('ion-content'); + const scrollRegion = page.locator('ion-content .inner-scroll'); + const offsetTop = () => content.evaluate((el) => el.style.getPropertyValue('--offset-top')); + + await expect(async () => { + expect(Math.abs((await scrollRegion.boundingBox())!.y)).toBeLessThanOrEqual(1); + }).toPass({ timeout: 5000 }); + + const beforeHiding = await offsetTop(); + expect(beforeHiding).toBe(`${Math.round((await page.locator('ion-header').boundingBox())!.height)}px`); + + await page.evaluate(() => document.getElementById('page')!.classList.add('ion-page-hidden')); + // Long enough that a missed visibility guard would have committed by now. + await page.waitForTimeout(300); + + expect(await offsetTop()).toBe(beforeHiding); + }); + }); +}); diff --git a/core/src/components/datetime-button/datetime-button.tsx b/core/src/components/datetime-button/datetime-button.tsx index af0a77f88e0..4b0b40fe857 100644 --- a/core/src/components/datetime-button/datetime-button.tsx +++ b/core/src/components/datetime-button/datetime-button.tsx @@ -7,7 +7,6 @@ import { createColorClasses } from '@utils/theme'; import { getIonMode } from '../../global/ionic-global'; import type { Color } from '../../interface'; import type { DatetimePresentation } from '../datetime/datetime-interface'; -import { getToday } from '../datetime/utils/data'; import { getLocalizedDateTime, getLocalizedTime } from '../datetime/utils/format'; import { getHourCycle } from '../datetime/utils/helpers'; import { parseDate } from '../datetime/utils/parse'; @@ -189,7 +188,7 @@ export class DatetimeButton implements ComponentInterface { * ion-datetime and then format it according * to the locale specified on ion-datetime. */ - private setDateTimeText = () => { + private setDateTimeText = async () => { const { datetimeEl, datetimePresentation } = this; if (!datetimeEl) { @@ -201,10 +200,12 @@ export class DatetimeButton implements ComponentInterface { const parsedValues = this.getParsedDateValues(value); /** - * Both ion-datetime and ion-datetime-button default - * to today's date and time if no value is set. + * Both ion-datetime and ion-datetime-button default to today's date and + * time if no value is set. We read the datetime's computed default so the + * button respects the same constraints (min, max, minuteValues, etc.) that + * the datetime applies to its own fallback, instead of using a raw "now". */ - const parsedDatetimes = parseDate(parsedValues.length > 0 ? parsedValues : [getToday()]); + const parsedDatetimes = parsedValues.length > 0 ? parseDate(parsedValues) : [await datetimeEl.getDefaultPart()]; if (!parsedDatetimes) { return; diff --git a/core/src/components/datetime-button/test/basic/datetime-button.e2e.ts b/core/src/components/datetime-button/test/basic/datetime-button.e2e.ts index 35088bce4a7..ca18c8a5e97 100644 --- a/core/src/components/datetime-button/test/basic/datetime-button.e2e.ts +++ b/core/src/components/datetime-button/test/basic/datetime-button.e2e.ts @@ -344,4 +344,146 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(page.locator('ion-datetime-button')).toContainText('Thu, November 02 01:22 AM'); }); }); + + test.describe(title('datetime-button: datetime constraints'), () => { + const fixedTime = new Date('2026-06-18T17:54:54.518Z'); + + const dateFormat = new Intl.DateTimeFormat('en-US', { + weekday: 'short', + month: 'long', + day: '2-digit', + }); + const timeFormat = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', + }); + + test.beforeEach(async ({ page }) => { + await page.clock.setFixedTime(fixedTime); + }); + + test('should default to exact current time with no constraints', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30183', + }); + + await page.setContent( + ` + + + + `, + config + ); + await page.locator('.datetime-ready').waitFor(); + + await expect(page.locator('#date-button')).toContainText(dateFormat.format(fixedTime)); + await expect(page.locator('#time-button')).toContainText(timeFormat.format(fixedTime)); + }); + + test('should obey minuteValues constraint', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30183', + }); + + await page.setContent( + ` + + + + `, + config + ); + await page.locator('.datetime-ready').waitFor(); + + const expectedTime = new Date(fixedTime); + expectedTime.setMinutes(0); + + await expect(page.locator('#time-button')).toContainText(timeFormat.format(expectedTime)); + }); + + test('should obey hourValues constraint', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30183', + }); + + await page.setContent( + ` + + + + `, + config + ); + await page.locator('.datetime-ready').waitFor(); + + const expectedTime = new Date(fixedTime); + expectedTime.setHours(0); + + await expect(page.locator('#time-button')).toContainText(timeFormat.format(expectedTime)); + }); + + test('should obey monthValues constraint', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30183', + }); + + await page.setContent( + ` + + + + `, + config + ); + await page.locator('.datetime-ready').waitFor(); + + const expectedTime = new Date(fixedTime); + expectedTime.setMonth(0); + + await expect(page.locator('#date-button')).toContainText(dateFormat.format(expectedTime)); + }); + }); }); diff --git a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts index a626dd90855..16050dbbf1b 100644 --- a/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts +++ b/core/src/components/datetime-button/test/overlays/datetime-button.e2e.ts @@ -1,6 +1,6 @@ import type { Locator } from '@playwright/test'; import { expect } from '@playwright/test'; -import type { EventSpy } from '@utils/test/playwright'; +import type { E2EPage, EventSpy } from '@utils/test/playwright'; import { configs, test } from '@utils/test/playwright'; /** @@ -207,3 +207,108 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { }); }); }); + +/** + * This behavior does not vary across modes/directions + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('datetime-button: reopening modal'), () => { + let datetime: Locator; + let modal: Locator; + let monthYear: Locator; + let ionModalDidPresent: EventSpy; + let ionModalDidDismiss: EventSpy; + + test.beforeEach(async ({ page }) => { + await page.setContent( + ` + + + + + + `, + config + ); + + datetime = page.locator('ion-datetime'); + modal = page.locator('ion-modal'); + monthYear = datetime.locator('.calendar-month-year'); + ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + }); + + const openModal = async (page: E2EPage) => { + await page.click('#date-button'); + await ionModalDidPresent.next(); + await page.locator('ion-datetime.datetime-ready').waitFor(); + await page.waitForChanges(); + }; + + const dismissModal = async () => { + await modal.evaluate((el: HTMLIonModalElement) => el.dismiss()); + await ionModalDidDismiss.next(); + }; + + test('should keep the selected day in view when reopened', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31155', + }); + + const selectedDay = datetime.locator('.calendar-day-active'); + + await openModal(page); + await expect(selectedDay).toBeInViewport(); + + await dismissModal(); + await openModal(page); + + await expect(selectedDay).toBeInViewport(); + }); + + test('should navigate to the previous month when reopened', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31155', + }); + + await openModal(page); + await dismissModal(); + await openModal(page); + + await expect(monthYear).toHaveText('March 2022'); + + await datetime.locator('.calendar-next-prev ion-button').first().click(); + + await expect(monthYear).toHaveText('February 2022'); + }); + + test('should select a day from the month shown in the header when reopened', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31155', + }); + + await openModal(page); + await dismissModal(); + await openModal(page); + + await expect(monthYear).toHaveText('March 2022'); + + const ionChange = await page.spyOnEvent('ionChange'); + + /** + * Click the middle of the calendar instead of a day by name so Playwright + * doesn't scroll the target into view and hide a wrong scroll position. + */ + const calendarBody = (await datetime.locator('.calendar-body').boundingBox())!; + await page.mouse.click(calendarBody.x + calendarBody.width / 2, calendarBody.y + calendarBody.height / 2); + await ionChange.next(); + + const value = await datetime.evaluate((el: HTMLIonDatetimeElement) => el.value as string); + expect(value).toMatch(/^2022-03-/); + await expect(monthYear).toHaveText('March 2022'); + }); + }); +}); diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index f36cd66718d..fa3ac113890 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -8,6 +8,7 @@ import { isRTL } from '@utils/rtl'; import { createColorClasses } from '@utils/theme'; import { caretDownSharp, caretUpSharp, chevronBack, chevronDown, chevronForward } from 'ionicons/icons'; +import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; import type { Color, Mode, StyleEventDetail } from '../../interface'; @@ -139,6 +140,10 @@ export class Datetime implements ComponentInterface { * Set true only by `visibleCallback`. Lets `hiddenCallback` ignore the * synthetic "not intersecting" entry IntersectionObserver fires on * `observe()` when the host mounts offscreen. + * + * Don't reset this in `disconnectedCallback`. Overlays disconnect and + * reconnect the host without re-creating the observers, so a reset there + * makes `hiddenCallback` miss the dismissal. */ private hasBeenIntersecting = false; @@ -178,7 +183,7 @@ export class Datetime implements ComponentInterface { /** * The color to use from your application's color palette. * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. - * For more information on colors, see [theming](/docs/theming/basics). + * For more information on colors, refer to [theming](/docs/theming/basics). */ @Prop() color?: Color = 'primary'; @@ -604,6 +609,18 @@ export class Datetime implements ComponentInterface { } } + /** + * Returns the default parts the datetime falls back to when no value is set: + * today's date and time snapped to the closest value allowed by the + * component's constraints (`min`, `max`, and the `*Values` props). + * + * @internal + */ + @Method() + async getDefaultPart(): Promise { + return this.defaultParts; + } + private warnIfIncorrectValueUsage = () => { const { multiple, value } = this; if (!multiple && Array.isArray(value)) { @@ -1103,7 +1120,6 @@ export class Datetime implements ComponentInterface { this.clearFocusVisible = undefined; } this.loadTimeoutCleanup(); - this.hasBeenIntersecting = false; } /** @@ -1555,10 +1571,11 @@ export class Datetime implements ComponentInterface { const left = (nextMonth as HTMLElement).offsetWidth * 2; + const scrollMode = config.getBoolean('animated', true) ? 'smooth' : 'instant'; calendarBodyRef.scrollTo({ top: 0, left: left * (isRTL(this.el) ? -1 : 1), - behavior: 'smooth', + behavior: scrollMode, }); }; @@ -1575,10 +1592,11 @@ export class Datetime implements ComponentInterface { const left = (prevMonth as HTMLElement).offsetWidth * 2; + const scrollMode = config.getBoolean('animated', true) ? 'smooth' : 'instant'; calendarBodyRef.scrollTo({ top: 0, left: left * (isRTL(this.el) ? 1 : -1), - behavior: 'smooth', + behavior: scrollMode, }); }; @@ -1951,10 +1969,13 @@ export class Datetime implements ComponentInterface { month: ev.detail.value, }); - this.setActiveParts({ - ...activePart, - month: ev.detail.value, - }); + // Month wheel is navigation-only in multi-select mode as a fix for https://github.com/ionic-team/ionic-framework/issues/29673 + if (!this.multiple) { + this.setActiveParts({ + ...activePart, + month: ev.detail.value, + }); + } ev.stopPropagation(); }} @@ -1995,10 +2016,13 @@ export class Datetime implements ComponentInterface { year: ev.detail.value, }); - this.setActiveParts({ - ...activePart, - year: ev.detail.value, - }); + // Year wheel is navigation-only in multi-select mode as a fix for https://github.com/ionic-team/ionic-framework/issues/29673 + if (!this.multiple) { + this.setActiveParts({ + ...activePart, + year: ev.detail.value, + }); + } ev.stopPropagation(); }} diff --git a/core/src/components/datetime/test/basic/datetime.e2e.ts b/core/src/components/datetime/test/basic/datetime.e2e.ts index bbc6033374f..3d8c8f111f3 100644 --- a/core/src/components/datetime/test/basic/datetime.e2e.ts +++ b/core/src/components/datetime/test/basic/datetime.e2e.ts @@ -319,9 +319,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( ` @@ -336,15 +334,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/); }); }); }); @@ -814,3 +816,83 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { }); }); }); + +/** + * This behavior does not differ across + * modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('datetime: animated'), () => { + test('next month button should instantly replace month view when animations are disabled', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30484', + }); + + await page.addInitScript(() => { + (window as any).__scrollToCalls = []; + const original = Element.prototype.scrollTo; + Element.prototype.scrollTo = function (this: Element, ...args: any[]) { + if (this.classList?.contains('calendar-body')) { + (window as any).__scrollToCalls.push(args[0]); + } + return (original as any).apply(this, args); + }; + }); + + await page.setContent( + ` + + + `, + config + ); + await page.locator('.datetime-ready').waitFor(); + + const nextMonthButton = page.locator('ion-datetime .calendar-next-prev ion-button').nth(1); + await nextMonthButton.click(); + await page.waitForChanges(); + + const scrollCalls = await page.evaluate(() => (window as any).__scrollToCalls); + + expect(scrollCalls.length).toBeGreaterThan(0); + expect(scrollCalls[0].behavior).toBe('instant'); + }); + + test('previous month button should instantly replace month view when animations are disabled', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30484', + }); + + await page.addInitScript(() => { + (window as any).__scrollToCalls = []; + const original = Element.prototype.scrollTo; + Element.prototype.scrollTo = function (this: Element, ...args: any[]) { + if (this.classList?.contains('calendar-body')) { + (window as any).__scrollToCalls.push(args[0]); + } + return (original as any).apply(this, args); + }; + }); + + await page.setContent( + ` + + + `, + config + ); + await page.locator('.datetime-ready').waitFor(); + + const prevMonthButton = page.locator('ion-datetime .calendar-next-prev ion-button').nth(0); + await prevMonthButton.click(); + await page.waitForChanges(); + + const scrollCalls = await page.evaluate(() => (window as any).__scrollToCalls); + + expect(scrollCalls.length).toBeGreaterThan(0); + expect(scrollCalls[0].behavior).toBe('instant'); + }); + }); +}); diff --git a/core/src/components/datetime/test/multiple/datetime.e2e.ts b/core/src/components/datetime/test/multiple/datetime.e2e.ts index 55a386f6f27..22fada77bda 100644 --- a/core/src/components/datetime/test/multiple/datetime.e2e.ts +++ b/core/src/components/datetime/test/multiple/datetime.e2e.ts @@ -337,3 +337,124 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { }); }); }); + +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe( + title('datetime: interaction between month/year wheels and selected date(s) with different values of multiple'), + () => { + test('using month wheel should change selection when multiple=false', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29673', + }); + + await page.setContent(``, config); + const datetime = page.locator('ion-datetime'); + + await page.waitForSelector('.datetime-ready'); + + const dropdownButton = page.locator('.calendar-month-year-toggle'); + await dropdownButton.click(); + await page.waitForChanges(); + + const monthOption = page.locator('.month-column ion-picker-column-option').nth(0); + await monthOption.click(); + await page.waitForChanges(); + + await expect(datetime).toHaveJSProperty('value', '2026-01-26T10:36:00'); + }); + + test('using month wheel should not change selection when multiple=true', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29673', + }); + + await page.setContent( + ` + + + `, + config + ); + const datetime = page.locator('ion-datetime'); + + await page.waitForSelector('.datetime-ready'); + + const dropdownButton = page.locator('.calendar-month-year-toggle'); + await dropdownButton.click(); + await page.waitForChanges(); + + const monthOption = page.locator('.month-column ion-picker-column-option').nth(0); + await monthOption.click(); + await page.waitForChanges(); + + await expect(datetime).toHaveJSProperty('value', [ + '2026-06-26T10:36:00', + '2026-06-28T10:36:00', + '2026-01-01T10:36:00', + ]); + }); + + test('using year wheel should change selection when multiple=false', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29673', + }); + + await page.setContent(``, config); + const datetime = page.locator('ion-datetime'); + + await page.waitForSelector('.datetime-ready'); + + const dropdownButton = page.locator('.calendar-month-year-toggle'); + await dropdownButton.click(); + await page.waitForChanges(); + + const yearOption = page.locator('.year-column ion-picker-column-option').nth(0); + await yearOption.click(); + await page.waitForChanges(); + + await expect(datetime).toHaveJSProperty('value', '1926-06-26T10:36:00'); + }); + + test('using year wheel should not change selection when multiple=true', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29673', + }); + + await page.setContent( + ` + + + `, + config + ); + const datetime = page.locator('ion-datetime'); + + await page.waitForSelector('.datetime-ready'); + + const dropdownButton = page.locator('.calendar-month-year-toggle'); + await dropdownButton.click(); + await page.waitForChanges(); + + const yearOption = page.locator('.year-column ion-picker-column-option').nth(0); + await yearOption.click(); + await page.waitForChanges(); + + await expect(datetime).toHaveJSProperty('value', [ + '2026-06-26T10:36:00', + '2026-06-28T10:36:00', + '2026-01-01T10:36:00', + ]); + }); + } + ); +}); diff --git a/core/src/components/fab-button/fab-button.tsx b/core/src/components/fab-button/fab-button.tsx index 2a7a4a6198a..ecb1f57c784 100755 --- a/core/src/components/fab-button/fab-button.tsx +++ b/core/src/components/fab-button/fab-button.tsx @@ -1,8 +1,9 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Prop, h } from '@stencil/core'; +import { Component, Element, Event, Host, Prop, Watch, h } from '@stencil/core'; import type { AnchorInterface, ButtonInterface } from '@utils/element-interface'; -import { inheritAriaAttributes } from '@utils/helpers'; +import { inheritAriaAttributes, hasShadowDom } from '@utils/helpers'; import type { Attributes } from '@utils/helpers'; +import { printIonWarning } from '@utils/logging'; import { createColorClasses, hostContext, openURL } from '@utils/theme'; import { close } from 'ionicons/icons'; @@ -26,6 +27,8 @@ import type { RouterDirection } from '../router/utils/interface'; }) export class FabButton implements ComponentInterface, AnchorInterface, ButtonInterface { private fab: HTMLIonFabElement | null = null; + private formButtonEl: HTMLButtonElement | null = null; + private formEl: HTMLFormElement | null = null; private inheritedAttributes: Attributes = {}; @Element() el!: HTMLElement; @@ -46,6 +49,13 @@ export class FabButton implements ComponentInterface, AnchorInterface, ButtonInt * If `true`, the user cannot interact with the fab button. */ @Prop() disabled = false; + @Watch('disabled') + disabledChanged() { + const { disabled } = this; + if (this.formButtonEl) { + this.formButtonEl.disabled = disabled; + } + } /** * This attribute instructs browsers to download a URL instead of navigating to @@ -103,6 +113,11 @@ export class FabButton implements ComponentInterface, AnchorInterface, ButtonInt */ @Prop() type: 'submit' | 'reset' | 'button' = 'button'; + /** + * The HTML form element or form element id. Used to submit a form when the button is not a child of the form. + */ + @Prop() form?: string | HTMLFormElement; + /** * The size of the button. Set this to `small` in order to have a mini fab button. */ @@ -137,27 +152,96 @@ export class FabButton implements ComponentInterface, AnchorInterface, ButtonInt this.ionBlur.emit(); }; - private onClick = () => { - const { fab } = this; - if (!fab) { - return; + private onClick = (ev: Event) => { + const { el, fab } = this; + if (this.type !== 'button' && hasShadowDom(el)) { + this.submitForm(ev); + } + if (fab) { + fab.toggle(); } - - fab.toggle(); }; + /** + * The native button rendered inside `ion-fab-button` is in the Shadow DOM and + * does not participate in form submission, so a hidden native button is added + * to the associated form and clicked instead. + */ + private renderHiddenButton() { + const formEl = (this.formEl = this.findForm()); + if (formEl) { + const { formButtonEl } = this; + + // Reuse the existing hidden button, syncing values that changed since it was created. + if (formButtonEl !== null && formEl.contains(formButtonEl)) { + formButtonEl.disabled = this.disabled; + formButtonEl.type = this.type; + return; + } + + const newFormButtonEl = (this.formButtonEl = document.createElement('button')); + newFormButtonEl.type = this.type; + newFormButtonEl.style.display = 'none'; + newFormButtonEl.disabled = this.disabled; + formEl.appendChild(newFormButtonEl); + } + } + + // TODO(FW-7663): share the hidden button and form lookup with ion-button + private findForm(): HTMLFormElement | null { + const { form } = this; + if (form instanceof HTMLFormElement) { + return form; + } + if (typeof form === 'string') { + const el: HTMLElement | null = document.getElementById(form); + if (el) { + if (el instanceof HTMLFormElement) { + return el; + } else { + printIonWarning( + `[ion-fab-button] - Form with selector: "#${form}" could not be found. Verify that the id is attached to a
element.`, + this.el + ); + return null; + } + } else { + printIonWarning( + `[ion-fab-button] - Form with selector: "#${form}" could not be found. Verify that the id is correct and the form is rendered in the DOM.`, + this.el + ); + return null; + } + } + if (form !== undefined) { + printIonWarning( + `[ion-fab-button] - The provided "form" element is invalid. Verify that the form is a HTMLFormElement and rendered in the DOM.`, + this.el + ); + return null; + } + return this.el.closest('form'); + } + + private submitForm(ev: Event) { + if (this.formEl && this.formButtonEl) { + ev.preventDefault(); + this.formButtonEl.click(); + } + } + componentWillLoad() { this.inheritedAttributes = inheritAriaAttributes(this.el); } render() { - const { el, disabled, color, href, activated, show, translucent, size, inheritedAttributes } = this; + const { el, disabled, color, href, activated, show, translucent, size, inheritedAttributes, type } = this; const inList = hostContext('ion-fab-list', el); const mode = getIonMode(this); const TagType = href === undefined ? 'button' : ('a' as any); const attrs = TagType === 'button' - ? { type: this.type } + ? { type } : { download: this.download, href, @@ -165,6 +249,14 @@ export class FabButton implements ComponentInterface, AnchorInterface, ButtonInt target: this.target, }; + /** + * The hidden button is created during render so that setting `type` or + * `form` after the component loads still adds it to the form. + */ + if (type !== 'button') { + this.renderHiddenButton(); + } + return ( openURL(href, ev, this.routerDirection, this.routerAnimation)} + onClick={(ev: Event) => type === 'button' && openURL(href, ev, this.routerDirection, this.routerAnimation)} {...inheritedAttributes} > { + test.describe(title('fab-button: form'), () => { + test.describe('fab-button: submit functionality', () => { + test('should submit the closest form', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/18550', + }); + + await page.setContent( + ` + + Submit + + `, + config + ); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.click('ion-fab-button'); + + expect(submitEvent).toHaveReceivedEvent(); + }); + + test('should submit the form by id', async ({ page }) => { + await page.setContent( + ` +
+ Submit + `, + config + ); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.click('ion-fab-button'); + + expect(submitEvent).toHaveReceivedEvent(); + }); + + test('should submit the form by reference', async ({ page }) => { + await page.setContent( + ` +
+ Submit + + `, + config + ); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.click('ion-fab-button'); + + expect(submitEvent).toHaveReceivedEvent(); + }); + + test('should submit the closest form by pressing the `enter` key on a form input', async ({ page }) => { + /** + * Two inputs are used because a single-field form with no submit button + * submits implicitly on "Enter", which would pass without the hidden button. + */ + await page.setContent( + ` +
+ + + Submit +
+ `, + config + ); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.press('input', 'Enter'); + + expect(submitEvent).toHaveReceivedEvent(); + }); + + test('should not submit the closest form when button is disabled', async ({ page }) => { + await page.setContent( + ` +
+ + Submit +
+ `, + config + ); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.press('input', 'Enter'); + + expect(submitEvent).not.toHaveReceivedEvent(); + }); + + test('should submit the form by id when form is set async', async ({ page }) => { + await page.setContent( + ` +
+ Submit + `, + config + ); + + const fabButton = page.locator('ion-fab-button'); + await fabButton.evaluate((el: HTMLIonFabButtonElement) => (el.form = 'myForm')); + await page.waitForChanges(); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.click('ion-fab-button'); + + expect(submitEvent).toHaveReceivedEvent(); + }); + + test('should not submit the form after disabled is set async', async ({ page }) => { + /** + * A single input is used so the browser attempts implicit submission on + * "Enter", which is only suppressed when the hidden button is disabled. + */ + await page.setContent( + ` +
+ + Submit +
+ `, + config + ); + + const fabButton = page.locator('ion-fab-button'); + await fabButton.evaluate((el: HTMLIonFabButtonElement) => (el.disabled = true)); + await page.waitForChanges(); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.press('input', 'Enter'); + + expect(submitEvent).not.toHaveReceivedEvent(); + }); + + test('should not submit the closest form when type is the default', async ({ page }) => { + await page.setContent( + ` +
+ + Click +
+ `, + config + ); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.click('ion-fab-button'); + + expect(submitEvent).not.toHaveReceivedEvent(); + }); + + test('should submit the closest form and toggle the fab', async ({ page }) => { + await page.setContent( + ` +
+ + Submit + + Item + + +
+ `, + config + ); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.click('ion-fab > ion-fab-button'); + + expect(submitEvent).toHaveReceivedEvent(); + await expect(page.locator('ion-fab')).toHaveJSProperty('activated', true); + }); + + test('should submit the form when href is also set', async ({ page }) => { + await page.setContent( + ` +
+ Submit +
+ `, + config + ); + + const submitEvent = await page.spyOnEvent('submit'); + + await page.click('ion-fab-button'); + + expect(submitEvent).toHaveReceivedEvent(); + expect(await page.evaluate(() => window.location.hash)).not.toBe('#navigated'); + }); + }); + + test.describe('fab-button: reset functionality', () => { + test('should reset the form', async ({ page }) => { + await page.setContent( + ` +
+ + Reset +
+ `, + config + ); + + const input = page.locator('input'); + await input.fill('changed'); + expect(await input.inputValue()).toBe('changed'); + + await page.click('ion-fab-button'); + + expect(await input.inputValue()).toBe('initial'); + }); + + test('should reset the form after type is changed from submit to reset', async ({ page }) => { + await page.setContent( + ` +
+ + + Submit +
+ `, + config + ); + + const fabButton = page.locator('ion-fab-button'); + await fabButton.evaluate((el: HTMLIonFabButtonElement) => (el.type = 'reset')); + await page.waitForChanges(); + + const submitEvent = await page.spyOnEvent('submit'); + + const input = page.locator('input').first(); + await input.fill('changed'); + + await page.click('ion-fab-button'); + + expect(await input.inputValue()).toBe('initial'); + expect(submitEvent).not.toHaveReceivedEvent(); + }); + }); + }); + + test.describe(title('fab-button: form warnings'), () => { + test('form is a string selector', async ({ page }) => { + const logs: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'warning') { + logs.push(msg.text()); + } + }); + + await page.setContent(`Submit`, config); + + expect(logs.length).toBe(1); + expect(logs[0]).toContain( + '[Ionic Warning]: [ion-fab-button] - Form with selector: "#missingForm" could not be found. Verify that the id is correct and the form is rendered in the DOM.' + ); + }); + + test('form id does not belong to a form element', async ({ page }) => { + const logs: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'warning') { + logs.push(msg.text()); + } + }); + + await page.setContent( + ` +
+ Submit + `, + config + ); + + expect(logs.length).toBe(1); + expect(logs[0]).toContain( + '[Ionic Warning]: [ion-fab-button] - Form with selector: "#notAForm" could not be found. Verify that the id is attached to a
element.' + ); + }); + + test('form is an element reference', async ({ page }) => { + const logs: string[] = []; + + page.on('console', (msg) => { + if (msg.type() === 'warning') { + logs.push(msg.text()); + } + }); + + await page.setContent(`Submit`, config); + + const fabButton = page.locator('ion-fab-button'); + await fabButton.evaluate((el: HTMLIonFabButtonElement) => ((el as any).form = document.createElement('div'))); + await page.waitForChanges(); + + expect(logs.length).toBe(1); + expect(logs[0]).toContain( + '[Ionic Warning]: [ion-fab-button] - The provided "form" element is invalid. Verify that the form is a HTMLFormElement and rendered in the DOM.' + ); + }); + }); +}); diff --git a/core/src/components/fab-button/test/form/fab-button.spec.ts b/core/src/components/fab-button/test/form/fab-button.spec.ts new file mode 100644 index 00000000000..4d53161a5fa --- /dev/null +++ b/core/src/components/fab-button/test/form/fab-button.spec.ts @@ -0,0 +1,60 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { FabButton } from '../../fab-button'; + +describe('FabButton: Hidden Form Button', () => { + it('should not add multiple buttons to the form on re-render', async () => { + const page = await newSpecPage({ + components: [FabButton], + html: ` +
+ Submit + `, + }); + + const getButtons = () => { + return page.body.querySelectorAll('form button'); + }; + + const fabButton = page.body.querySelector('ion-fab-button')!; + + await page.waitForChanges(); + + expect(getButtons().length).toEqual(1); + + // Re-render the component + fabButton.color = 'danger'; + await page.waitForChanges(); + + expect(getButtons().length).toEqual(1); + }); + + it('should sync the type and disabled state to the hidden button', async () => { + const page = await newSpecPage({ + components: [FabButton], + html: ` +
+ Submit + `, + }); + + const getHiddenButton = () => { + return page.body.querySelector('form button')!; + }; + + const fabButton = page.body.querySelector('ion-fab-button')!; + + await page.waitForChanges(); + + expect(getHiddenButton().type).toEqual('submit'); + expect(getHiddenButton().disabled).toEqual(false); + + fabButton.type = 'reset'; + fabButton.disabled = true; + await page.waitForChanges(); + + expect(page.body.querySelectorAll('form button').length).toEqual(1); + expect(getHiddenButton().type).toEqual('reset'); + expect(getHiddenButton().disabled).toEqual(true); + }); +}); diff --git a/core/src/components/fab-button/test/form/index.html b/core/src/components/fab-button/test/form/index.html new file mode 100644 index 00000000000..652f986129a --- /dev/null +++ b/core/src/components/fab-button/test/form/index.html @@ -0,0 +1,76 @@ + + + + + FAB Button - Form Submit + + + + + + + + + + + + + FAB Button - Form Submit + + + + +

The FAB button below is inside the form. Clicking it should submit the form.

+
+ + + + + + + + + +
+ +

+ +

The FAB button below is outside the form but linked by id. Clicking it should also submit the form.

+
+ + + + + +
+ + + + + +

+ The fixed FAB below submits the form above it. A fixed FAB has to be a direct child of + ion-content for slot="fixed" to apply, so it cannot be nested in the form and needs + form to target it. +

+ + + + + + +
+
+ + + + diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx index c6b4f394599..7c46ca59d42 100644 --- a/core/src/components/input-otp/input-otp.tsx +++ b/core/src/components/input-otp/input-otp.tsx @@ -1,11 +1,10 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Fragment, Host, Prop, State, h, Watch } from '@stencil/core'; +import { Component, Element, Event, Fragment, Host, Method, Prop, State, h, Watch } from '@stencil/core'; 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/input/test/a11y/input.e2e.ts-snapshots/input-scale-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/input/test/a11y/input.e2e.ts-snapshots/input-scale-ios-ltr-Mobile-Chrome-linux.png index cd456e8fae3..2519546bcd4 100644 Binary files a/core/src/components/input/test/a11y/input.e2e.ts-snapshots/input-scale-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/input/test/a11y/input.e2e.ts-snapshots/input-scale-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/item-sliding/test/a11y/item-sliding.e2e.ts-snapshots/item-sliding-scale-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/item-sliding/test/a11y/item-sliding.e2e.ts-snapshots/item-sliding-scale-ios-ltr-Mobile-Chrome-linux.png index 97a7d364640..97c5195ac65 100644 Binary files a/core/src/components/item-sliding/test/a11y/item-sliding.e2e.ts-snapshots/item-sliding-scale-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/item-sliding/test/a11y/item-sliding.e2e.ts-snapshots/item-sliding-scale-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/item/item.scss b/core/src/components/item/item.scss index b17649265db..e51b931c610 100644 --- a/core/src/components/item/item.scss +++ b/core/src/components/item/item.scss @@ -369,7 +369,10 @@ a { text-overflow: ellipsis; + // Makes the wrapper a scroll container, which zeroes its automatic minimum + // size and lets long labels ellipsize overflow: inherit; + box-sizing: border-box; } @@ -384,6 +387,17 @@ a { flex-direction: column; } +// Item Focus Indicator Room +// -------------------------------------------------- +// Widens the clip boundary so a focus indicator on a control in the default +// slot isn't cut off. Scoped because the wider painted box overlaps end slot +// content on items with nothing overhanging + +:host(.item-focus-indicator-room) .input-wrapper { + @include margin-horizontal(-$item-input-focus-indicator-room); + @include padding-horizontal($item-input-focus-indicator-room); +} + // Item w/ Multiple Inputs // -------------------------------------------------- // Multiple inputs in an item should have the input @@ -415,4 +429,3 @@ a { ion-ripple-effect { color: var(--ripple-color); } - diff --git a/core/src/components/item/item.tsx b/core/src/components/item/item.tsx index 9e268e972f8..45eea6867d3 100644 --- a/core/src/components/item/item.tsx +++ b/core/src/components/item/item.tsx @@ -1,5 +1,5 @@ import type { ComponentInterface } from '@stencil/core'; -import { Component, Element, Host, Listen, Prop, State, Watch, forceUpdate, h } from '@stencil/core'; +import { Build, Component, Element, Host, Listen, Prop, State, Watch, forceUpdate, h } from '@stencil/core'; import type { AnchorInterface, ButtonInterface } from '@utils/element-interface'; import type { Attributes } from '@utils/helpers'; import { inheritAttributes, raf } from '@utils/helpers'; @@ -10,6 +10,8 @@ import { getIonMode } from '../../global/ionic-global'; import type { AnimationBuilder, Color, CssClassMap, StyleEventDetail } from '../../interface'; import type { RouterDirection } from '../router/utils/interface'; +const INDICATOR_CONTROL_SELECTOR = 'ion-checkbox, ion-radio, ion-toggle'; + /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * @@ -34,12 +36,15 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac private labelColorStyles = {}; private itemStyles = new Map(); private inheritedAriaAttributes: Attributes = {}; + private indicatorControlObserver?: MutationObserver; + private didLoad = false; @Element() el!: HTMLIonItemElement; @State() multipleInputs = false; @State() focusable = true; @State() isInteractive = false; + @State() hasSlottedIndicatorControl = false; /** * The color to use from your application's color palette. @@ -166,6 +171,15 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac connectedCallback() { this.hasStartEl(); + + /** + * `componentDidLoad` doesn't run again when the item is moved, so re-arm the + * observer and re-read the light DOM, which may have changed while detached. + */ + if (this.didLoad) { + this.watchForIndicatorControls(); + this.updateInteractivityOnSlotChange(); + } } componentWillLoad() { @@ -176,8 +190,19 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac raf(() => { this.setMultipleInputs(); this.setIsInteractive(); + this.setHasSlottedIndicatorControl(); this.focusable = this.isFocusable(); }); + + this.watchForIndicatorControls(); + this.didLoad = true; + } + + disconnectedCallback() { + if (this.indicatorControlObserver) { + this.indicatorControlObserver.disconnect(); + this.indicatorControlObserver = undefined; + } } private totalNestedInputs() { @@ -222,10 +247,61 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac this.isInteractive = covers.length > 0 || inputs.length > 0 || clickables.length > 0; } + /** + * `slotchange` only fires for nodes assigned directly to a slot, so a control + * inside a slotted wrapper (`
`) never reaches + * `updateInteractivityOnSlotChange`. The light DOM is observed instead. + * + * The callback runs the whole handler because a control below a wrapper can also + * make the item multi-input, which is what decides whether the controls draw + * their own indicator at all. + * + * `:host(:has())` would avoid the observer, but the `:has()` fallback in + * `core.scss` is still open as FW-6106 and it's unreliable for slotted content + * in Android WebView. Worth revisiting when FW-6106 closes. + */ + private watchForIndicatorControls() { + if (!Build.isBrowser || typeof MutationObserver === 'undefined') { + return; + } + + // `Node.moveBefore` relocates the item without either callback firing, so + // never leave a previous observer behind + this.indicatorControlObserver?.disconnect(); + + this.indicatorControlObserver = new MutationObserver((records) => { + // The subtree observer also fires for text and hidden input churn, so only + // re-read the DOM when a control was added or removed + if (records.some(touchesIndicatorControl)) { + this.updateInteractivityOnSlotChange(); + } + }); + this.indicatorControlObserver.observe(this.el, { childList: true, subtree: true }); + } + + // These controls paint a focus indicator that overhangs their own bounds, and + // only the default slot is clipped, so only a control there needs extra room. + private setHasSlottedIndicatorControl() { + const controls = this.el.querySelectorAll(INDICATOR_CONTROL_SELECTOR); + + this.hasSlottedIndicatorControl = Array.from(controls).some((control) => { + // The control isn't always a direct child, so walk up to the element the item + // slots, which is the one carrying the slot name. + let slotted: HTMLElement | null = control; + + while (slotted !== null && slotted.parentElement !== this.el) { + slotted = slotted.parentElement; + } + + return slotted !== null && !slotted.getAttribute('slot'); + }); + } + // slot change listener updates state to reflect how/if item should be interactive private updateInteractivityOnSlotChange = () => { this.setIsInteractive(); this.setMultipleInputs(); + this.setHasSlottedIndicatorControl(); }; // If the item contains an input including a checkbox, datetime, select, or radio @@ -361,6 +437,13 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac const firstInteractiveNeedsPointerCursor = firstInteractive !== undefined && !['ION-INPUT', 'ION-TEXTAREA'].includes(firstInteractive.tagName); + /** + * A control in a single-input item defers its indicator to the item, so there's + * nothing to clip and nothing to make room for. It draws its own indicator in a + * multi-input item, and in a clickable item, which is a second tab stop. + */ + const slottedIndicatorNeedsRoom = this.hasSlottedIndicatorControl && (multipleInputs || this.isClickable()); + return ( { + if (node.nodeType !== Node.ELEMENT_NODE) { + return false; + } + + const el = node as Element; + + return el.matches(INDICATOR_CONTROL_SELECTOR) || el.querySelector(INDICATOR_CONTROL_SELECTOR) !== null; +}; + +const touchesIndicatorControl = (record: MutationRecord): boolean => + Array.from(record.addedNodes).some(isIndicatorControl) || Array.from(record.removedNodes).some(isIndicatorControl); diff --git a/core/src/components/item/item.vars.scss b/core/src/components/item/item.vars.scss index d0ebad2ee19..2c48c677f05 100644 --- a/core/src/components/item/item.vars.scss +++ b/core/src/components/item/item.vars.scss @@ -2,3 +2,7 @@ // Item // -------------------------------------------------- + +// Room for the focus indicator to paint outside a slotted control. Widest +// overhang is the iOS radio: a 36px indicator around a 15px icon. +$item-input-focus-indicator-room: 12px; diff --git a/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-ltr-Mobile-Chrome-linux.png index c201fda4a0d..4f62e8d5248 100644 Binary files a/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-ltr-Mobile-Chrome-linux.png differ 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 6c4731aa58a..675045fb8bd 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-Chrome-linux.png b/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-rtl-Mobile-Chrome-linux.png index 258a51ef0d9..88808118bba 100644 Binary files a/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-rtl-Mobile-Chrome-linux.png and b/core/src/components/item/test/buttons/item.e2e.ts-snapshots/item-buttons-diff-ios-rtl-Mobile-Chrome-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 31188e11458..845742be7ff 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 76aea912c2e..1e5ef5b04c6 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-Chrome-linux.png b/core/src/components/item/test/colors/item.e2e.ts-snapshots/item-colors-diff-ios-ltr-Mobile-Chrome-linux.png index 8537c8fe151..cb7a98d9657 100644 Binary files a/core/src/components/item/test/colors/item.e2e.ts-snapshots/item-colors-diff-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/item/test/colors/item.e2e.ts-snapshots/item-colors-diff-ios-ltr-Mobile-Chrome-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 891efa258fb..10618efdeb4 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/item.spec.tsx b/core/src/components/item/test/item.spec.tsx index ed82925d3a4..f07b2a58461 100644 --- a/core/src/components/item/test/item.spec.tsx +++ b/core/src/components/item/test/item.spec.tsx @@ -19,4 +19,59 @@ describe('item', () => { // Check if it has the expected class that gives the focused styles to the item element expect(item).toHaveClass('ion-focusable'); }); + + describe('focus indicator room', () => { + const newItemPage = async (html: string) => { + const page = await newSpecPage({ components: [Item], html }); + + // The slotted-control check runs in a raf after load, so let the frame + // callback fire before asserting on the rendered class. + await new Promise((resolve) => setTimeout(resolve, 0)); + await page.waitForChanges(); + + return page.body.querySelector('ion-item')!; + }; + + it('should reserve room for a control in the default slot of a multi-input item', async () => { + const item = await newItemPage( + `OneTwo` + ); + + expect(item).toHaveClass('item-focus-indicator-room'); + }); + + it('should reserve room for a control in the default slot of a clickable item', async () => { + const item = await newItemPage(`Label`); + + expect(item).toHaveClass('item-focus-indicator-room'); + }); + + it('should reserve room for a control nested below the slotted element', async () => { + const item = await newItemPage(`
Label
`); + + expect(item).toHaveClass('item-focus-indicator-room'); + }); + + // A lone control has the item's input cover and lets the item draw the indicator, + // so there's nothing overhanging the wrapper to make room for. + it('should not reserve room for a control that defers its indicator to the item', async () => { + const item = await newItemPage(`Label`); + + expect(item).not.toHaveClass('item-focus-indicator-room'); + }); + + it('should not reserve room for controls outside the default slot', async () => { + const item = await newItemPage( + `StartTextEnd` + ); + + expect(item).not.toHaveClass('item-focus-indicator-room'); + }); + + it('should not reserve room when no control is slotted', async () => { + const item = await newItemPage(`Label`); + + expect(item).not.toHaveClass('item-focus-indicator-room'); + }); + }); }); 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/item/test/slotted-inputs/item.e2e.ts b/core/src/components/item/test/slotted-inputs/item.e2e.ts index 7fcbf4b8b1e..b84dfc50abb 100644 --- a/core/src/components/item/test/slotted-inputs/item.e2e.ts +++ b/core/src/components/item/test/slotted-inputs/item.e2e.ts @@ -172,3 +172,64 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { }); }); }); + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('item: focus indicator room'), () => { + test('should reserve room for a control added inside a slotted wrapper', async ({ page }) => { + // A control can appear inside an already slotted wrapper after the item renders, + // and the item still has to make room for its focus indicator. + await page.setContent( + ` + +
+
+ `, + config + ); + + const item = page.locator('ion-item'); + + await expect(item).not.toHaveClass(/item-focus-indicator-room/); + + await page.evaluate(() => { + document.querySelector('#wrapper')!.appendChild(document.createElement('ion-toggle')); + }); + + await expect(item).toHaveClass(/item-focus-indicator-room/); + + await page.evaluate(() => { + document.querySelector('ion-toggle')!.remove(); + }); + + await expect(item).not.toHaveClass(/item-focus-indicator-room/); + }); + + test('should mark the item multi-input when a control is added inside a slotted wrapper', async ({ page }) => { + // Whether a control draws its own indicator depends on this class, so it has to + // track the same mutations the room does. + await page.setContent( + ` + + One +
+
+ `, + config + ); + + const item = page.locator('ion-item'); + + await expect(item).not.toHaveClass(/item-multiple-inputs/); + + await page.evaluate(() => { + document.querySelector('#wrapper')!.appendChild(document.createElement('ion-checkbox')); + }); + + await expect(item).toHaveClass(/item-multiple-inputs/); + await expect(item).toHaveClass(/item-focus-indicator-room/); + }); + }); +}); diff --git a/core/src/components/label/test/a11y/label.e2e.ts-snapshots/label-scale-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/label/test/a11y/label.e2e.ts-snapshots/label-scale-ios-ltr-Mobile-Chrome-linux.png index a86392a5c6f..e825fe5a140 100644 Binary files a/core/src/components/label/test/a11y/label.e2e.ts-snapshots/label-scale-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/label/test/a11y/label.e2e.ts-snapshots/label-scale-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/label/test/headings/label.e2e.ts-snapshots/item-headings-inherit-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/label/test/headings/label.e2e.ts-snapshots/item-headings-inherit-ios-ltr-Mobile-Chrome-linux.png index 8c5b00c98d0..6791c79f89e 100644 Binary files a/core/src/components/label/test/headings/label.e2e.ts-snapshots/item-headings-inherit-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/label/test/headings/label.e2e.ts-snapshots/item-headings-inherit-ios-ltr-Mobile-Chrome-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/menu.tsx b/core/src/components/menu/menu.tsx index 4769f609914..4bf4564c40f 100644 --- a/core/src/components/menu/menu.tsx +++ b/core/src/components/menu/menu.tsx @@ -150,7 +150,7 @@ export class Menu implements ComponentInterface, MenuI { @Watch('side') protected sideChanged() { - this.isEndSide = isEnd(this.side); + this.isEndSide = isEnd(this.side, this.el); /** * Menu direction animation is calculated based on the document direction. * If the document direction changes, we need to create a new animation. @@ -499,7 +499,7 @@ export class Menu implements ComponentInterface, MenuI { * Menu direction animation is calculated based on the document direction. * If the document direction changes, we need to create a new animation. */ - const isEndSide = isEnd(this.side); + const isEndSide = isEnd(this.side, this.el); if (width === this.width && this.animation !== undefined && isEndSide === this.isEndSide) { return; } 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/refresher-scroll-target/index.html b/core/src/components/modal/test/refresher-scroll-target/index.html new file mode 100644 index 00000000000..12206de68df --- /dev/null +++ b/core/src/components/modal/test/refresher-scroll-target/index.html @@ -0,0 +1,136 @@ + + + + + Modal - Refresher with Custom Scroll Target + + + + + + + + + + + + + + + +
+ + + Refresher with Custom Scroll Target + + + + + + + + + + +
+
+ + + + diff --git a/core/src/components/modal/test/refresher-scroll-target/modal.e2e.ts b/core/src/components/modal/test/refresher-scroll-target/modal.e2e.ts new file mode 100644 index 00000000000..c2950ec5e03 --- /dev/null +++ b/core/src/components/modal/test/refresher-scroll-target/modal.e2e.ts @@ -0,0 +1,189 @@ +import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; +import { configs, dragElementByYAxis, test } from '@utils/test/playwright'; + +const dragDownOnScrollHost = async (page: E2EPage, dragByY = 250) => { + // The refresher creates its gesture asynchronously, so wait for it to hydrate. + await page.locator('ion-modal ion-refresher.hydrated').waitFor({ state: 'attached' }); + + const scrollHost = page.locator('ion-modal .ion-content-scroll-host'); + const box = (await scrollHost.boundingBox())!; + + // Start near the top of the host so the drag stays within smaller viewports. + await dragElementByYAxis(scrollHost, page, dragByY, box.y + 5); +}; + +/** + * This behavior does not vary across directions. + */ +configs({ directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('sheet modal: refresher with custom scroll target'), () => { + test.beforeEach(async ({ page }) => { + await page.goto('/src/components/modal/test/refresher-scroll-target', config); + }); + + test('should refresh instead of dismissing when pulling down on the custom scroll target', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31332', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + const ionRefresh = await page.spyOnEvent('ionRefresh'); + + await page.click('#sheet'); + await ionModalDidPresent.next(); + + await dragDownOnScrollHost(page); + + await ionRefresh.next(); + expect(ionModalDidDismiss).toHaveReceivedEventTimes(0); + }); + + test('should refresh instead of dismissing when expandToScroll is disabled', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31332', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + const ionRefresh = await page.spyOnEvent('ionRefresh'); + + await page.click('#sheet-no-expand'); + await ionModalDidPresent.next(); + + await dragDownOnScrollHost(page); + + await ionRefresh.next(); + expect(ionModalDidDismiss).toHaveReceivedEventTimes(0); + }); + + test('should move the sheet rather than refresh when dragging the content at a partial breakpoint', async ({ + page, + }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange'); + const ionRefresh = await page.spyOnEvent('ionRefresh'); + + await page.click('#sheet-breakpoints'); + await ionModalDidPresent.next(); + + await dragDownOnScrollHost(page); + + await ionBreakpointDidChange.next(); + expect(ionRefresh).toHaveReceivedEventTimes(0); + }); + + test('should refresh rather than move the sheet at a partial breakpoint when expandToScroll is disabled', async ({ + page, + }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange'); + const ionRefresh = await page.spyOnEvent('ionRefresh'); + + await page.click('#sheet-breakpoints-no-expand'); + await ionModalDidPresent.next(); + + await dragDownOnScrollHost(page); + + await ionRefresh.next(); + expect(ionBreakpointDidChange).toHaveReceivedEventTimes(0); + }); + + test('should keep the content opted out of scrolling after the sheet snaps back', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31332', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange'); + const ionRefresh = await page.spyOnEvent('ionRefresh'); + + await page.click('#sheet-breakpoints'); + await ionModalDidPresent.next(); + + const content = page.locator('ion-modal ion-content'); + await expect(content).toHaveJSProperty('scrollY', false); + + /** + * Expanding to the top breakpoint is what used to force scrolling back on. + * Waiting on the breakpoint change also proves the drag moved the sheet, so + * this cannot pass by leaving the content untouched. + */ + const wrapper = (await page.locator('ion-modal .modal-wrapper').boundingBox())!; + await dragElementByYAxis(page.locator('ion-modal .modal-handle'), page, -Math.round(wrapper.height * 0.3)); + await ionBreakpointDidChange.next(); + + await expect(content).toHaveJSProperty('scrollY', false); + + await dragDownOnScrollHost(page); + + await ionRefresh.next(); + }); + + test('should keep the content opted out of scrolling when expandToScroll is disabled', async ({ page }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#sheet-breakpoints-no-expand'); + await ionModalDidPresent.next(); + + const content = page.locator('ion-modal ion-content'); + await expect(content).toHaveJSProperty('scrollY', false); + + // With expandToScroll disabled the sheet restores scrolling at every breakpoint. + await dragElementByYAxis(page.locator('ion-modal .modal-handle'), page, 60); + + await expect(content).toHaveJSProperty('scrollY', false); + }); + + test('should still dismiss when dragging the handle', async ({ page }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + + await page.click('#sheet-no-expand'); + await ionModalDidPresent.next(); + + await dragElementByYAxis(page.locator('ion-modal .modal-handle'), page, 500); + + await ionModalDidDismiss.next(); + }); + }); +}); + +/** + * Card modals are only available in iOS mode. + * This behavior does not vary across directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('card modal: refresher with custom scroll target'), () => { + test.beforeEach(async ({ page }) => { + await page.goto('/src/components/modal/test/refresher-scroll-target', config); + }); + + test('should refresh instead of dismissing when pulling down on the custom scroll target', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31332', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + const ionRefresh = await page.spyOnEvent('ionRefresh'); + + await page.click('#card'); + await ionModalDidPresent.next(); + + await dragDownOnScrollHost(page); + + await ionRefresh.next(); + expect(ionModalDidDismiss).toHaveReceivedEventTimes(0); + }); + }); +}); diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts b/core/src/components/modal/test/safe-area/modal.e2e.ts index 8f13349fe95..e247b174956 100644 --- a/core/src/components/modal/test/safe-area/modal.e2e.ts +++ b/core/src/components/modal/test/safe-area/modal.e2e.ts @@ -257,6 +257,62 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config expect(offsetTop).toBe(`${TEST_SAFE_AREA_TOP}px`); }); + test('sheet modal should update --ion-modal-offset-top when the safe area top changes after present', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31337', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#sheet-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + const offsetTop = () => + modal.evaluate((el: HTMLIonModalElement) => el.style.getPropertyValue('--ion-modal-offset-top')); + + expect(await offsetTop()).toBe(`${TEST_SAFE_AREA_TOP}px`); + + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '24px')); + + await expect(async () => { + expect(await offsetTop()).toBe('24px'); + }).toPass({ timeout: 5000 }); + }); + + /** + * Covers a sheet presented before the inset is known, which is the Android + * edge-to-edge startup order rather than the 47px the test page declares. + */ + test('sheet modal should pick up the safe area top when it starts at zero', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31337', + }); + + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '0px')); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#sheet-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + const offsetTop = () => + modal.evaluate((el: HTMLIonModalElement) => el.style.getPropertyValue('--ion-modal-offset-top')); + + expect(await offsetTop()).toBe('0px'); + + await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '24px')); + + await expect(async () => { + expect(await offsetTop()).toBe('24px'); + }).toPass({ timeout: 5000 }); + }); + test('fullscreen modal safe-area should update on resize from phone to tablet', async ({ page }, testInfo) => { testInfo.annotations.push({ type: 'issue', diff --git a/core/src/components/modal/test/sheet/modal.e2e.ts b/core/src/components/modal/test/sheet/modal.e2e.ts index 7a1e013bfed..74e6e13431e 100644 --- a/core/src/components/modal/test/sheet/modal.e2e.ts +++ b/core/src/components/modal/test/sheet/modal.e2e.ts @@ -127,6 +127,20 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => expect(breakpoint).toBe(0.5); }); + test('should give scrolling back to the content when expanded to the top breakpoint', async ({ page }) => { + const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange'); + const modal = page.locator('.modal-sheet'); + const content = modal.locator('ion-content'); + + // The sheet starts partially open, so the content doesn't scroll yet. + await expect(content).toHaveJSProperty('scrollY', false); + + await modal.evaluate((el: HTMLIonModalElement) => el.setCurrentBreakpoint(1)); + await ionBreakpointDidChange.next(); + + await expect(content).toHaveJSProperty('scrollY', true); + }); + test('should emit ionBreakpointDidChange', async ({ page }) => { const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange'); const modal = page.locator('.modal-sheet'); @@ -157,6 +171,61 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => }); }); + test('it should restore content scrolling on dismiss so a reused modal still scrolls', async ({ page }) => { + await page.goto('/src/components/modal/test/sheet', config); + + await page.setContent( + ` + + Open + + + Dismiss + + + + + `, + config + ); + + const modal = page.locator('ion-modal'); + const content = modal.locator('ion-content'); + const openButton = page.locator('#open-modal'); + const dismissButton = page.locator('#dismiss'); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange'); + + await openButton.click(); + await ionModalDidPresent.next(); + + // Below the top breakpoint, so the sheet has turned content scrolling off. + await expect(content).toHaveJSProperty('scrollY', false); + + await dismissButton.click(); + await ionModalDidDismiss.next(); + + /** + * An inline modal reuses this content on the next present. Leaving it + * switched off means the re-presented sheet reads that as the app's own + * setting and never scrolls again. + */ + await expect(content).toHaveJSProperty('scrollY', true); + + await openButton.click(); + await ionModalDidPresent.next(); + + await modal.evaluate((el: HTMLIonModalElement) => el.setCurrentBreakpoint(1)); + await ionBreakpointDidChange.next(); + + await expect(content).toHaveJSProperty('scrollY', true); + }); + test('it should reset the breakpoint value on dismiss', async ({ page }) => { await page.goto('/src/components/modal/test/sheet', config); test.info().annotations.push({ @@ -399,4 +468,51 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => 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/note/test/a11y/note.e2e.ts-snapshots/note-wrapping-label-scale-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/note/test/a11y/note.e2e.ts-snapshots/note-wrapping-label-scale-ios-ltr-Mobile-Chrome-linux.png index 9861dbc8e52..97644d6a078 100644 Binary files a/core/src/components/note/test/a11y/note.e2e.ts-snapshots/note-wrapping-label-scale-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/note/test/a11y/note.e2e.ts-snapshots/note-wrapping-label-scale-ios-ltr-Mobile-Chrome-linux.png differ 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-column/test/slots/picker-column.e2e.ts-snapshots/picker-long-prefix-suffix-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/picker-column/test/slots/picker-column.e2e.ts-snapshots/picker-long-prefix-suffix-ios-ltr-Mobile-Chrome-linux.png index 67dc703029e..89164ff8350 100644 Binary files a/core/src/components/picker-column/test/slots/picker-column.e2e.ts-snapshots/picker-long-prefix-suffix-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/picker-column/test/slots/picker-column.e2e.ts-snapshots/picker-long-prefix-suffix-ios-ltr-Mobile-Chrome-linux.png differ 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 8d10eceb5c9..94202a01d5c 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 1ce48a75cb6..cdeed3b7c7a 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/radio.ios.scss b/core/src/components/radio/radio.ios.scss index 775239a9ea1..f208cb083fb 100644 --- a/core/src/components/radio/radio.ios.scss +++ b/core/src/components/radio/radio.ios.scss @@ -44,25 +44,6 @@ opacity: $radio-ios-disabled-opacity; } -// iOS Radio: Keyboard Focus -// ----------------------------------------- - -:host(.ion-focused) .radio-icon::after { - @include border-radius(var(--inner-border-radius)); - @include position(-8px, null, null, -9px); - - display: block; - position: absolute; - - width: 36px; - height: 36px; - - background: $radio-ios-background-color-focused; - - content: ""; - opacity: 0.2; -} - // Radio Native Wrapper // ---------------------------------------------------------------- diff --git a/core/src/components/radio/radio.ios.vars.scss b/core/src/components/radio/radio.ios.vars.scss index 2c971126436..46f068f15a5 100644 --- a/core/src/components/radio/radio.ios.vars.scss +++ b/core/src/components/radio/radio.ios.vars.scss @@ -4,9 +4,6 @@ // iOS Radio // -------------------------------------------------- -/// @prop - Background color of focus indicator for radio when focused -$radio-ios-background-color-focused: ion-color(primary, tint); - /// @prop - Width of the radio icon $radio-ios-icon-width: dynamic-font(15px); diff --git a/core/src/components/radio/radio.md.scss b/core/src/components/radio/radio.md.scss index ba6296bcbc5..a057177ae77 100644 --- a/core/src/components/radio/radio.md.scss +++ b/core/src/components/radio/radio.md.scss @@ -76,24 +76,6 @@ opacity: $radio-md-icon-disabled-opacity; } -// Material Design Radio: Keyboard Focus -// ----------------------------------------- - -:host(.ion-focused) .radio-icon::after { - @include border-radius(var(--inner-border-radius)); - - display: block; - position: absolute; - - width: 36px; - height: 36px; - - background: $radio-md-background-color-focused; - - content: ""; - opacity: 0.2; -} - // Radio Native Wrapper // ---------------------------------------------------------------- diff --git a/core/src/components/radio/radio.md.vars.scss b/core/src/components/radio/radio.md.vars.scss index 99dca7c1c56..4ff1b9fd094 100644 --- a/core/src/components/radio/radio.md.vars.scss +++ b/core/src/components/radio/radio.md.vars.scss @@ -7,9 +7,6 @@ /// @prop - Color of the checked radio $radio-md-color-on: current-color(base); -/// @prop - Background color of focus indicator for radio when focused -$radio-md-background-color-focused: ion-color(primary, tint); - /// @prop - Color of the unchecked radio $radio-md-color-off: rgb($text-color-rgb, 0.60); diff --git a/core/src/components/radio/radio.scss b/core/src/components/radio/radio.scss index 8f875811e88..953397061e5 100644 --- a/core/src/components/radio/radio.scss +++ b/core/src/components/radio/radio.scss @@ -1,4 +1,3 @@ -@import "../../themes/ionic.globals"; @import "./radio.vars.scss"; // Radio @@ -53,6 +52,7 @@ input { @include visually-hidden(); } +// Hide the native focus ring since we draw our own focus indicator. :host(:focus) { outline: none; } @@ -80,6 +80,24 @@ input { width: auto; } +// Radio: Keyboard Focus +// --------------------------------------------- + +:host(.ion-focused) .radio-icon::after { + @include border-radius(var(--inner-border-radius)); + + display: block; + position: absolute; + + width: 36px; + height: 36px; + + background: $radio-background-color-focused; + + content: ""; + opacity: 0.2; +} + // Radio Wrapper // -------------------------------------------------- .radio-wrapper { diff --git a/core/src/components/radio/radio.tsx b/core/src/components/radio/radio.tsx index bb343c8c858..e05dc8c6c24 100644 --- a/core/src/components/radio/radio.tsx +++ b/core/src/components/radio/radio.tsx @@ -1,6 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core'; -import { isOptionSelected } from '@utils/forms'; +import { Component, Element, Event, Host, Method, Prop, State, Watch, forceUpdate, h } from '@stencil/core'; +import { createItemMultipleInputsObserver, isOptionSelected } from '@utils/forms'; import { addEventListener, removeEventListener } from '@utils/helpers'; import { createColorClasses, hostContext } from '@utils/theme'; @@ -27,6 +27,7 @@ import type { Color } from '../../interface'; export class Radio implements ComponentInterface { private inputId = `ion-rb-${radioButtonIds++}`; private radioGroup: HTMLIonRadioGroupElement | null = null; + private itemFocusObserver?: MutationObserver; @Element() el!: HTMLIonRadioElement; @@ -150,6 +151,8 @@ export class Radio implements ComponentInterface { this.updateState(); addEventListener(radioGroup, 'ionValueChange', this.updateState); } + + this.itemFocusObserver = createItemMultipleInputsObserver(this.el, () => forceUpdate(this)); } disconnectedCallback() { @@ -158,6 +161,10 @@ export class Radio implements ComponentInterface { removeEventListener(radioGroup, 'ionValueChange', this.updateState); this.radioGroup = null; } + if (this.itemFocusObserver) { + this.itemFocusObserver.disconnect(); + this.itemFocusObserver = undefined; + } } private updateState = () => { @@ -216,6 +223,7 @@ export class Radio implements ComponentInterface { const { checked, disabled, color, el, justify, labelPlacement, hasLabel, buttonTabindex, alignment } = this; const mode = getIonMode(this); const inItem = hostContext('ion-item', el); + const inMultipleInputsItem = hostContext('ion-item.item-multiple-inputs', el); return ( { - test.beforeEach(async ({ page, browserName }) => { + test.beforeEach(async ({ page }) => { await page.setContent( ` @@ -98,20 +98,13 @@ configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config ); - if (browserName === 'webkit') { - const radio = page.locator('#first-group ion-radio').first(); - /** - * Sometimes Safari does not focus the first radio. - * This is a workaround to ensure the first radio is focused. - * - * Wait for the first radio to be rendered before tabbing. - * This is necessary because the first radio may not be rendered - * when the page first loads. - * - * This would cause the first radio to be skipped when tabbing. - */ - await radio.waitFor(); - } + /** + * The group assigns `tabindex` in its `componentDidLoad` and each radio + * re-renders to pick it up. Tabbing before that lands on something else, + * and focus never comes back to the first radio. + */ + await expect(page.locator('#first-group ion-radio').first()).toHaveAttribute('tabindex', '0'); + await expect(page.locator('#second-group ion-radio').first()).toHaveAttribute('tabindex', '0'); }); test('tabbing should switch between radio groups', async ({ page, pageUtils }) => { @@ -150,6 +143,72 @@ configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, }); }); +/** + * These assert `ion-focusable`, not the rendered ring, because `ion-focused` + * relies on keyboard-mode detection that is flaky on WebKit. Gating is mode-independent. + */ +configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => { + test.describe(title('radio: focus indicator'), () => { + test('standalone radio should be focusable', async ({ page }) => { + await page.setContent( + ` + + + Radio + + + `, + config + ); + + const radio = page.locator('ion-radio'); + await expect(radio).toHaveClass(/ion-focusable/); + }); + + test('radio in a single-input item should not show its own focus indicator', async ({ page }) => { + await page.setContent( + ` + + + + Radio + + + + `, + config + ); + + const radio = page.locator('ion-radio'); + const item = page.locator('ion-item'); + await expect(radio).not.toHaveClass(/ion-focusable/); + await expect(item).toHaveClass(/ion-focusable/); + }); + + test('radio in a multi-input item should be focusable', async ({ page }) => { + await page.setContent( + ` + + + + Radio 1 + + + Radio 2 + + + + `, + config + ); + + const radios = page.locator('ion-radio'); + await expect(radios.nth(0)).toHaveClass(/ion-focusable/); + await expect(radios.nth(1)).toHaveClass(/ion-focusable/); + }); + }); +}); + /** * This behavior does not vary across directions */ diff --git a/core/src/components/radio/test/basic/radio.e2e.ts b/core/src/components/radio/test/basic/radio.e2e.ts new file mode 100644 index 00000000000..eae4c6947db --- /dev/null +++ b/core/src/components/radio/test/basic/radio.e2e.ts @@ -0,0 +1,69 @@ +import { expect } from '@playwright/test'; +import { applyKeyboardFocus, configs, test } from '@utils/test/playwright'; + +/** + * This behavior does not vary across directions + */ +configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('radio: focus visual'), () => { + test('should render focus indicator when unchecked', async ({ page }) => { + await page.setContent( + ` + + + +
+ + Unchecked + +
+
+ `, + config + ); + + const radio = page.locator('ion-radio'); + + await applyKeyboardFocus(page, radio); + + const container = page.locator('#container'); + + await expect(container).toHaveScreenshot(screenshot(`radio-focus`)); + }); + + test('should render focus indicator when checked', async ({ page }) => { + await page.setContent( + ` + + + +
+ + Checked + +
+
+ `, + config + ); + + const radio = page.locator('ion-radio'); + + await applyKeyboardFocus(page, radio); + + const container = page.locator('#container'); + + await expect(container).toHaveScreenshot(screenshot(`radio-focus-checked`)); + }); + }); +}); diff --git a/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..5ff92f5d5e3 Binary files /dev/null and b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..ab13bbaa13c Binary files /dev/null and b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-ios-ltr-Mobile-Safari-linux.png b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..f8c844b5351 Binary files /dev/null and b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-md-ltr-Mobile-Chrome-linux.png b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..af756eb1be7 Binary files /dev/null and b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-md-ltr-Mobile-Firefox-linux.png b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c0e80459e81 Binary files /dev/null and b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-md-ltr-Mobile-Safari-linux.png b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..e1f652a322a Binary files /dev/null and b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-checked-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..749bbf84999 Binary files /dev/null and b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..6a7c560e590 Binary files /dev/null and b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-ios-ltr-Mobile-Safari-linux.png b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..595e0f81d24 Binary files /dev/null and b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-md-ltr-Mobile-Chrome-linux.png b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..828d7ce2f16 Binary files /dev/null and b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-md-ltr-Mobile-Firefox-linux.png b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..ff068041cb7 Binary files /dev/null and b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-md-ltr-Mobile-Safari-linux.png b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..a89aa121456 Binary files /dev/null and b/core/src/components/radio/test/basic/radio.e2e.ts-snapshots/radio-focus-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/radio/test/item/index.html b/core/src/components/radio/test/item/index.html index 36e46779fd2..e33e518d156 100644 --- a/core/src/components/radio/test/item/index.html +++ b/core/src/components/radio/test/item/index.html @@ -15,7 +15,7 @@ + + + + + + + Range - Dual Knobs + + + + +

+ Every range below is assigned { lower: 20, upper: 80 }. The lower knob is purple while the upper + is blue. +

+ +

dualKnobs set on the component

+ + 20 - 80 + + +

dualKnobs assigned before value

+ + 20 - 80 + + +

dualKnobs assigned after value

+ + 20 - 80 + + +

dualKnobs toggled

+ + + + Toggle Dual Knobs +
+
+ + + + diff --git a/core/src/components/range/test/dual-knobs/range.e2e.ts b/core/src/components/range/test/dual-knobs/range.e2e.ts new file mode 100644 index 00000000000..f3e8e610568 --- /dev/null +++ b/core/src/components/range/test/dual-knobs/range.e2e.ts @@ -0,0 +1,82 @@ +import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +const DUAL_VALUE = { lower: 20, upper: 80 }; + +/** + * Returns the value each knob is positioned at. Sorted low to high because knob + * A is not guaranteed to be the knob holding the lower value. + */ +const knobValues = async (range: Locator) => { + const values: string[] = await range + .locator('.range-knob-handle') + .evaluateAll((els: HTMLElement[]) => els.map((el) => el.getAttribute('aria-valuenow')!)); + + return values.sort((a, b) => parseFloat(a) - parseFloat(b)); +}; + +/** + * This behavior does not vary across modes/directions + */ +configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => { + test.describe(title('range: dual knobs'), () => { + test('should position knobs when dualKnobs is assigned before value', async ({ page }) => { + await page.setContent(``, config); + + const range = page.locator('ion-range'); + await range.evaluate((el: HTMLIonRangeElement, value) => { + el.dualKnobs = true; + el.value = value; + }, DUAL_VALUE); + await page.waitForChanges(); + + expect(await knobValues(range)).toEqual(['20', '80']); + + // A single knob range ignores the lower half of an object value. + await range.evaluate((el: HTMLIonRangeElement) => (el.dualKnobs = false)); + await page.waitForChanges(); + + expect(await knobValues(range)).toEqual(['80']); + }); + + test('should position knobs when dualKnobs is assigned after value', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31026', + }); + + await page.setContent(``, config); + + const range = page.locator('ion-range'); + await range.evaluate((el: HTMLIonRangeElement, value) => { + el.value = value; + el.dualKnobs = true; + }, DUAL_VALUE); + await page.waitForChanges(); + + expect(await knobValues(range)).toEqual(['20', '80']); + }); + + test('should reposition knobs when dualKnobs is toggled at runtime', async ({ page }) => { + await page.setContent(``, config); + + const range = page.locator('ion-range'); + await range.evaluate((el: HTMLIonRangeElement, value) => (el.value = value), DUAL_VALUE); + await page.waitForChanges(); + + // A single knob range ignores the lower half of an object value. + expect(await knobValues(range)).toEqual(['80']); + + await range.evaluate((el: HTMLIonRangeElement) => (el.dualKnobs = true)); + await page.waitForChanges(); + + expect(await knobValues(range)).toEqual(['20', '80']); + + await range.evaluate((el: HTMLIonRangeElement) => (el.dualKnobs = false)); + await page.waitForChanges(); + + expect(await knobValues(range)).toEqual(['80']); + }); + }); +}); diff --git a/core/src/components/refresher/refresher.tsx b/core/src/components/refresher/refresher.tsx index 9d7c6a7ad06..ecd27fd0965 100644 --- a/core/src/components/refresher/refresher.tsx +++ b/core/src/components/refresher/refresher.tsx @@ -2,6 +2,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Method, Prop, State, Watch, h, readTask, writeTask } from '@stencil/core'; import { getTimeGivenProgression } from '@utils/animation/cubic-bezier'; import { + findRefresherScrollHost, getScrollElement, ION_CONTENT_CLASS_SELECTOR, ION_CONTENT_ELEMENT_SELECTOR, @@ -299,8 +300,19 @@ export class Refresher implements ComponentInterface { this.scrollEl!.addEventListener('scroll', this.scrollListenerCallback); - this.gesture = (await import('../../utils/gesture')).createGesture({ - el: this.scrollEl!, + const { createGesture } = await import('../../utils/gesture'); + + /** + * Awaiting the dynamic import yields to the event loop, so the component can + * disconnect before it resolves. disconnectedCallback clears scrollEl, so a + * missing scrollEl here means there is nothing left to attach a gesture to. + */ + if (!this.scrollEl) { + return; + } + + this.gesture = createGesture({ + el: this.scrollEl, gestureName: 'refresher', gesturePriority: 31, direction: 'y', @@ -369,8 +381,19 @@ export class Refresher implements ComponentInterface { }); } - this.gesture = (await import('../../utils/gesture')).createGesture({ - el: this.scrollEl!, + const { createGesture } = await import('../../utils/gesture'); + + /** + * Awaiting the dynamic import yields to the event loop, so the component can + * disconnect before it resolves. disconnectedCallback clears scrollEl, so a + * missing scrollEl here means there is nothing left to attach a gesture to. + */ + if (!this.scrollEl) { + return; + } + + this.gesture = createGesture({ + el: this.scrollEl, gestureName: 'refresher', gesturePriority: 31, direction: 'y', @@ -504,7 +527,7 @@ export class Refresher implements ComponentInterface { * or the background content element. */ componentOnReady(contentEl, async () => { - const customScrollTarget = contentEl.querySelector(ION_CONTENT_CLASS_SELECTOR); + const customScrollTarget = findRefresherScrollHost(contentEl); /** * Query the custom scroll target (if available), first. In refresher implementations, * the ion-refresher element will always be a direct child of ion-content (slot="fixed"). By @@ -526,7 +549,18 @@ export class Refresher implements ComponentInterface { if (await shouldUseNativeRefresher(this.el, getIonMode(this))) { this.setupNativeRefresher(contentEl); } else { - this.gesture = (await import('../../utils/gesture')).createGesture({ + const { createGesture } = await import('../../utils/gesture'); + + /** + * Awaiting the dynamic import yields to the event loop, so the component can + * disconnect before it resolves. A disconnected contentEl means there is + * nothing left to attach a gesture to. + */ + if (!contentEl.isConnected) { + return; + } + + this.gesture = createGesture({ el: contentEl, gestureName: 'refresher', gesturePriority: 31, diff --git a/core/src/components/router/router.tsx b/core/src/components/router/router.tsx index a5c0c67bf0e..36287043127 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'; @@ -24,6 +24,7 @@ export class Router implements ComponentInterface { private state = 0; private lastState = 0; private waitPromise?: Promise; + private fragmentScrollToken = 0; @Element() el!: HTMLElement; @@ -67,11 +68,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(); } } @@ -93,7 +101,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' }) @@ -132,7 +145,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); @@ -146,8 +159,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. */ @@ -188,7 +206,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; @@ -245,8 +268,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; } @@ -361,15 +384,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 c10b34d0840..d2b23e095dc 100644 --- a/core/src/components/router/utils/dom.ts +++ b/core/src/components/router/utils/dom.ts @@ -1,4 +1,5 @@ -import { componentOnReady } from '@utils/helpers'; +import { findClosestIonContent, getScrollElement, isIonContent } from '@utils/content'; +import { componentOnReady, raf } 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 2830d75b73c..d031db55884 100644 --- a/core/src/components/searchbar/searchbar.tsx +++ b/core/src/components/searchbar/searchbar.tsx @@ -32,6 +32,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. @@ -302,6 +303,7 @@ export class Searchbar implements ComponentInterface { if (this.clearTimeout) { clearTimeout(this.clearTimeout); } + this.searchIconResizeObserver?.disconnect(); } private emitStyle() { @@ -539,7 +541,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/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Chrome-linux.png index aee8b589f9c..e9993f88a63 100644 Binary files a/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/segment-view/test/disabled/segment-view.e2e.ts-snapshots/segment-view-disabled-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/segment/test/icon/segment.e2e.ts-snapshots/segment-icons-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/segment/test/icon/segment.e2e.ts-snapshots/segment-icons-ios-ltr-Mobile-Chrome-linux.png index 6311fd47f81..1bf3edf214a 100644 Binary files a/core/src/components/segment/test/icon/segment.e2e.ts-snapshots/segment-icons-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/segment/test/icon/segment.e2e.ts-snapshots/segment-icons-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/segment/test/icon/segment.e2e.ts-snapshots/segment-icons-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/segment/test/icon/segment.e2e.ts-snapshots/segment-icons-ios-rtl-Mobile-Chrome-linux.png index 463a3799af4..c6a84ca61a9 100644 Binary files a/core/src/components/segment/test/icon/segment.e2e.ts-snapshots/segment-icons-ios-rtl-Mobile-Chrome-linux.png and b/core/src/components/segment/test/icon/segment.e2e.ts-snapshots/segment-icons-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index e39856c2482..8701a7e19a0 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1228,7 +1228,8 @@ export class Select implements ComponentInterface { * TODO(FW-5592): Remove hasStartEndSlots condition */ const labelShouldFloat = - labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || isExpanded || hasStartEndSlots)); + labelPlacement === 'stacked' || + (labelPlacement === 'floating' && (hasValue || hasFocus || isExpanded || hasStartEndSlots)); return ( }); }); }); + +/** + * This behavior does not vary across modes/directions + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('select: floating label focus'), () => { + test('label should appear on top of the select when it is focused, has a placeholder, and no value', async ({ + page, + }) => { + await page.setContent( + ` + + Apples + + `, + config + ); + + const select = page.locator('ion-select'); + await page.locator('ion-select button').focus(); + await expect(select).toHaveScreenshot(screenshot(`select-label-floating-focus-no-value-placeholder`)); + }); + }); +}); diff --git a/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-floating-focus-no-value-placeholder-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-floating-focus-no-value-placeholder-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..1ddc9fb1414 Binary files /dev/null and b/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-floating-focus-no-value-placeholder-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-floating-focus-no-value-placeholder-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-floating-focus-no-value-placeholder-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..dd6ca99b04f Binary files /dev/null and b/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-floating-focus-no-value-placeholder-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-floating-focus-no-value-placeholder-md-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-floating-focus-no-value-placeholder-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..89bb6f642ba Binary files /dev/null and b/core/src/components/select/test/label/select.e2e.ts-snapshots/select-label-floating-focus-no-value-placeholder-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-ios-ltr-Mobile-Firefox-linux.png index bf27720310d..b24ece9af8c 100644 Binary files a/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-ios-rtl-Mobile-Firefox-linux.png index 0b1c9e79903..67554fdede3 100644 Binary files a/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-ios-rtl-Mobile-Firefox-linux.png and b/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-md-ltr-Mobile-Firefox-linux.png index 1ddfeeee780..84d7da30b07 100644 Binary files a/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-md-rtl-Mobile-Firefox-linux.png b/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-md-rtl-Mobile-Firefox-linux.png index f3073946b4f..049560bcf81 100644 Binary files a/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/spinner/test/basic/spinner.e2e.ts-snapshots/spinner-basic-diff-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/spinner/test/color/spinner.e2e.ts-snapshots/spinner-color-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/spinner/test/color/spinner.e2e.ts-snapshots/spinner-color-diff-md-ltr-Mobile-Firefox-linux.png index 0a39da496db..278b02ffcd3 100644 Binary files a/core/src/components/spinner/test/color/spinner.e2e.ts-snapshots/spinner-color-diff-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/spinner/test/color/spinner.e2e.ts-snapshots/spinner-color-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/tab/tab.tsx b/core/src/components/tab/tab.tsx index a1f6be57a06..e4465bc3ec1 100644 --- a/core/src/components/tab/tab.tsx +++ b/core/src/components/tab/tab.tsx @@ -11,7 +11,8 @@ import type { ComponentRef, FrameworkDelegate } from '../../interface'; shadow: true, }) export class Tab implements ComponentInterface { - private loaded = false; + private loadPromise?: Promise; + @Element() el!: HTMLIonTabElement; /** @internal */ @@ -57,20 +58,31 @@ export class Tab implements ComponentInterface { @Watch('active') changeActive(isActive: boolean) { if (isActive) { - this.prepareLazyLoaded(); + this.prepareLazyLoaded().catch((e) => { + printIonError('[ion-tab] - Exception in prepareLazyLoaded:', e); + }); } } private prepareLazyLoaded(): Promise { - if (!this.loaded && this.component != null) { - this.loaded = true; - try { - return attachComponent(this.delegate, this.el, this.component, ['ion-page']); - } catch (e) { - printIonError('[ion-tab] - Exception in prepareLazyLoaded:', e); - } + if (this.component == null) { + return Promise.resolve(undefined); } - return Promise.resolve(undefined); + + /** + * The attach is cached so that concurrent activations share a single + * attempt and later activations reuse the result. The cache is cleared + * only when the attach fails, so a tab whose component failed to load + * can be retried the next time it is activated. + */ + if (this.loadPromise === undefined) { + this.loadPromise = attachComponent(this.delegate, this.el, this.component, ['ion-page']).catch((e) => { + this.loadPromise = undefined; + throw e; + }); + } + + return this.loadPromise; } render() { diff --git a/core/src/components/tab/test/tab.spec.ts b/core/src/components/tab/test/tab.spec.ts new file mode 100644 index 00000000000..d08791a8a25 --- /dev/null +++ b/core/src/components/tab/test/tab.spec.ts @@ -0,0 +1,185 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import type { FrameworkDelegate } from '../../../interface'; +import { Tab } from '../tab'; + +const mockDelegate = (attachViewToDom: FrameworkDelegate['attachViewToDom']): FrameworkDelegate => ({ + attachViewToDom, + removeViewFromDom: jest.fn().mockResolvedValue(undefined), +}); + +const createTab = async (html = '') => { + const page = await newSpecPage({ + components: [Tab], + html, + }); + + return { page, tabEl: page.body.querySelector('ion-tab')! }; +}; + +describe('ion-tab: lazy loading', () => { + it('should attach the component only once across multiple setActive calls', async () => { + const { tabEl } = await createTab(); + const attachViewToDom = jest.fn().mockResolvedValue(document.createElement('div')); + tabEl.delegate = mockDelegate(attachViewToDom); + + await tabEl.setActive(); + await tabEl.setActive(); + + expect(attachViewToDom).toHaveBeenCalledTimes(1); + }); + + it('should share one attach attempt across concurrent setActive calls', async () => { + const { tabEl } = await createTab(); + const attachViewToDom = jest.fn().mockResolvedValue(document.createElement('div')); + tabEl.delegate = mockDelegate(attachViewToDom); + + await Promise.all([tabEl.setActive(), tabEl.setActive()]); + + expect(attachViewToDom).toHaveBeenCalledTimes(1); + }); + + it('should make a later setActive wait for the in-flight attach', async () => { + const { tabEl } = await createTab(); + let resolveAttach!: (el: HTMLElement) => void; + const attachViewToDom = jest.fn(() => new Promise((resolve) => (resolveAttach = resolve))); + tabEl.delegate = mockDelegate(attachViewToDom); + + const first = tabEl.setActive(); + const second = tabEl.setActive(); + let secondSettled = false; + second.then(() => (secondSettled = true)); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(secondSettled).toBe(false); + + resolveAttach(document.createElement('div')); + await Promise.all([first, second]); + + expect(secondSettled).toBe(true); + expect(attachViewToDom).toHaveBeenCalledTimes(1); + }); + + it('should retry the attach after a failed first attempt', async () => { + const { tabEl } = await createTab(); + const attachViewToDom = jest + .fn() + .mockRejectedValueOnce(new Error('attach failed')) + .mockResolvedValue(document.createElement('div')); + tabEl.delegate = mockDelegate(attachViewToDom); + + await expect(tabEl.setActive()).rejects.toThrow('attach failed'); + await expect(tabEl.setActive()).resolves.toBeUndefined(); + + expect(attachViewToDom).toHaveBeenCalledTimes(2); + }); + + it('should not activate the tab when the attach fails', async () => { + const { tabEl } = await createTab(); + tabEl.delegate = mockDelegate(jest.fn().mockRejectedValue(new Error('attach failed'))); + + await expect(tabEl.setActive()).rejects.toThrow('attach failed'); + + expect(tabEl.active).toBe(false); + }); + + it('should activate the tab once a retried attach succeeds', async () => { + const { page, tabEl } = await createTab(); + const attachViewToDom = jest + .fn() + .mockRejectedValueOnce(new Error('attach failed')) + .mockResolvedValue(document.createElement('div')); + tabEl.delegate = mockDelegate(attachViewToDom); + + await expect(tabEl.setActive()).rejects.toThrow('attach failed'); + await tabEl.setActive(); + await page.waitForChanges(); + + expect(attachViewToDom).toHaveBeenCalledTimes(2); + expect(tabEl.active).toBe(true); + expect(tabEl.classList.contains('tab-hidden')).toBe(false); + }); + + it('should attach the component when the tab starts active', async () => { + const { page, tabEl } = await createTab(''); + + await page.waitForChanges(); + + expect(tabEl.querySelector('ion-content.ion-page')).not.toBeNull(); + expect(tabEl.classList.contains('tab-hidden')).toBe(false); + }); + + it('should not attach anything when no component is provided', async () => { + const { tabEl } = await createTab(''); + const attachViewToDom = jest.fn().mockResolvedValue(document.createElement('div')); + tabEl.delegate = mockDelegate(attachViewToDom); + + await expect(tabEl.setActive()).resolves.toBeUndefined(); + + expect(attachViewToDom).not.toHaveBeenCalled(); + expect(tabEl.active).toBe(true); + }); + + describe('when the attach fails on the active watcher path', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error'); + // Suppress console.error output from polluting the test output + consoleErrorSpy.mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + // Jest fails a test on an unhandled rejection, so the watcher swallowing the + // rejection is covered without an explicit unhandledRejection listener. + it('should log the error', async () => { + const { page, tabEl } = await createTab(); + tabEl.delegate = mockDelegate(jest.fn().mockRejectedValue(new Error('attach failed'))); + + // ion-tabs activates a tab by setting `active`, it does not await setActive(). + tabEl.active = true; + await page.waitForChanges(); + // Give the rejected attach a turn to settle so the watcher can log it. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[Ionic Error]: [ion-tab] - Exception in prepareLazyLoaded:', + expect.any(Error) + ); + }); + + it('should retry on the next activation, as ion-tabs reactivates tabs', async () => { + const { page, tabEl } = await createTab(); + const attachViewToDom = jest + .fn() + .mockRejectedValueOnce(new Error('attach failed')) + .mockImplementation(() => { + const el = document.createElement('div'); + el.classList.add('attached-page'); + tabEl.appendChild(el); + return Promise.resolve(el); + }); + tabEl.delegate = mockDelegate(attachViewToDom); + + tabEl.active = true; + await page.waitForChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(tabEl.querySelector('.attached-page')).toBeNull(); + + // ion-tabs deactivates the leaving tab, so returning to it is a false -> true change. + tabEl.active = false; + await page.waitForChanges(); + tabEl.active = true; + await page.waitForChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(attachViewToDom).toHaveBeenCalledTimes(2); + expect(tabEl.querySelector('.attached-page')).not.toBeNull(); + }); + }); +}); diff --git a/core/src/components/textarea/test/a11y/textarea.e2e.ts-snapshots/textarea-scale-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/textarea/test/a11y/textarea.e2e.ts-snapshots/textarea-scale-ios-ltr-Mobile-Chrome-linux.png index cfc2bb57478..54d8c14e5a8 100644 Binary files a/core/src/components/textarea/test/a11y/textarea.e2e.ts-snapshots/textarea-scale-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/textarea/test/a11y/textarea.e2e.ts-snapshots/textarea-scale-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/title/test/a11y/title.e2e.ts-snapshots/title-small-scale-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/title/test/a11y/title.e2e.ts-snapshots/title-small-scale-ios-ltr-Mobile-Chrome-linux.png index e9daeb3a04e..669df96e6e8 100644 Binary files a/core/src/components/title/test/a11y/title.e2e.ts-snapshots/title-small-scale-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/title/test/a11y/title.e2e.ts-snapshots/title-small-scale-ios-ltr-Mobile-Chrome-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 bf1fbfd72c7..35ff0b3e61c 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 06e8aae5ae2..957e15c5d92 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/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/a11y/toggle.e2e.ts b/core/src/components/toggle/test/a11y/toggle.e2e.ts index 26f5e1f496f..192e68db138 100644 --- a/core/src/components/toggle/test/a11y/toggle.e2e.ts +++ b/core/src/components/toggle/test/a11y/toggle.e2e.ts @@ -21,3 +21,87 @@ configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, }); }); }); + +/** + * These assert `ion-focusable`, not the rendered ring, because `ion-focused` + * relies on keyboard-mode detection that is flaky on WebKit. Gating is mode-independent. + */ +configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => { + test.describe(title('toggle: focus indicator'), () => { + test('standalone toggle should be focusable', async ({ page }) => { + await page.setContent( + ` + + Toggle + + `, + config + ); + + const toggle = page.locator('ion-toggle'); + await expect(toggle).toHaveClass(/ion-focusable/); + await expect(toggle).not.toHaveClass(/toggle-defers-indicator/); + }); + + test('toggle in a single-input item should defer the indicator to the item', async ({ page }) => { + await page.setContent( + ` + + + Toggle + + + `, + config + ); + + const toggle = page.locator('ion-toggle'); + + // The toggle's `ion-focusable` is what makes the item focusable, so both carry it. + await expect(toggle).toHaveClass(/ion-focusable/); + await expect(toggle).toHaveClass(/toggle-defers-indicator/); + await expect(page.locator('ion-item')).toHaveClass(/ion-focusable/); + }); + + test('toggle in a clickable item should keep its own indicator', async ({ page }) => { + await page.setContent( + ` + + + Toggle + + + `, + config + ); + + const toggle = page.locator('ion-toggle'); + + // Two tab stops sharing one indicator would be indistinguishable. + await expect(page.locator('ion-item')).toHaveClass(/ion-activatable/); + await expect(toggle).not.toHaveClass(/toggle-defers-indicator/); + }); + + test('toggle in a multi-input item should be focusable', async ({ page }) => { + await page.setContent( + ` + + + Toggle 1 + Toggle 2 + + + `, + config + ); + + const toggles = page.locator('ion-toggle'); + await expect(toggles.nth(0)).toHaveClass(/ion-focusable/); + await expect(toggles.nth(1)).toHaveClass(/ion-focusable/); + + // No single indicator to defer to, so each toggle draws its own. + await expect(toggles.nth(0)).not.toHaveClass(/toggle-defers-indicator/); + await expect(toggles.nth(1)).not.toHaveClass(/toggle-defers-indicator/); + }); + }); +}); diff --git a/core/src/components/toggle/test/basic/toggle.e2e.ts b/core/src/components/toggle/test/basic/toggle.e2e.ts index 9502df83b55..2dc8a89d2e4 100644 --- a/core/src/components/toggle/test/basic/toggle.e2e.ts +++ b/core/src/components/toggle/test/basic/toggle.e2e.ts @@ -1,10 +1,10 @@ import { expect } from '@playwright/test'; -import { configs, test } from '@utils/test/playwright'; +import { applyKeyboardFocus, configs, test } from '@utils/test/playwright'; /** * This behavior does not vary across modes/directions */ -configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('toggle: ionChange'), () => { test('should fire ionChange when interacting with toggle', async ({ page }) => { await page.setContent( @@ -95,48 +95,6 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c }); test.describe(title('toggle: ionFocus'), () => { - test('should not have visual regressions', async ({ page, pageUtils }) => { - await page.setContent( - ` - - -
- Unchecked -
- `, - config - ); - - await pageUtils.pressKeys('Tab'); - - const container = page.locator('#container'); - - await expect(container).toHaveScreenshot(screenshot(`toggle-focus`)); - }); - - test('should not have visual regressions when interacting with toggle in item', async ({ page, pageUtils }) => { - await page.setContent( - ` - - Unchecked - - `, - config - ); - - // Test focus with keyboard navigation - await pageUtils.pressKeys('Tab'); - - const item = page.locator('ion-item'); - - await expect(item).toHaveScreenshot(screenshot(`toggle-in-item-focus`)); - }); - test('should fire ionFocus when toggle is focused', async ({ page, pageUtils }) => { await page.setContent( ` @@ -285,3 +243,66 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c }); }); }); + +/** + * This behavior does not vary across directions + */ +configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('toggle: focus visual'), () => { + test('should render focus indicator when unchecked', async ({ page }) => { + await page.setContent( + ` + + + +
+ Unchecked +
+
+ `, + config + ); + + const toggle = page.locator('ion-toggle'); + + await applyKeyboardFocus(page, toggle); + + const container = page.locator('#container'); + + await expect(container).toHaveScreenshot(screenshot(`toggle-focus`)); + }); + + test('should render focus indicator when checked', async ({ page }) => { + await page.setContent( + ` + + + +
+ Checked +
+
+ `, + config + ); + + const toggle = page.locator('ion-toggle'); + + await applyKeyboardFocus(page, toggle); + + const container = page.locator('#container'); + + await expect(container).toHaveScreenshot(screenshot(`toggle-focus-checked`)); + }); + }); +}); diff --git a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..f5090dcb232 Binary files /dev/null and b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c2afe2f5941 Binary files /dev/null and b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-ios-ltr-Mobile-Safari-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..08176dd7004 Binary files /dev/null and b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-md-ltr-Mobile-Chrome-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..2b0432e046b Binary files /dev/null and b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-md-ltr-Mobile-Firefox-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..800164b9640 Binary files /dev/null and b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-md-ltr-Mobile-Safari-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..20b9c1f27e1 Binary files /dev/null and b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-checked-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-ios-ltr-Mobile-Chrome-linux.png index 0d7cbc11195..9bd6ccd2884 100644 Binary files a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-ios-ltr-Mobile-Firefox-linux.png index bb21be2862e..e26f1c49803 100644 Binary files a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-ios-ltr-Mobile-Firefox-linux.png differ 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..243ab8eaaae 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-focus-md-ltr-Mobile-Chrome-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..a82ee6c5875 Binary files /dev/null and b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-md-ltr-Mobile-Firefox-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..8bc4ddfa407 Binary files /dev/null and b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-md-ltr-Mobile-Safari-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..bc2c99f8353 Binary files /dev/null and b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-focus-md-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-Chrome-linux.png b/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-in-item-focus-ios-ltr-Mobile-Chrome-linux.png deleted file mode 100644 index ee7a99593cd..00000000000 Binary files a/core/src/components/toggle/test/basic/toggle.e2e.ts-snapshots/toggle-in-item-focus-ios-ltr-Mobile-Chrome-linux.png and /dev/null differ diff --git a/core/src/components/toggle/test/color/toggle.e2e.ts b/core/src/components/toggle/test/color/toggle.e2e.ts index cdebe75ec79..cb63cc33373 100644 --- a/core/src/components/toggle/test/color/toggle.e2e.ts +++ b/core/src/components/toggle/test/color/toggle.e2e.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { configs, test } from '@utils/test/playwright'; +import { applyKeyboardFocus, configs, test } from '@utils/test/playwright'; configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { test.describe(title('toggle: color'), () => { @@ -26,5 +26,61 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { const toggle = page.locator('ion-toggle'); await expect(toggle).toHaveScreenshot(screenshot(`toggle-color-unchecked`)); }); + + test('should apply color to the focus indicator when checked', async ({ page }) => { + await page.setContent( + ` + + + +
+ Label +
+
+ `, + config + ); + + const toggle = page.locator('ion-toggle'); + + await applyKeyboardFocus(page, toggle); + + const container = page.locator('#container'); + + await expect(container).toHaveScreenshot(screenshot(`toggle-color-focus-checked`)); + }); + + test('should not apply color to the focus indicator when unchecked', async ({ page }) => { + await page.setContent( + ` + + + +
+ Label +
+
+ `, + config + ); + + const toggle = page.locator('ion-toggle'); + + await applyKeyboardFocus(page, toggle); + + const container = page.locator('#container'); + + await expect(container).toHaveScreenshot(screenshot(`toggle-color-focus-unchecked`)); + }); }); }); diff --git a/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..5649ce582d9 Binary files /dev/null and b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..1c859ee105d Binary files /dev/null and b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-ios-ltr-Mobile-Safari-linux.png b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..d2d2651e795 Binary files /dev/null and b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-md-ltr-Mobile-Chrome-linux.png b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..de6ed72d4be Binary files /dev/null and b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-md-ltr-Mobile-Firefox-linux.png b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..f8ef411ff1a Binary files /dev/null and b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-md-ltr-Mobile-Safari-linux.png b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..168808ae7e1 Binary files /dev/null and b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-checked-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..d3472848e87 Binary files /dev/null and b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..8c4b7cf5cb5 Binary files /dev/null and b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-ios-ltr-Mobile-Safari-linux.png b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..5d5b3edbf9e Binary files /dev/null and b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-md-ltr-Mobile-Chrome-linux.png b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..476ba9d5097 Binary files /dev/null and b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-md-ltr-Mobile-Firefox-linux.png b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..5ff96d60a1c Binary files /dev/null and b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-md-ltr-Mobile-Safari-linux.png b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..fe6f1b21513 Binary files /dev/null and b/core/src/components/toggle/test/color/toggle.e2e.ts-snapshots/toggle-color-focus-unchecked-md-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/components/toggle/test/item/index.html b/core/src/components/toggle/test/item/index.html index b872bcf3ea3..a1a29e43047 100644 --- a/core/src/components/toggle/test/item/index.html +++ b/core/src/components/toggle/test/item/index.html @@ -15,7 +15,7 @@