diff --git a/.bazelrc b/.bazelrc index 174fe13d7304..b7a083db69ba 100644 --- a/.bazelrc +++ b/.bazelrc @@ -2,6 +2,9 @@ common --@aspect_rules_ts//ts:skipLibCheck=always common --@aspect_rules_ts//ts:default_to_tsc_transpiler +# Needed as otherwise `env` of TS actions would be ignored. +common --incompatible_merge_fixed_and_default_shell_env + ############################### # Filesystem interactions # ############################### diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index bcd999bde3be..e30d8f489463 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,2 +1,2 @@ -# `material.angular.io/` folder renamed to `docs/` +# `material.angular.dev/` folder renamed to `docs/` cc8d090758b6448a3f745e1faf3e503a293b1757 diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml index 32f05e124764..2a628c6ebdc1 100644 --- a/.github/ISSUE_TEMPLATE/docs.yml +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -1,5 +1,5 @@ name: Documentation -description: Suggest an improvement to our documentation at material.angular.io +description: Suggest an improvement to our documentation at material.angular.dev title: 'docs-bug(COMPONENT): TITLE' labels: ['docs', 'needs triage'] body: @@ -8,7 +8,7 @@ body: attributes: label: Documentation Feedback description: | - Provide a brief summary of what you would like to see changed in our documentation at [material.angular.io](https://material.angular.io/). + Provide a brief summary of what you would like to see changed in our documentation at [material.angular.dev](https://material.angular.dev/). Feel free to provide any suggestions of content or examples you’d like us to include. validations: required: true @@ -16,6 +16,6 @@ body: id: affected-documentation-page attributes: label: Affected documentation page - description: Insert a link to the affected page on [material.angular.io](https://material.angular.io/). + description: Insert a link to the affected page on [material.angular.dev](https://material.angular.dev/). validations: required: true diff --git a/.github/actions/slack/action.yml b/.github/actions/slack/action.yml deleted file mode 100644 index 404bce59a8a8..000000000000 --- a/.github/actions/slack/action.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: 'Notify slack on failure' -description: 'Notifying Slack of failure' - -inputs: - JOB_NAME: - description: 'Name of the job' - required: true - SLACK_BOT_TOKEN: - required: true - -runs: - using: 'composite' - steps: - - name: Notify about failed test - uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v=v1.24.0 - with: - channel-id: 'C015EBF2XB6' - slack-message: '${{ inputs.JOB_NAME }} job failed for ${{ github.base_ref }} branch failed on build ${{ github.event.after }}: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' - env: - SLACK_BOT_TOKEN: ${{ inputs.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/assistant-to-the-branch-manager.yml b/.github/workflows/assistant-to-the-branch-manager.yml index 50ea78941573..b4cea7c321e4 100644 --- a/.github/workflows/assistant-to-the-branch-manager.yml +++ b/.github/workflows/assistant-to-the-branch-manager.yml @@ -13,9 +13,9 @@ jobs: assistant_to_the_branch_manager: runs-on: ubuntu-latest steps: - - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3.0.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - - uses: angular/dev-infra/github-actions/branch-manager@e4bf37af223483ce00f9316d227fd62cd744dc4b + - uses: angular/dev-infra/github-actions/branch-manager@1f047e7dbae43ea969c2cafb53b33207e86b800f with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/ci.material-aio.yml b/.github/workflows/ci.material-aio.yml index c62ff067985d..8757761d96fe 100644 --- a/.github/workflows/ci.material-aio.yml +++ b/.github/workflows/ci.material-aio.yml @@ -1,4 +1,4 @@ -name: 'CI (material.angular.io)' +name: 'CI (material.angular.dev)' on: push: @@ -21,11 +21,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Execute Build @@ -35,40 +35,40 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Execute Tests run: pnpm bazel test --test_tag_filters=-e2e,-audit //docs/... - name: Store Test Logs - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: test-logs - path: material.angular.io/bazel-testlogs/ + path: material.angular.dev/bazel-testlogs/ retention-days: 14 lighthouse: - runs-on: ubuntu-22.04 # Note, fails on Ubuntu 24.04. see https://github.com/actions/runner-images/issues/10636 + runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Execute Lighthouse Audit run: pnpm -s bazel test --test_tag_filters=audit //docs/... - name: Store Audit Logs - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: lighthouse-logs - path: material.angular.io/bazel-testlogs/ + path: material.angular.dev/bazel-testlogs/ retention-days: 14 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cb336b2fcf5..26a8121a2947 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Install node modules @@ -44,69 +44,54 @@ jobs: run: pnpm tslint - name: Check for circular dependencies run: pnpm -s ts-circular-deps:check - - uses: ./.github/actions/slack - if: failure() - with: - JOB_NAME: 'Lint check' - SLACK_BOT_TOKEN: ${{ secrets.ANGULAR_ROBOT_SLACK_TOKEN }} api_golden_checks: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules run: pnpm install --frozen-lockfile - name: Check API Goldens run: pnpm bazel test goldens/... - - uses: ./.github/actions/slack - if: failure() - with: - JOB_NAME: 'API Golden Checks' - SLACK_BOT_TOKEN: ${{ secrets.ANGULAR_ROBOT_SLACK_TOKEN }} e2e: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules run: pnpm install --frozen-lockfile - name: Run e2e tests run: pnpm e2e --flaky_test_attempts=2 - - uses: ./.github/actions/slack - if: failure() - with: - JOB_NAME: 'E2E test' - SLACK_BOT_TOKEN: ${{ secrets.ANGULAR_ROBOT_SLACK_TOKEN }} integration: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules @@ -114,69 +99,54 @@ jobs: - name: Run integration tests run: pnpm integration-tests continue-on-error: true - - uses: ./.github/actions/slack - if: failure() - with: - JOB_NAME: 'Integration test' - SLACK_BOT_TOKEN: ${{ secrets.ANGULAR_ROBOT_SLACK_TOKEN }} test: runs-on: ubuntu-latest-16core steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules run: pnpm install --frozen-lockfile - name: Run tests - run: bazel test --build_tag_filters=-e2e --test_tag_filters=-e2e --build_tests_only -- src/... - - uses: ./.github/actions/slack - if: failure() - with: - JOB_NAME: 'Test' - SLACK_BOT_TOKEN: ${{ secrets.ANGULAR_ROBOT_SLACK_TOKEN }} + run: bazel test --build_tag_filters=-e2e --test_tag_filters=-e2e --build_tests_only --test_env="DEBUG=puppeteer:*" -- src/... build: runs-on: ubuntu-latest-16core steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules run: pnpm install --frozen-lockfile - name: Run tests run: bazel build --build_tag_filters=-docs-package,-release-package -- src/... - - uses: ./.github/actions/slack - if: failure() - with: - JOB_NAME: 'Build' - SLACK_BOT_TOKEN: ${{ secrets.ANGULAR_ROBOT_SLACK_TOKEN }} publish_snapshots: runs-on: ubuntu-latest-4core steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Build and Verify Release Output @@ -191,17 +161,12 @@ jobs: run: ./scripts/circleci/publish-snapshots.sh env: SNAPSHOT_BUILDS_GITHUB_TOKEN: ${{ secrets.SNAPSHOT_BUILDS_GITHUB_TOKEN }} - - uses: ./.github/actions/slack - if: failure() - with: - JOB_NAME: 'Snapshot publishing' - SLACK_BOT_TOKEN: ${{ secrets.ANGULAR_ROBOT_SLACK_TOKEN }} deploy_doc_site: runs-on: ubuntu-latest-4core steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true # See: https://github.com/puppeteer/puppeteer/pull/13196 and @@ -209,9 +174,9 @@ jobs: - name: Disable AppArmor run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Build and Verify Release Output @@ -225,11 +190,6 @@ jobs: CIRCLE_SHA1: ${{ github.sha }} DOCS_SITE_GCP_SERVICE_KEY: ${{ secrets.DOCS_SITE_GCP_SERVICE_KEY }} DOCS_DEPLOY_GITHUB_TOKEN: ${{ secrets.DOCS_DEPLOY_GITHUB_TOKEN }} - - uses: ./.github/actions/slack - if: failure() - with: - JOB_NAME: 'Docs site deployment' - SLACK_BOT_TOKEN: ${{ secrets.ANGULAR_ROBOT_SLACK_TOKEN }} browserstack: runs-on: ubuntu-latest @@ -239,14 +199,14 @@ jobs: CI_RUNNER_NUMBER: ${{ github.run_id }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Browserstack Variables - uses: angular/dev-infra/github-actions/browserstack@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/browserstack@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Run tests on Browserstack run: ./scripts/circleci/run-browserstack-tests.sh diff --git a/.github/workflows/deploy-dev-app-main-push.yml b/.github/workflows/deploy-dev-app-main-push.yml index 43a3c52f8e68..fccd8ceeeb19 100644 --- a/.github/workflows/deploy-dev-app-main-push.yml +++ b/.github/workflows/deploy-dev-app-main-push.yml @@ -17,13 +17,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile @@ -38,7 +38,7 @@ jobs: npx -y firebase-tools@latest target:clear --project ${{env.PREVIEW_PROJECT}} hosting dev-app npx -y firebase-tools@latest target:apply --project ${{env.PREVIEW_PROJECT}} hosting dev-app ${{env.PREVIEW_SITE}} - - uses: FirebaseExtended/action-hosting-deploy@638d3c162747cead0c1c0d3093208ed59dfd8231 # v0 + - uses: FirebaseExtended/action-hosting-deploy@d482eb942f549f059116ec36b191860128553142 # v0 id: deploy with: # Note: No token used here as the action otherwise may attempt to post a comment. diff --git a/.github/workflows/dev-infra.yml b/.github/workflows/dev-infra.yml index d88858a6ddbc..62b0bcbeef5c 100644 --- a/.github/workflows/dev-infra.yml +++ b/.github/workflows/dev-infra.yml @@ -11,14 +11,14 @@ jobs: labels: runs-on: ubuntu-latest steps: - - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3.0.2 - - uses: angular/dev-infra/github-actions/commit-message-based-labels@e4bf37af223483ce00f9316d227fd62cd744dc4b + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: angular/dev-infra/github-actions/pull-request-labeling@1f047e7dbae43ea969c2cafb53b33207e86b800f with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} post_approval_changes: runs-on: ubuntu-latest steps: - - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3.0.2 - - uses: angular/dev-infra/github-actions/post-approval-changes@e4bf37af223483ce00f9316d227fd62cd744dc4b + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: angular/dev-infra/github-actions/post-approval-changes@1f047e7dbae43ea969c2cafb53b33207e86b800f with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/docs-preview-build.yml b/.github/workflows/docs-preview-build.yml index 24064070160d..9ec1e164a1fb 100644 --- a/.github/workflows/docs-preview-build.yml +++ b/.github/workflows/docs-preview-build.yml @@ -21,18 +21,18 @@ jobs: (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'docs: preview')) steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Build docs site run: pnpm bazel build //docs:build.production - - uses: angular/dev-infra/github-actions/previews/pack-and-upload-artifact@b9bcdb3f9dff85fbfe8d1ca1d95531ddfe475f80 + - uses: angular/dev-infra/github-actions/previews/pack-and-upload-artifact@1f047e7dbae43ea969c2cafb53b33207e86b800f with: workflow-artifact-name: 'docs-preview' pull-number: '${{github.event.pull_request.number}}' artifact-build-revision: '${{github.event.pull_request.head.sha}}' - deploy-directory: './dist/bin/docs/dist' + deploy-directory: './dist/bin/docs/dist/browser' diff --git a/.github/workflows/docs-preview-deploy.yml b/.github/workflows/docs-preview-deploy.yml index dcfe2986a48e..d601b1f7771e 100644 --- a/.github/workflows/docs-preview-deploy.yml +++ b/.github/workflows/docs-preview-deploy.yml @@ -40,7 +40,7 @@ jobs: npx -y firebase-tools@latest target:clear --config docs/firebase.json --project ${{env.PREVIEW_PROJECT}} hosting mat-aio npx -y firebase-tools@latest target:apply --config docs/firebase.json --project ${{env.PREVIEW_PROJECT}} hosting mat-aio ${{env.PREVIEW_SITE}} - - uses: angular/dev-infra/github-actions/previews/upload-artifacts-to-firebase@b9bcdb3f9dff85fbfe8d1ca1d95531ddfe475f80 + - uses: angular/dev-infra/github-actions/previews/upload-artifacts-to-firebase@1f047e7dbae43ea969c2cafb53b33207e86b800f with: github-token: '${{secrets.GITHUB_TOKEN}}' workflow-artifact-name: 'docs-preview' diff --git a/.github/workflows/google-internal-tests.yml b/.github/workflows/google-internal-tests.yml index 598e8e599aba..17db4c23eb79 100644 --- a/.github/workflows/google-internal-tests.yml +++ b/.github/workflows/google-internal-tests.yml @@ -12,8 +12,8 @@ jobs: trigger: runs-on: ubuntu-latest steps: - - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3.1.0 - - uses: angular/dev-infra/github-actions/google-internal-tests@e4bf37af223483ce00f9316d227fd62cd744dc4b + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: angular/dev-infra/github-actions/google-internal-tests@1f047e7dbae43ea969c2cafb53b33207e86b800f with: run-tests-guide-url: http://go/angular-material-presubmit github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr.material-aio.yml b/.github/workflows/pr.material-aio.yml index 59b8ee2777a3..d7c6a174d0e8 100644 --- a/.github/workflows/pr.material-aio.yml +++ b/.github/workflows/pr.material-aio.yml @@ -1,4 +1,4 @@ -name: 'PR (material.angular.io)' +name: 'PR (material.angular.dev)' on: pull_request: @@ -19,11 +19,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Execute Build @@ -33,40 +33,40 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Execute Tests run: pnpm bazel test --test_tag_filters=-e2e,-audit //docs/... - name: Store Test Logs - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: test-logs - path: material.angular.io/bazel-testlogs/ + path: material.angular.dev/bazel-testlogs/ retention-days: 14 lighthouse: - runs-on: ubuntu-22.04 # Note, fails on Ubuntu 24.04. see https://github.com/actions/runner-images/issues/10636 + runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Execute Lighthouse Audit run: pnpm bazel test --test_tag_filters=audit //docs/... - name: Store Audit Logs - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: lighthouse-logs - path: material.angular.io/bazel-testlogs/ + path: material.angular.dev/bazel-testlogs/ retention-days: 14 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 73543a97a9ac..c257909a580d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Install node modules @@ -45,7 +45,7 @@ jobs: - name: Check code format run: pnpm ng-dev format changed --check ${{ github.event.pull_request.base.sha }} - name: Check Package Licenses - uses: angular/dev-infra/github-actions/linting/licenses@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/linting/licenses@1f047e7dbae43ea969c2cafb53b33207e86b800f # Commit message check is last intentionally, because the caretaker can fix it # during merge, while other lint failures have to be resolved by the PR author. - name: Check commit message @@ -55,13 +55,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Check API Goldens @@ -71,13 +71,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Run e2e tests @@ -87,13 +87,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Run integration tests @@ -103,13 +103,13 @@ jobs: runs-on: ubuntu-latest-16core steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Run tests @@ -119,13 +119,13 @@ jobs: runs-on: ubuntu-latest-16core steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Run tests @@ -135,13 +135,13 @@ jobs: runs-on: ubuntu-latest-4core steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Install node modules run: pnpm install --frozen-lockfile - name: Build and Verify Release Output @@ -149,9 +149,9 @@ jobs: - name: Verify tooling setup run: pnpm check-tooling-setup - name: Build and create package artifacts - run: ./scripts/create-package-archives.js --suffix "pr${{github.event.number}}-$(git rev-parse --short HEAD)" + run: ./scripts/create-package-archives.mjs --suffix "pr${{github.event.number}}-$(git rev-parse --short HEAD)" - name: Upload artifacts - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # tag=v4.3.1 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: Package Artifacts path: dist/release-archives @@ -164,7 +164,7 @@ jobs: CI_RUNNER_NUMBER: ${{ github.run_id }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true # Checking out the pull request commit is intended here as we need to run the changed code tests. @@ -172,8 +172,8 @@ jobs: - name: Install node modules run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Browserstack Variables - uses: angular/dev-infra/github-actions/browserstack@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/browserstack@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Run tests on Browserstack run: ./scripts/circleci/run-browserstack-tests.sh diff --git a/.github/workflows/preview-build-dev-app.yml b/.github/workflows/preview-build-dev-app.yml index 4d506aa5817a..eb23e0ade5f5 100644 --- a/.github/workflows/preview-build-dev-app.yml +++ b/.github/workflows/preview-build-dev-app.yml @@ -23,18 +23,18 @@ jobs: (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'dev-app preview')) steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f # Build the web package - run: bazel build //src/dev-app:web_package --symlink_prefix=dist/ - - uses: angular/dev-infra/github-actions/previews/pack-and-upload-artifact@e4bf37af223483ce00f9316d227fd62cd744dc4b + - uses: angular/dev-infra/github-actions/previews/pack-and-upload-artifact@1f047e7dbae43ea969c2cafb53b33207e86b800f with: workflow-artifact-name: 'dev-app' pull-number: '${{github.event.pull_request.number}}' diff --git a/.github/workflows/preview-deploy-dev-app.yml b/.github/workflows/preview-deploy-dev-app.yml index c72d22d2fa59..e973e8018446 100644 --- a/.github/workflows/preview-deploy-dev-app.yml +++ b/.github/workflows/preview-deploy-dev-app.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - - uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # renovate: tag=v2.0.0 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Configure Firebase deploy target run: | @@ -33,7 +33,7 @@ jobs: npx -y firebase-tools@latest target:clear --project ${{env.PREVIEW_PROJECT}} hosting dev-app npx -y firebase-tools@latest target:apply --project ${{env.PREVIEW_PROJECT}} hosting dev-app ${{env.PREVIEW_SITE}} - - uses: angular/dev-infra/github-actions/previews/upload-artifacts-to-firebase@e4bf37af223483ce00f9316d227fd62cd744dc4b + - uses: angular/dev-infra/github-actions/previews/upload-artifacts-to-firebase@1f047e7dbae43ea969c2cafb53b33207e86b800f with: github-token: '${{secrets.GITHUB_TOKEN}}' workflow-artifact-name: 'dev-app' diff --git a/.github/workflows/scheduled-ci.yml b/.github/workflows/scheduled-ci.yml index 6e82665ae576..4f7ebbcf3aa1 100644 --- a/.github/workflows/scheduled-ci.yml +++ b/.github/workflows/scheduled-ci.yml @@ -19,13 +19,13 @@ jobs: runs-on: ubuntu-latest-4core steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Setting up Angular snapshot builds @@ -36,23 +36,18 @@ jobs: run: pnpm install - name: Run Browser tests run: pnpm bazel test --build_tag_filters=-e2e --test_tag_filters=-e2e --build_tests_only -- src/... - - uses: ./.github/actions/slack - if: ${{ failure() && github.event_name == 'push' }} - with: - JOB_NAME: 'Browser snapshot test' - SLACK_BOT_TOKEN: ${{ secrets.ANGULAR_ROBOT_SLACK_TOKEN }} linker_snapshot_tests: runs-on: ubuntu-latest-4core steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/setup@1f047e7dbae43ea969c2cafb53b33207e86b800f - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/bazel/configure-remote@1f047e7dbae43ea969c2cafb53b33207e86b800f with: google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Setting up Angular snapshot builds @@ -65,17 +60,12 @@ jobs: run: pnpm test-linker-aot - name: Run linker tests using JIT run: pnpm test-linker-jit - - uses: ./.github/actions/slack - if: ${{ failure() && github.event_name == 'push' }} - with: - JOB_NAME: 'Linker snapshot test' - SLACK_BOT_TOKEN: ${{ secrets.ANGULAR_ROBOT_SLACK_TOKEN }} monitor-docs-site: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@e4bf37af223483ce00f9316d227fd62cd744dc4b + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@1f047e7dbae43ea969c2cafb53b33207e86b800f with: cache-node-modules: true # See: https://github.com/puppeteer/puppeteer/pull/13196 and @@ -85,9 +75,4 @@ jobs: - name: Install node modules run: pnpm install --frozen-lockfile - name: Check Docs Site - run: ppnpm ci-docs-monitor-test - - uses: ./.github/actions/slack - if: ${{ failure() && github.event_name == 'push' }} - with: - JOB_NAME: 'Docs site monitoring' - SLACK_BOT_TOKEN: ${{ secrets.ANGULAR_ROBOT_SLACK_TOKEN }} + run: pnpm ci-docs-monitor-test diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 213dcfa8d12f..4a7f801b2bea 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -25,12 +25,12 @@ jobs: steps: - name: 'Checkout code' - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: 'Run analysis' - uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 with: results_file: results.sarif results_format: sarif @@ -39,7 +39,7 @@ jobs: # Upload the results as artifacts. - name: 'Upload artifact' - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: SARIF file path: results.sarif @@ -47,6 +47,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: 'Upload to code-scanning' - uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9 + uses: github/codeql-action/upload-sarif@39edc492dbe16b1465b0cafca41432d857bdb31a # v3.29.1 with: sarif_file: results.sarif diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index 56b1531b30e9..16ec612231f8 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -9,14 +9,17 @@ export const commitMessage: CommitMessageConfig = { minBodyLengthTypeExcludes: ['docs'], scopes: [ 'multiple', // For when a commit applies to multiple components. + 'cdk-experimental/accordion', 'cdk-experimental/column-resize', 'cdk-experimental/combobox', 'cdk-experimental/listbox', 'cdk-experimental/popover-edit', + 'cdk-experimental/radio-group', 'cdk-experimental/scrolling', 'cdk-experimental/selection', 'cdk-experimental/table-scroll-container', 'cdk-experimental/tabs', + 'cdk-experimental/tree', 'cdk-experimental/ui-patterns', 'cdk/a11y', 'cdk/accordion', diff --git a/.ng-dev/google-sync-config.json b/.ng-dev/google-sync-config.json index 7bd62f2ffcb8..effc32010c0f 100644 --- a/.ng-dev/google-sync-config.json +++ b/.ng-dev/google-sync-config.json @@ -24,6 +24,7 @@ "**/typings.d.ts", "src/**/*spec.ts", "src/cdk/schematics/**/*", + "src/cdk/testing/private/**/*", "src/material/schematics/**/*", "src/material/schematics/ng-generate/theme-color/**/*.bazel", "src/material/schematics/ng-generate/theme-color/index_bundled.d.ts", diff --git a/.ng-dev/release.mts b/.ng-dev/release.mts index 84e8271d2f0b..42ec4ff2ee77 100644 --- a/.ng-dev/release.mts +++ b/.ng-dev/release.mts @@ -1,3 +1,11 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + import semver from 'semver'; import {ReleaseConfig} from '@angular/ng-dev'; import {assertValidUpdateMigrationCollections} from '../tools/release-checks/check-migration-collections.mjs'; diff --git a/.nvmrc b/.nvmrc index 7af24b7ddbde..fc37597bccdb 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.11.0 +22.17.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index 6dd174b51c8e..00d1ce468dbf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,7 +11,6 @@ "**/bazel-out": true, "**/dist": true }, - "git.ignoreLimitWarning": true, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true @@ -24,8 +23,5 @@ "editor.rulers": [100], "bazel.buildifierExecutable": "node_modules/.bin/buildifier", "bazel.buildifierFixOnFormat": true, - "bazel.executable": "node_modules/.bin/bazel", - "git.inputValidation": true, - "git.inputValidationSubjectLength": 100, - "git.inputValidationLength": 100 + "bazel.executable": "node_modules/.bin/bazel" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ddae84bccfb..8e6fb9e954b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,120 +1,387 @@ - -# 20.0.0-next.9 "sodium-salamander" (2025-05-01) + +# 20.1.0 "metal-brownie" (2025-07-09) +There have been some internal mapping changes in Material 2 theme config maps generated by `mat.define-light-theme` and `mat.define-dark-theme`. + +If your app was directly modifying these internal properties to alter styles, such as setting new color values used in the foreground and background palette maps, then your changes may no longer be applied to the components. The supported path for changing theme values is through each component’s overrides API. + +The Material 2 component styles have undergone minor adjustments to enhance consistency across components and better align with the latest Material 2 specification. These changes result in a closer adherence to system-level colors such as `surface`, `background`, `disabled`, and `surface` variants. In general, these changes are slight hue differences for grey colors that aren’t very noticeable. + +### cdk +| Commit | Type | Description | +| -- | -- | -- | +| [f446d7c412](https://github.com/angular/components/commit/f446d7c4126fc253fa9e51664360bcd02245f005) | feat | **drag-drop:** add opt-in indicator of pick-up position ([#31288](https://github.com/angular/components/pull/31288)) | +| [5564d7d016](https://github.com/angular/components/commit/5564d7d0163d3ab56429b3059c4c572f74214ab2) | feat | **menu:** add setActiveMenuItem to cdkMenu ([#31371](https://github.com/angular/components/pull/31371)) | +| [6dc2f3ba50](https://github.com/angular/components/commit/6dc2f3ba50ba4a42708b32c8ae67461bdf982eaa) | fix | **a11y:** allow mixed types to be passed into setActiveItem ([#31462](https://github.com/angular/components/pull/31462)) | +| [cfa97bb785](https://github.com/angular/components/commit/cfa97bb785f8faab1e209899695c1e187020b391) | fix | **dialog:** avoid setting aria-hidden before focus has moved ([#31030](https://github.com/angular/components/pull/31030)) | +### material +| Commit | Type | Description | +| -- | -- | -- | +| [243845460d](https://github.com/angular/components/commit/243845460d3842beee3f58b1bdb0679bd16e81f3) | feat | **chips:** add (optional) edit icon to input chips ([#31041](https://github.com/angular/components/pull/31041)) | +| [9e942b4ba2](https://github.com/angular/components/commit/9e942b4ba2d31e7af70b3468b2c9b42364f47998) | feat | **menu:** add support for context menu | +| [4de7c0fb2d](https://github.com/angular/components/commit/4de7c0fb2d033bd81074764a52cc46edd02e6774) | feat | **testing:** Extend Angular harness testing functionality ([#30960](https://github.com/angular/components/pull/30960)) | +| [512433b6d1](https://github.com/angular/components/commit/512433b6d1c51d567e120b539da0053bc189fbaf) | fix | **badge:** remove badge opacity color mix ([#31213](https://github.com/angular/components/pull/31213)) | +| [92a8253e28](https://github.com/angular/components/commit/92a8253e28a676c41c5ca87382aad6006a48d212) | fix | **badge:** use system disabled states ([#31266](https://github.com/angular/components/pull/31266)) | +| [a7150d42cf](https://github.com/angular/components/commit/a7150d42cf0d423fe89679e2366a32830bf4aaf3) | fix | **button-toggle:** simplify divider color ([#31214](https://github.com/angular/components/pull/31214)) | +| [1f8b1302f2](https://github.com/angular/components/commit/1f8b1302f26d66e46896f433697822ebc410a98f) | fix | **button-toggle:** use system colors ([#31231](https://github.com/angular/components/pull/31231)) | +| [5ee7d79e4c](https://github.com/angular/components/commit/5ee7d79e4cfc1a466a3b1630f0f95c1f545966d3) | fix | **button:** FAB emitting invalid elevation tokens ([#31028](https://github.com/angular/components/pull/31028)) | +| [8523397fd2](https://github.com/angular/components/commit/8523397fd29a1cb5c08c88420367d0def020bf12) | fix | **button:** prevents mat-icon being cut off by text-spacing ([#30891](https://github.com/angular/components/pull/30891)) | +| [afe36e4d42](https://github.com/angular/components/commit/afe36e4d425ff365fc2aaf1f57368ffdefe51ef8) | fix | **button:** remove internal-only M2 styles ([#31017](https://github.com/angular/components/pull/31017)) | +| [589ea5905e](https://github.com/angular/components/commit/589ea5905eef43a671f519a13d566a151e5578fa) | fix | **button:** remove internal-only M2 styles ([#31018](https://github.com/angular/components/pull/31018)) | +| [0d47628d2e](https://github.com/angular/components/commit/0d47628d2eb1a5d72198769fbc7cfb978c8074d6) | fix | **button:** use system colors for state layers and text ([#31278](https://github.com/angular/components/pull/31278)) | +| [203c1737eb](https://github.com/angular/components/commit/203c1737eb6853c2e1b92afc8ed5f2756d5484de) | fix | **card:** subtitle text token should be on-surface-variant ([#31261](https://github.com/angular/components/pull/31261)) | +| [55b7ba0814](https://github.com/angular/components/commit/55b7ba0814863765f42e079dd72ba0313087fc3e) | fix | **card:** use system outline ([#31218](https://github.com/angular/components/pull/31218)) | +| [eb9abc366f](https://github.com/angular/components/commit/eb9abc366fcda8057d5b958231f518435f965e1e) | fix | **checkbox:** remove internal-only M2 styles ([#31019](https://github.com/angular/components/pull/31019)) | +| [458a17ba46](https://github.com/angular/components/commit/458a17ba4680cc4ed90965f2a71f655699f2bc11) | fix | **checkbox:** update tokens to system colors ([#31279](https://github.com/angular/components/pull/31279)) | +| [68d7ea0351](https://github.com/angular/components/commit/68d7ea0351b489bfb9419e1fe28e560ed7280d3f) | fix | **chips:** provide ability to edit for all screen readers with a click on already focused chip ([#30983](https://github.com/angular/components/pull/30983)) | +| [8c9cae0a8c](https://github.com/angular/components/commit/8c9cae0a8c61939dcab325be597ee2ee0eec72d4) | fix | **chips:** sync with g3 ([#31393](https://github.com/angular/components/pull/31393)) | +| [be48108bc2](https://github.com/angular/components/commit/be48108bc2b0939af496c8e82482b4b7326ef85d) | fix | **chips:** update tokens to system colors ([#31280](https://github.com/angular/components/pull/31280)) | +| [20ac2b2bd8](https://github.com/angular/components/commit/20ac2b2bd8bf551c6efccd149c21280df18cc31b) | fix | **core:** improve disabled psuedo checkbox contrast ([#31464](https://github.com/angular/components/pull/31464)) | +| [576a008b09](https://github.com/angular/components/commit/576a008b09ce377da892fbd54720947269deb6c6) | fix | **core:** remove unused form field mixins ([#31283](https://github.com/angular/components/pull/31283)) | +| [4d67427cef](https://github.com/angular/components/commit/4d67427cef1442f2c66feb821a2f2cf20a01001b) | fix | **core:** update ripple tokens to system colors ([#31282](https://github.com/angular/components/pull/31282)) | +| [28f2763f96](https://github.com/angular/components/commit/28f2763f96427239514b03a87ad6543c7bf1ce98) | fix | **core:** update tokens to system colors ([#31281](https://github.com/angular/components/pull/31281)) | +| [009424c73e](https://github.com/angular/components/commit/009424c73ec97769edbed440336d0e7386c49d13) | fix | **datepicker:** use system colors ([#31300](https://github.com/angular/components/pull/31300)) | +| [a24081f2bd](https://github.com/angular/components/commit/a24081f2bd66fd686c2751f9a429131ae0ff0acc) | fix | **dialog:** remove internal-only M2 styles ([#31020](https://github.com/angular/components/pull/31020)) | +| [d52ede5627](https://github.com/angular/components/commit/d52ede5627ceeed33122838c89afd38a1bfff4a4) | fix | **expansion:** add token for box shadow ([#31340](https://github.com/angular/components/pull/31340)) | +| [ecc6a09ca5](https://github.com/angular/components/commit/ecc6a09ca5fcbb4bc5e92056cc6102e956a544a8) | fix | **expansion:** align state layer colors ([#31236](https://github.com/angular/components/pull/31236)) | +| [e57aef2912](https://github.com/angular/components/commit/e57aef291240c952aa854ea1fb731b05a8792094) | fix | **expansion:** use correct typography ([#31347](https://github.com/angular/components/pull/31347)) | +| [82ab91e58a](https://github.com/angular/components/commit/82ab91e58ad03207c6dfa25f5995dde1bd5760cf) | fix | **form-field:** remove internal-only M2 styles ([#31021](https://github.com/angular/components/pull/31021)) | +| [6d27e04051](https://github.com/angular/components/commit/6d27e0405150855dc5027ebccf356951af2f7c6f) | fix | **form-field:** use system for neutral colors ([#31272](https://github.com/angular/components/pull/31272)) | +| [918b625438](https://github.com/angular/components/commit/918b6254385ef35c6d95621f6c0c04f294974710) | fix | **list:** update tokens to system colors ([#31284](https://github.com/angular/components/pull/31284)) | +| [a62164db5b](https://github.com/angular/components/commit/a62164db5b60b4e77132b3d5d18de84d3e7e74c1) | fix | **menu:** do not auto-focus when hover opens menu ([#31257](https://github.com/angular/components/pull/31257)) | +| [623030b50d](https://github.com/angular/components/commit/623030b50df52162e4a5c931b6d12226c44a4897) | fix | **menu:** update tokens to system colors ([#31285](https://github.com/angular/components/pull/31285)) | +| [0f26b91425](https://github.com/angular/components/commit/0f26b91425b5b7f34fdabf877e0f84929a6c8032) | fix | **paginator:** update tokens to system colors ([#31286](https://github.com/angular/components/pull/31286)) | +| [9a96db3233](https://github.com/angular/components/commit/9a96db32338dc13407b58ebb29de0a67db34cc52) | fix | **select:** remove internal-only M2 styles ([#31022](https://github.com/angular/components/pull/31022)) | +| [010122ff2a](https://github.com/angular/components/commit/010122ff2adebb7f7b6aff330044591ff771a966) | fix | **select:** use system color for arrow ([#31302](https://github.com/angular/components/pull/31302)) | +| [89d9cd07bc](https://github.com/angular/components/commit/89d9cd07bcf4e4c47d3f0966b22fb634b17c0d77) | fix | **slide-toggle:** use system colors ([#31303](https://github.com/angular/components/pull/31303)) | +| [c10f376a5f](https://github.com/angular/components/commit/c10f376a5fce0e88f41364e1507da13441eff754) | fix | **slide-toggle:** use token system for label text ([#31366](https://github.com/angular/components/pull/31366)) | +| [029e39cced](https://github.com/angular/components/commit/029e39ccedc01e931f4a2dc762b26bc8c87ec857) | fix | **slider:** use system color ([#31305](https://github.com/angular/components/pull/31305)) | +| [7bb0a82a43](https://github.com/angular/components/commit/7bb0a82a434c40d01a0d1aa35bcddaa3f725eccb) | fix | **snack-bar:** use inverse primary for button color ([#31465](https://github.com/angular/components/pull/31465)) | +| [d22191a619](https://github.com/angular/components/commit/d22191a61907bff5d01c1278a38382af5236f7c2) | fix | **sort:** simplify m2 sort color ([#31165](https://github.com/angular/components/pull/31165)) | +| [680dd6fa80](https://github.com/angular/components/commit/680dd6fa8094657e62755cc3b46123b6e539e547) | fix | **stepper:** align state colors ([#31227](https://github.com/angular/components/pull/31227)) | +| [0a47244b6c](https://github.com/angular/components/commit/0a47244b6c7a7fa4c37a5acdbab35c7366094be5) | fix | **stepper:** use system color for error text ([#31333](https://github.com/angular/components/pull/31333)) | +| [ea76df6040](https://github.com/angular/components/commit/ea76df6040bf648679d2e2d91608b29ad10deb55) | fix | **tabs:** fix missing token values ([#31345](https://github.com/angular/components/pull/31345)) | +| [951f37e656](https://github.com/angular/components/commit/951f37e6564c5d0701acdad992b0e0ca23b99882) | fix | **tabs:** update tokens to system colors ([#31287](https://github.com/angular/components/pull/31287)) | +| [f26ff4ece4](https://github.com/angular/components/commit/f26ff4ece4a5092578f1e268a22583d904af725a) | fix | **toolbar:** use correct typography ([#31348](https://github.com/angular/components/pull/31348)) | +| [4b85f9f62b](https://github.com/angular/components/commit/4b85f9f62bb944a2dcf2ad5ab80a47151161c5dc) | fix | **toolbar:** use system surface for neutral background ([#31234](https://github.com/angular/components/pull/31234)) | +| [ae3b0bfcf3](https://github.com/angular/components/commit/ae3b0bfcf3d84b353c08fb9ca9731ea1562a83c5) | fix | **tree:** use correct typography ([#31349](https://github.com/angular/components/pull/31349)) | +### cdk-experimental +| Commit | Type | Description | +| -- | -- | -- | +| [4f9ab216c1](https://github.com/angular/components/commit/4f9ab216c17107044a8f9a1b2f72d4b364e599e5) | feat | **accordion:** add accordion directives, example, and tests ([#31134](https://github.com/angular/components/pull/31134)) | +| [3704b7e32f](https://github.com/angular/components/commit/3704b7e32f1ae8c26307024004ffa93e9b90b82d) | feat | **radio:** add radio demo to dev-app ([#31180](https://github.com/angular/components/pull/31180)) | +| [50c3e0e12d](https://github.com/angular/components/commit/50c3e0e12d33f76e02531ee6300aca086800cedf) | feat | **radio:** create radio group and button directives ([#31050](https://github.com/angular/components/pull/31050)) | +| [cdc0dab261](https://github.com/angular/components/commit/cdc0dab26179c9e0b48b1e519dd3ed9b924dab3c) | feat | **tabs:** add setDefaultState and unit tests, refactor the content children strategy ([#31374](https://github.com/angular/components/pull/31374)) | +| [97d60c5378](https://github.com/angular/components/commit/97d60c5378c54d2e68bd18e794b40bf0dab08da5) | feat | **tree:** add tree directives, example, and tests ([#31350](https://github.com/angular/components/pull/31350)) | +| [ff0f6c61b5](https://github.com/angular/components/commit/ff0f6c61b54c9d5e3ac378b4b9ce338bc638818f) | feat | **ui-patterns:** accordion ([#31034](https://github.com/angular/components/pull/31034)) | +| [c5b2f0ac6d](https://github.com/angular/components/commit/c5b2f0ac6d40cc3dde466a925328c66cd7ff77ed) | feat | **ui-patterns:** create grid navigation behavior ([#31290](https://github.com/angular/components/pull/31290)) | +| [ce145fa9a3](https://github.com/angular/components/commit/ce145fa9a30b1f75f8c20ac7eb837cb007ce2695) | feat | **ui-patterns:** create the grid focus behavior ([#31055](https://github.com/angular/components/pull/31055)) | +| [9f249d0a25](https://github.com/angular/components/commit/9f249d0a25a665b705f5e1e2d9de363b9c2492dc) | feat | **ui-patterns:** radio button and group ([#31016](https://github.com/angular/components/pull/31016)) | +| [70841dbb5b](https://github.com/angular/components/commit/70841dbb5bc4d356d017f8dd944a89f52a8bbcc9) | feat | **ui-patterns:** tree ([#31308](https://github.com/angular/components/pull/31308)) | +| [06d7384a23](https://github.com/angular/components/commit/06d7384a23d0ded675f3df92be0487279de097ff) | fix | **accordion:** fix disabled trigger button can't be focused when skipDisabled=false ([#31379](https://github.com/angular/components/pull/31379)) | +| [9a856ad559](https://github.com/angular/components/commit/9a856ad5592d2447d82b4f774acc75216488f94c) | fix | **tabs:** fix tabs example styles ([#31382](https://github.com/angular/components/pull/31382)) | +| [8a091a7ea8](https://github.com/angular/components/commit/8a091a7ea8bf9b0c51d1e25d8ac2df49b0feb942) | fix | **tabs:** set tabpanel tabindex to -1 if hidden ([#31401](https://github.com/angular/components/pull/31401)) | +| [ea700e6d81](https://github.com/angular/components/commit/ea700e6d817699b99af15276affac3a4013e61fb) | fix | **ui-patterns:** add guardrails to selectOne for edge cases ([#31402](https://github.com/angular/components/pull/31402)) | +### multiple +| Commit | Type | Description | +| -- | -- | -- | +| [dc3ae8d945](https://github.com/angular/components/commit/dc3ae8d945450fe603de514c31b5cca5e4d4d9c9) | fix | update goldens ([#31123](https://github.com/angular/components/pull/31123)) | +| [e462301f8f](https://github.com/angular/components/commit/e462301f8f6c53f9b54deff0f4684e21ef4ec183) | fix | use inline m2 token definitions ([#31359](https://github.com/angular/components/pull/31359)) | +| [5546d10992](https://github.com/angular/components/commit/5546d10992cd0a822854ae29a3d80029217d3680) | fix | use system on-surface ([#31225](https://github.com/angular/components/pull/31225)) | + + + + +# 20.0.5 "agardite-ant" (2025-07-02) +### cdk +| Commit | Type | Description | +| -- | -- | -- | +| [45cc05641](https://github.com/angular/components/commit/45cc05641f89afb760077cb806af9814a01909e8) | fix | **tree:** remove leaking subscription ([#31457](https://github.com/angular/components/pull/31457)) | +### material +| Commit | Type | Description | +| -- | -- | -- | +| [7556beaa7](https://github.com/angular/components/commit/7556beaa7b20247d7c01f9e76d7097a0758f5ce7) | fix | **schematics:** typo in prompt question ([#31449](https://github.com/angular/components/pull/31449)) | +### google-maps +| Commit | Type | Description | +| -- | -- | -- | +| [bdfeb04c3](https://github.com/angular/components/commit/bdfeb04c3a220121d7b98b9ac8147e0cfdcdfeb6) | fix | fix update schematic ([#31448](https://github.com/angular/components/pull/31448)) | + + + + +# 20.0.4 "strontium-shack" (2025-06-25) +### cdk +| Commit | Type | Description | +| -- | -- | -- | +| [59be54616](https://github.com/angular/components/commit/59be54616446b596fa36182cad9ac29f243e564c) | fix | **menu:** close sibling triggers when opening a menu ([#30894](https://github.com/angular/components/pull/30894)) | +### material +| Commit | Type | Description | +| -- | -- | -- | +| [88bc13d88](https://github.com/angular/components/commit/88bc13d88639aff36eacd33cd3d958c1993e6f57) | fix | **button:** harness not picking up buttons with dynamic appearance ([#31327](https://github.com/angular/components/pull/31327)) | +| [c1b9682e4](https://github.com/angular/components/commit/c1b9682e49699fe02693a68def16e5c26302d547) | fix | **button:** update button golden ([#31346](https://github.com/angular/components/pull/31346)) | +| [c3bb067bf](https://github.com/angular/components/commit/c3bb067bf2e135d4086837a55aad476825c21b24) | fix | **input:** remove native clear button ([#31394](https://github.com/angular/components/pull/31394)) | +| [747218ba2](https://github.com/angular/components/commit/747218ba2fce8bac943915bb06e09c6fb4bbef2d) | fix | **input:** revert wheel workaround ([#31391](https://github.com/angular/components/pull/31391)) | +### youtube-player +| Commit | Type | Description | +| -- | -- | -- | +| [3b31dacc0](https://github.com/angular/components/commit/3b31dacc09f5040e5b4dc3e33bec39bad3b339f7) | fix | invalid URL when playlist is passed in without videoId ([#31403](https://github.com/angular/components/pull/31403)) | + + + + +# 19.2.19 "strontium-shovel" (2025-06-25) +### material +| Commit | Type | Description | +| -- | -- | -- | +| [1b9061ad2](https://github.com/angular/components/commit/1b9061ad26b949b58e82077b0fa07aacac76b307) | fix | **core:** fix sass deprecation warning ([#31390](https://github.com/angular/components/pull/31390)) | + + + + +# 20.0.3 "plastic-tube" (2025-06-11) +### material +| Commit | Type | Description | +| -- | -- | -- | +| [13e661e5c6](https://github.com/angular/components/commit/13e661e5c65f7b42b234f63bc9cdf2f4389ce4ba) | fix | **core:** output tokens from theme under same selector ([#31295](https://github.com/angular/components/pull/31295)) | +| [567ddbe84c](https://github.com/angular/components/commit/567ddbe84c3821c41bc3334a7317c70d885bc114) | fix | **radio:** color tokens excluded when passing in variant ([#31321](https://github.com/angular/components/pull/31321)) | +| [af811091cd](https://github.com/angular/components/commit/af811091cdf23fd713a0ace6c02143cce746edfa) | fix | **slider:** ensure disabled slider thumb input has 'auto' c… ([#31311](https://github.com/angular/components/pull/31311)) | + + + + +# 20.0.2 "amesite-armoire" (2025-06-04) +### material +| Commit | Type | Description | +| -- | -- | -- | +| [45fcf24b6](https://github.com/angular/components/commit/45fcf24b6cb5088e3c6e08c0eff068c7a7a7b548) | fix | **core:** brand family not set for plain value ([#31260](https://github.com/angular/components/pull/31260)) | +| [3a3a9b1c2](https://github.com/angular/components/commit/3a3a9b1c25dcca3953191f37d4f8be9b3eab5ebe) | fix | **schematics:** avoid overwriting files that didn't change ([#31270](https://github.com/angular/components/pull/31270)) | +| [1af07e3b2](https://github.com/angular/components/commit/1af07e3b220ccdc64592bee17e3b353798836699) | fix | **schematics:** token migration not replacing all instances ([#31277](https://github.com/angular/components/pull/31277)) | + + + + +# 20.0.1 "sulfur-sandpaper" (2025-05-28) +### material +| Commit | Type | Description | +| -- | -- | -- | +| [ecd17ad75](https://github.com/angular/components/commit/ecd17ad758dd831f0be2d106ad4b4cd63f116069) | fix | **button:** add token for icon button shape ([#31223](https://github.com/angular/components/pull/31223)) | +| [20fa71807](https://github.com/angular/components/commit/20fa71807bcbb8ef4dee75b86f0000aee31591f5) | fix | **schematics:** filter paths when renaming tokens ([#31249](https://github.com/angular/components/pull/31249)) | + + + + +# 20.0.0 "calcium-carrot" (2025-05-28) ## Breaking Changes ### cdk +- * `SelectionModel.clear` now returns a boolean. + * `SelectionModel.deselect` now returns a boolean. + * `SelectionModel.select` now returns a boolean. + * `SelectionModel.setSelection` now returns a boolean. + * `SelectionModel.toggle` now returns a boolean. +- * `DIALOG_SCROLL_STRATEGY_PROVIDER` has been removed. + * `DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY` has been removed. +- * `DragDropRegistry` is no longer generic. + * `DragDropRegistry.scroll` has been removed. Use the `scrolled` method instead. +- * `DomPortalHost` has been removed. Use `DomPortalOutlet` instead. + * `PortalInjector` has been removed. Use `Injector.create` instead. + * `PortalHost` has been removed. Use `PortalOutlet` instead. + * `BasePortalHost` has been removed. Use `BasePortalOutlet` instead. +- * `Constructor` symbol has been removed. + * `CanStickCtor` symbol has been removed. + * `mixinHasStickyInput` has been removed. Implement the `CanStick` interface instead. - * `CanStick` has been removed. * `CDK_TABLE_TEMPLATE` has been removed. * `StickyDirection` has been removed. * `StickyStyler` has been removed. +### material +- * `ButtonVariant` which is returned by `MatButtonHarness.getVariant` no longer includes the appearance of the button. Use `MatButtonHarness.getAppearance` instead. +- `tabindex` values set as `[attr.tabindex]` set on a Material button might not work as expected. Use `tabindex` for static values, or `[tabindex]`/`[tabIndex]` for dynamic ones. +- * `MAT_CHECKBOX_REQUIRED_VALIDATOR` has been removed. + * `MAT_CHECKBOX_VALUE_ACCESSOR` has been removed. + * `MatCheckboxRequiredValidator` has been removed. + * `_MatCheckboxRequiredValidatorModule` has been removed. +- * `MAT_DIALOG_SCROLL_STRATEGY_PROVIDER` has been removed. + * `MAT_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY` has been removed. +- * `matSelectAnimations.transformPanelWrap` has been removed. +- * `MAT_SLIDE_TOGGLE_REQUIRED_VALIDATOR` has been removed. + * `MAT_SLIDE_TOGGLE_VALUE_ACCESSOR` has been removed. + * `MatSlideToggleRequiredValidator` has been removed. + * `_MatSlideToggleRequiredValidatorModule` has been removed. +### multiple +- * `DialogConfig.componentFactoryResolver` has been removed. + * The constructor of `DomPortalOutlet` has changed. + * `ComponentPortal.componentFactoryResolver` has been removed. + * The constructor signature of `ComponentPortal` has changed. + * `MatDialogConfig.componentFactoryResolver` has been removed. ### cdk-experimental | Commit | Type | Description | | -- | -- | -- | | [e456c751c](https://github.com/angular/components/commit/e456c751cdeff2f196e90933d4c99de317e70b31) | feat | **listbox:** add cdk-active class ([#30932](https://github.com/angular/components/pull/30932)) | +| [6ac4c1ae2](https://github.com/angular/components/commit/6ac4c1ae2ca67564eef15e11f5b4363c5262747e) | feat | **listbox:** readonly mode ([#30803](https://github.com/angular/components/pull/30803)) | | [96fa9fc55](https://github.com/angular/components/commit/96fa9fc5526e79127edf028eef88f547c8c93b88) | feat | **popover-edit:** Support skipping over annotated rows when the user moves focus up/down. ([#30916](https://github.com/angular/components/pull/30916)) | | [025010b64](https://github.com/angular/components/commit/025010b64c59b4aa2bfec8593f050e75fe3eefaa) | feat | **tabs:** add cdk-active class and update example styles ([#30950](https://github.com/angular/components/pull/30950)) | +| [9a94bb76a](https://github.com/angular/components/commit/9a94bb76a755a01cd18f5f5a333502ca9d6ddd6d) | feat | **tabs:** support initial tab selection and add unit tests ([#31023](https://github.com/angular/components/pull/31023)) | +| [1833aa32e](https://github.com/angular/components/commit/1833aa32e662968d6b62209d2934e609c2f0d24b) | feat | **ui-patterns:** add expansion behavior and refactor tabs ([#30962](https://github.com/angular/components/pull/30962)) | +| [fc4699744](https://github.com/angular/components/commit/fc46997442b72bc1ba395fcd5f008b0358e5c91f) | feat | **ui-patterns:** listbox ui pattern ([#30495](https://github.com/angular/components/pull/30495)) | +| [9caed2a8a](https://github.com/angular/components/commit/9caed2a8a7b0433383b19526cadbf20325685040) | feat | **ui-patterns:** tabs ui pattern ([#30568](https://github.com/angular/components/pull/30568)) | +| [e0446ecfc](https://github.com/angular/components/commit/e0446ecfcb3d74a73f8b4799cb17d2098ba6c433) | fix | **listbox:** change shift+nav behavior ([#30854](https://github.com/angular/components/pull/30854)) | +| [184ceee3a](https://github.com/angular/components/commit/184ceee3a1abd829f303c606a05403b97a93a1b6) | fix | **listbox:** ignore spaces during typeahead ([#30766](https://github.com/angular/components/pull/30766)) | | [44e9ccbf5](https://github.com/angular/components/commit/44e9ccbf5080abade2d35478ad3c82451ac61585) | fix | **listbox:** initial listbox focus state ([#30764](https://github.com/angular/components/pull/30764)) | +| [a7df65f3e](https://github.com/angular/components/commit/a7df65f3e0faf6c3dca96876d99df67c4e2f42b4) | fix | **listbox:** use meta as alternative to ctrl ([#30852](https://github.com/angular/components/pull/30852)) | +| [149fe6129](https://github.com/angular/components/commit/149fe6129c9647b0d330ec47d901909f01f933a5) | fix | **popover-edit:** use MutationObserver to detect table rows ([#30713](https://github.com/angular/components/pull/30713)) | +| [16493ecab](https://github.com/angular/components/commit/16493ecab893d522f81b769e059f8fa6138b9e7e) | fix | **scrolling:** Fix ExpressionChangedAfterItWasCheckedError ([#31076](https://github.com/angular/components/pull/31076)) | | [0e391700c](https://github.com/angular/components/commit/0e391700cb55786a74a3aa16757e90596eb55e6c) | fix | **tabs:** handle space and enter events ([#30889](https://github.com/angular/components/pull/30889)) | -### google-maps +| [2e34b20cb](https://github.com/angular/components/commit/2e34b20cb7d2ede488c5df4ca858684524ef8073) | fix | **ui-patterns:** add missing event handlers ([#30786](https://github.com/angular/components/pull/30786)) | +| [4bf3591c7](https://github.com/angular/components/commit/4bf3591c77af15881a5d49b73492ddfa023d9833) | fix | **ui-patterns:** add modifier checks for regexps ([#30738](https://github.com/angular/components/pull/30738)) | +| [ae8f9dd28](https://github.com/angular/components/commit/ae8f9dd2828107af387062bae1aad03a9c8d6204) | fix | **ui-patterns:** fix lint and build config ([#30877](https://github.com/angular/components/pull/30877)) | +| [10890983c](https://github.com/angular/components/commit/10890983c8b91a5d5280e85b158b1a28eedda951) | fix | **ui-patterns:** listbox pointer event handler ([#30843](https://github.com/angular/components/pull/30843)) | +| [7343657f5](https://github.com/angular/components/commit/7343657f5a562990418e3859d5409c8681f03ea1) | fix | **ui-patterns:** small fixes for internal compatibi… ([#30625](https://github.com/angular/components/pull/30625)) | +### youtube-player | Commit | Type | Description | | -- | -- | -- | -| [cc61697fb](https://github.com/angular/components/commit/cc61697fb9cf234c0e6a4f95a587d377cdb23a4b) | fix | implicitly include google.maps types instead of the triple slash workaround ([#30942](https://github.com/angular/components/pull/30942)) | +| [18ddea330](https://github.com/angular/components/commit/18ddea33077d5c7c805f6dcf8f6e31f7d1863dd2) | fix | use safevalues ([#30773](https://github.com/angular/components/pull/30773)) | ### material | Commit | Type | Description | | -- | -- | -- | +| [fb81ab4f2](https://github.com/angular/components/commit/fb81ab4f234498acd5b9925087bb712a7db039f6) | feat | **button:** add support for tonal button ([#30638](https://github.com/angular/components/pull/30638)) | +| [e79f60558](https://github.com/angular/components/commit/e79f60558fec6055c78dddc9d6e291a600778bc5) | feat | **button:** allow appearance to be set dynamically | | [663585a1c](https://github.com/angular/components/commit/663585a1c4f539b946d12bc0d014c97f02d940d0) | feat | **card:** support filled variant ([#29868](https://github.com/angular/components/pull/29868)) | +| [82f0fa6fa](https://github.com/angular/components/commit/82f0fa6fa750779a04687d2564537e672a326b90) | feat | **core:** handle prefers-reduced-motion automatically ([#30796](https://github.com/angular/components/pull/30796)) | +| [3daa36030](https://github.com/angular/components/commit/3daa360300a98e0c63db770f6812faa94eea1133) | feat | **core:** introduce custom token for controlling animations ([#30749](https://github.com/angular/components/pull/30749)) | | [764a1ccd1](https://github.com/angular/components/commit/764a1ccd15f5f231058789eb98cdcf4e40dc69db) | feat | **dialog:** add closePredicate option | -| [48644f56a](https://github.com/angular/components/commit/48644f56a134fd1c78847bae01ab9e35f6ad9320) | fix | **chips:** icon cut off when text-spacing is increased ([#30867](https://github.com/angular/components/pull/30867)) | -| [86ea718e7](https://github.com/angular/components/commit/86ea718e7813d360a620df6bf1ddba77e93cdb6e) | fix | **schematics:** surface-container-low token not emitted ([#30985](https://github.com/angular/components/pull/30985)) | +| [097f49d90](https://github.com/angular/components/commit/097f49d90f5fe079a0399fd499202a8bdd1542cf) | fix | **autocomplete:** allow overlay backdrop by setting hasBackdrop option ([#30631](https://github.com/angular/components/pull/30631)) | +| [7d360ac77](https://github.com/angular/components/commit/7d360ac7761520c4382776c2950d47919e59cc21) | fix | **button-toggle:** changed after checked error when updating tabindex ([#31172](https://github.com/angular/components/pull/31172)) | +| [2c7199c02](https://github.com/angular/components/commit/2c7199c02886ebf0c8c668f9290ed47a1b7b20ab) | fix | **button:** align harness with new terminology | +| [6a5943d8b](https://github.com/angular/components/commit/6a5943d8b0fe6042c885e608d7d2a071e27dd802) | fix | **button:** combine MatButton and MatAnchor ([#30492](https://github.com/angular/components/pull/30492)) | +| [3aecb3521](https://github.com/angular/components/commit/3aecb3521614d1bae40a5233d7fcc609fed0e632) | fix | **button:** tonal touch target token transgression ([#30688](https://github.com/angular/components/pull/30688)) | +| [77d6b69e7](https://github.com/angular/components/commit/77d6b69e7789d97220afac64144e8025c5960353) | fix | **checkbox:** breaking changes for v20 | +| [907815dc5](https://github.com/angular/components/commit/907815dc5cecc2b56d54fd9c525b1e37fb9002be) | fix | **checkbox:** hardcode token renames ([#30752](https://github.com/angular/components/pull/30752)) | +| [f01ac6e9c](https://github.com/angular/components/commit/f01ac6e9c8bfce3056ed79937900469c1c262e69) | fix | **checkbox:** resolve change after checked error | +| [aba4c4437](https://github.com/angular/components/commit/aba4c44371c119e1c95e52874d58d50a13e6e296) | fix | **chips:** chip input not showing placeholder ([#30664](https://github.com/angular/components/pull/30664)) | +| [33795a1a1](https://github.com/angular/components/commit/33795a1a138cb4930b9e20773403ec712e0ace73) | fix | **chips:** implement disabledInteractive in chip input ([#30665](https://github.com/angular/components/pull/30665)) | +| [92bcc67c2](https://github.com/angular/components/commit/92bcc67c2d2be8c9560c0e1ab59598b20f7753da) | fix | **core:** include density tokens in system theme ([#30845](https://github.com/angular/components/pull/30845)) | +| [5bc01c398](https://github.com/angular/components/commit/5bc01c3984d2c45582b945a7e15e2b5613bc26f1) | fix | **datepicker:** resolve change after checked errors | +| [227e83d24](https://github.com/angular/components/commit/227e83d242a83d680d8cd39b36f6385c9f653102) | fix | **dialog:** breaking changes for v20 | +| [a5aade2df](https://github.com/angular/components/commit/a5aade2df3a34532924365c3ac797e4b343633f6) | fix | **form-field:** preserve aria-describedby set externally across all form controls ([#30699](https://github.com/angular/components/pull/30699)) | +| [ec538b598](https://github.com/angular/components/commit/ec538b598bb7e4b5184d460de852b31523b99710) | fix | **form-field:** use ResizeObserver for label offset calculation ([#30702](https://github.com/angular/components/pull/30702)) | +| [d120f973f](https://github.com/angular/components/commit/d120f973f8bc1f665a1d7bda9b6bacc92b4c18fb) | fix | **list:** resolve changed after checked error | +| [fd162d201](https://github.com/angular/components/commit/fd162d201807670f7eebd6e55fa0ba544a3cabfd) | fix | **schematics:** add schematic to rename tokens ([#31051](https://github.com/angular/components/pull/31051)) | +| [db090cac3](https://github.com/angular/components/commit/db090cac310118915c91cec6187ef9c1cfa7ced5) | fix | **select:** breaking changes for v20 | +| [688c430fe](https://github.com/angular/components/commit/688c430fe8df09152ee59eb8a2ca62b5ba2fb81e) | fix | **select:** close panel on detach output event ([#30634](https://github.com/angular/components/pull/30634)) | +| [12f96e229](https://github.com/angular/components/commit/12f96e2297d04782246c5e726da33722168e2c35) | fix | **select:** resolve change after checked errors | +| [404868707](https://github.com/angular/components/commit/40486870719f8d6a12ee8144e690d4225d41a704) | fix | **sidenav:** resolve changed after checked error | +| [c6ad44c68](https://github.com/angular/components/commit/c6ad44c68d478833ddfa5d55a14922921f858771) | fix | **slide-toggle:** breaking changes for v20 | | [d3a8c5b89](https://github.com/angular/components/commit/d3a8c5b8921c057a7655b44e2e6fc7c282b66b0b) | fix | **slide-toggle:** opacity tokens not being used ([#30946](https://github.com/angular/components/pull/30946)) | +| [2d86ad441](https://github.com/angular/components/commit/2d86ad441eeb3ec5fac3e99d7c9081db356615b3) | fix | **slider:** fix clobbered token for hover/focus/pressed ([#30754](https://github.com/angular/components/pull/30754)) | +| [0e985f2c6](https://github.com/angular/components/commit/0e985f2c671b6472f6c4f5c3ecdce11e3c1c85f3) | fix | **slider:** handle null values in slider input ([#30621](https://github.com/angular/components/pull/30621)) | +| [8b033742b](https://github.com/angular/components/commit/8b033742b68c08b733e2df4caf0fe0ced61db57a) | fix | **snack-bar:** change politeness level ([#30851](https://github.com/angular/components/pull/30851)) | +| [40d0ab4fc](https://github.com/angular/components/commit/40d0ab4fc73f059cb5496c4c035d4dcebc08b8b0) | fix | **snack-bar:** rename snackbar token prefix ([#30750](https://github.com/angular/components/pull/30750)) | | [0cf6ba107](https://github.com/angular/components/commit/0cf6ba1076dff1a7ec9f6e6c88f39a71751bb5d9) | fix | **stepper:** done icon tokens not emitted in M3 ([#30996](https://github.com/angular/components/pull/30996)) | -| [50a9a9b9d](https://github.com/angular/components/commit/50a9a9b9daabc06e16a63f03668df2fde192ab44) | fix | **table:** height tokens not applying to flex table ([#30978](https://github.com/angular/components/pull/30978)) | +| [e1f97f920](https://github.com/angular/components/commit/e1f97f920bbcf0c653d2ea743d7deb5af12cfde6) | fix | **stepper:** resolve changed after checked errors | | [c63b1fe7b](https://github.com/angular/components/commit/c63b1fe7b340796d21492b9ccc28e91df6464f37) | fix | **tabs:** flicker when animationDuration is set to zero ([#30966](https://github.com/angular/components/pull/30966)) | +| [922c678c0](https://github.com/angular/components/commit/922c678c0790161c5b9dc001393e82fafc607025) | fix | **tabs:** not emitting token in M3 ([#31136](https://github.com/angular/components/pull/31136)) | +| [1a800decc](https://github.com/angular/components/commit/1a800decc8656ae118c64394647b5481bb32379b) | fix | **tabs:** resolve change after checked errors | | [6c9678e69](https://github.com/angular/components/commit/6c9678e6902f13a841511df671efc724720cca1e) | perf | **form-field:** set notch outline variable only on relevant element ([#30955](https://github.com/angular/components/pull/30955)) | +| [10a6ea493](https://github.com/angular/components/commit/10a6ea493bf8fd845a7fec81ff2036488812d67f) | perf | **form-field:** split DOM accesses into read and write ([#31086](https://github.com/angular/components/pull/31086)) | ### cdk | Commit | Type | Description | | -- | -- | -- | | [06821d85a](https://github.com/angular/components/commit/06821d85a0d75648fcc936c714f333908d5be017) | feat | **dialog:** add closePredicate option | -| [e4a1f54d8](https://github.com/angular/components/commit/e4a1f54d80f8cf2ea05cd7153b798eb5e6c2bd03) | fix | **overlay:** reduce touch interaction delay on backdrop ([#30971](https://github.com/angular/components/pull/30971)) | +| [cf619601a](https://github.com/angular/components/commit/cf619601a8b58aa44bcbbb4793d4ee8a9d9de3ec) | feat | **drag-drop:** introduce `resetToBoundary` ([#30436](https://github.com/angular/components/pull/30436)) | +| [77c8534ff](https://github.com/angular/components/commit/77c8534ff17174998045d5fd857ab139a403e64a) | fix | **accordion:** resolve changed after checked error | +| [1167d0638](https://github.com/angular/components/commit/1167d063882d8f85f30c682244f92804f59afc2b) | fix | **collections:** breaking changes for v20 | +| [79e887219](https://github.com/angular/components/commit/79e887219316c5d606d687c7a31fc387507da40e) | fix | **dialog:** breaking changes for v20 | +| [a2ab84da1](https://github.com/angular/components/commit/a2ab84da1788075dadcf653c007abe9f9ac00e43) | fix | **dialog:** provide proper shaped Directionality ([#30898](https://github.com/angular/components/pull/30898)) | +| [b3e516f2d](https://github.com/angular/components/commit/b3e516f2d1d0cb09b6e1c150d094717f8e68dee3) | fix | **drag-drop:** breaking changes for v20 | +| [69eedd75f](https://github.com/angular/components/commit/69eedd75f79ceec549ecaf0a8dfc38c36e33f02a) | fix | **drag-drop:** incorrect type DragConstrainPosition ([#30510](https://github.com/angular/components/pull/30510)) | +| [f9973ee23](https://github.com/angular/components/commit/f9973ee231166e02bb176f5c6172f1d4a1e0bfef) | fix | **overlay:** use MutationObserver to detach overlay ([#30703](https://github.com/angular/components/pull/30703)) | +| [11599f808](https://github.com/angular/components/commit/11599f808f823b38e7c3eca99fb5bbf1b48ad922) | fix | **portal:** remove deprecated symbols ([#30584](https://github.com/angular/components/pull/30584)) | | [87501e866](https://github.com/angular/components/commit/87501e8662e1bd551376cd452fdd181b4091751c) | fix | **schematics:** support project index file discovery for object-form and default ([#30967](https://github.com/angular/components/pull/30967)) | -| [6bdea0b86](https://github.com/angular/components/commit/6bdea0b864e153ab501557aa480418c798c5c869) | fix | **stepper:** resolve null pointer errors ([#30944](https://github.com/angular/components/pull/30944)) | +| [8078efc21](https://github.com/angular/components/commit/8078efc21403d5fc83a0cb40a17df43b64a28c67) | fix | **table:** breaking changes for v20 | | [0f48b04dd](https://github.com/angular/components/commit/0f48b04dd340461811f974d112c05374a5fb1a4a) | fix | **table:** move out unused style scheduler ([#30963](https://github.com/angular/components/pull/30963)) | | [6ffe9c748](https://github.com/angular/components/commit/6ffe9c748ef85cea8c7af1018962d5807a78a8ee) | fix | **table:** remove private symbols from public API ([#30956](https://github.com/angular/components/pull/30956)) | +| [08f4acfef](https://github.com/angular/components/commit/08f4acfefd57bcc213e34ac399f04c3923c53e26) | perf | **overlay:** add tree-shakeable alternatives for overlay APIs ([#30904](https://github.com/angular/components/pull/30904)) | +### material-angular-io +| Commit | Type | Description | +| -- | -- | -- | +| [3a1a0b368](https://github.com/angular/components/commit/3a1a0b3685866103d297b80b1b6bbb27b95fb307) | fix | streamline directive and component metadata handling in dgeni ([#30661](https://github.com/angular/components/pull/30661)) | ### multiple | Commit | Type | Description | | -- | -- | -- | +| [e1cf2844f](https://github.com/angular/components/commit/e1cf2844f047bfd044cb8b5306faad4bdc09bdbd) | fix | avoid repeating selectors in m3 themes ([#30645](https://github.com/angular/components/pull/30645)) | | [3465f9271](https://github.com/angular/components/commit/3465f9271811733fb623536af0e53cd529b4b2a9) | fix | ensure mat-option parents are the offsetParent ([#30977](https://github.com/angular/components/pull/30977)) | +| [cb3b0a87a](https://github.com/angular/components/commit/cb3b0a87a7528aa2f3525f951a021398821df970) | fix | ensure re-exported module symbols can be imported ([#30667](https://github.com/angular/components/pull/30667)) | +| [590971359](https://github.com/angular/components/commit/590971359f053f93e891d84d8544c6295d6d321e) | fix | remove backwards-compatibility code ([#30747](https://github.com/angular/components/pull/30747)) | +| [cda5c8e68](https://github.com/angular/components/commit/cda5c8e686b0c4394b382db08c2d0b4b793ac7d6) | fix | remove remaining references to ComponentFactoryResolver ([#30569](https://github.com/angular/components/pull/30569)) | +| [5229fe0a0](https://github.com/angular/components/commit/5229fe0a0824a94baf1c595b798ae11fd19f1a09) | fix | rename hardcoded tokens that were renamed ([#30807](https://github.com/angular/components/pull/30807)) | +| [9bf4be9e2](https://github.com/angular/components/commit/9bf4be9e231add2698324f020ef2c8c3c28769cc) | fix | rename token prefixes to match components ([#30802](https://github.com/angular/components/pull/30802)) | +| [32353fb3a](https://github.com/angular/components/commit/32353fb3ab97de9a5617115b92dafa667d53f1e0) | fix | resolve directive host binding issues ([#30606](https://github.com/angular/components/pull/30606)) | +| [48894ad55](https://github.com/angular/components/commit/48894ad5570e3dfdbd3e2128ebcd88c206d80733) | fix | use DI token for tag name ([#30892](https://github.com/angular/components/pull/30892)) | - -# 19.2.14 "sodium-scallop" (2025-05-01) + +# 19.2.18 "calcium-coconut" (2025-05-28) - -# 20.0.0-next.8 "glutamate-gyoza" (2025-04-23) -### cdk + +# 19.2.17 "fulgurite-fiesta" (2025-05-21) +### material | Commit | Type | Description | | -- | -- | -- | -| [cf619601a8](https://github.com/angular/components/commit/cf619601a8b58aa44bcbbb4793d4ee8a9d9de3ec) | feat | **drag-drop:** introduce `resetToBoundary` ([#30436](https://github.com/angular/components/pull/30436)) | -| [a2ab84da17](https://github.com/angular/components/commit/a2ab84da1788075dadcf653c007abe9f9ac00e43) | fix | **dialog:** provide proper shaped Directionality ([#30898](https://github.com/angular/components/pull/30898)) | -| [1dd643e208](https://github.com/angular/components/commit/1dd643e20880a5116809416b5d3efa942ed11dc6) | fix | **overlay:** scroll was blocked when zoomed out even if scrolling wasn't an option ([#30762](https://github.com/angular/components/pull/30762)) | -| [22876a904e](https://github.com/angular/components/commit/22876a904e755f829af77f44aff0711964f9b344) | fix | **schematics:** account for new karma builder ([#30907](https://github.com/angular/components/pull/30907)) | -| [08f4acfefd](https://github.com/angular/components/commit/08f4acfefd57bcc213e34ac399f04c3923c53e26) | perf | **overlay:** add tree-shakeable alternatives for overlay APIs ([#30904](https://github.com/angular/components/pull/30904)) | +| [0e300d1041](https://github.com/angular/components/commit/0e300d10413056bc53f67dcbd164b5eb8b54572a) | fix | **datepicker:** prevent calendar from stealing away focus ([#31128](https://github.com/angular/components/pull/31128)) | +| [af04c772ed](https://github.com/angular/components/commit/af04c772ed5cc5d324f7eb937f57e3333b04a6f3) | fix | **dialog:** use custom token to disable animations ([#31156](https://github.com/angular/components/pull/31156)) | +| [306eef1d74](https://github.com/angular/components/commit/306eef1d74be438735edcff6a3bd1a01ed58aad2) | fix | **radio:** assistive technology announcing button as invalid ([#31160](https://github.com/angular/components/pull/31160)) | +| [bd614b4a69](https://github.com/angular/components/commit/bd614b4a69c147ece0a0f0eacdac9714567080e1) | fix | **radio:** updating required value should mark for check ([#31109](https://github.com/angular/components/pull/31109)) | +| [ca231dc9b8](https://github.com/angular/components/commit/ca231dc9b80f7c2711efe74544b6a37945b1c1b4) | fix | **sidenav:** removes -1 tabindex from sidenav ([#31155](https://github.com/angular/components/pull/31155)) | +| [33d8f3347e](https://github.com/angular/components/commit/33d8f3347ef56dfc963ad6264b849f0b0dc96a92) | fix | **tabs:** avoid not having any focusable tabs ([#31144](https://github.com/angular/components/pull/31144)) | + + + + +# 19.2.16 "metal-monkey" (2025-05-14) ### material | Commit | Type | Description | | -- | -- | -- | -| [204b289588](https://github.com/angular/components/commit/204b289588106d90c991f26a50c23c80c68d4fc3) | fix | **list:** update documentation ([#30883](https://github.com/angular/components/pull/30883)) | +| [08f5fda901](https://github.com/angular/components/commit/08f5fda901c169250e934e6a8aed9248bac08303) | fix | **button-toggle:** use smaller border radius ([#31084](https://github.com/angular/components/pull/31084)) | +| [68928e58aa](https://github.com/angular/components/commit/68928e58aa9a4588e88dbf04c50c1bf9828c9798) | fix | **button:** inherit button shape in focus indicator ([#31065](https://github.com/angular/components/pull/31065)) | +| [6abe7510b2](https://github.com/angular/components/commit/6abe7510b271a4edbdd566b059c6c6fc90edad8c) | fix | **schematics:** incorrect on-tertiary color being generated ([#31061](https://github.com/angular/components/pull/31061)) | ### cdk-experimental | Commit | Type | Description | | -- | -- | -- | -| [e0446ecfcb](https://github.com/angular/components/commit/e0446ecfcb3d74a73f8b4799cb17d2098ba6c433) | fix | **listbox:** change shift+nav behavior ([#30854](https://github.com/angular/components/pull/30854)) | -### multiple -| Commit | Type | Description | -| -- | -- | -- | -| [48894ad557](https://github.com/angular/components/commit/48894ad5570e3dfdbd3e2128ebcd88c206d80733) | fix | use DI token for tag name ([#30892](https://github.com/angular/components/pull/30892)) | +| [af0b4638f2](https://github.com/angular/components/commit/af0b4638f2a55d4e8280c12a66f5eb9a0c949d9c) | fix | **column-resize:** Obscure weird behavior in tables that are too wide by conditionally disabling resize ([#31100](https://github.com/angular/components/pull/31100)) | - -# 19.2.11 "dopamine-dumpling" (2025-04-23) + +# 19.2.15 "abuite-ant" (2025-05-07) ### cdk | Commit | Type | Description | | -- | -- | -- | -| [0509a99d27](https://github.com/angular/components/commit/0509a99d27655b170611903754282b7240e23cf5) | fix | **overlay:** scroll was blocked when zoomed out even if scrolling wasn't an option ([#30762](https://github.com/angular/components/pull/30762)) | -| [14afce23b6](https://github.com/angular/components/commit/14afce23b6d67f8c3a5284aef5c6fa6d6937e429) | fix | **schematics:** account for new karma builder ([#30907](https://github.com/angular/components/pull/30907)) | -### material +| [85e448678](https://github.com/angular/components/commit/85e448678db1a37d3e93853513edc16f6b336044) | fix | **dialog:** avoid setting aria-hidden before focus has moved ([#31030](https://github.com/angular/components/pull/31030)) ([#31036](https://github.com/angular/components/pull/31036)) | +### docs | Commit | Type | Description | | -- | -- | -- | -| [4d0dcc49f8](https://github.com/angular/components/commit/4d0dcc49f8d58852963dabab0ab18c98b72d7c0c) | fix | **list:** update documentation ([#30883](https://github.com/angular/components/pull/30883)) | +| [02a893a6d](https://github.com/angular/components/commit/02a893a6de80089c1214283fbfb5e0ee62a8569f) | fix | Automatic scrolling fails on screens smaller than 721px due to flexbox behavior ([#31037](https://github.com/angular/components/pull/31037)) | - -# 20.0.0-next.7 "metal-keys" (2025-04-16) + +# 19.2.14 "sodium-scallop" (2025-05-01) + + + + +# 19.2.11 "dopamine-dumpling" (2025-04-23) ### cdk | Commit | Type | Description | | -- | -- | -- | -| [91a0cf9b02](https://github.com/angular/components/commit/91a0cf9b02ba34d3775ba7ec73a88a1a1624cc7f) | fix | **overlay:** disable overlay animation with prefers-reduced-motion ([#30858](https://github.com/angular/components/pull/30858)) | +| [0509a99d27](https://github.com/angular/components/commit/0509a99d27655b170611903754282b7240e23cf5) | fix | **overlay:** scroll was blocked when zoomed out even if scrolling wasn't an option ([#30762](https://github.com/angular/components/pull/30762)) | +| [14afce23b6](https://github.com/angular/components/commit/14afce23b6d67f8c3a5284aef5c6fa6d6937e429) | fix | **schematics:** account for new karma builder ([#30907](https://github.com/angular/components/pull/30907)) | ### material | Commit | Type | Description | | -- | -- | -- | -| [5dcbba70a9](https://github.com/angular/components/commit/5dcbba70a959e1ceacbef6b9f3db26e48e82696d) | fix | **bottom-sheet:** page jumping if backdrop-filter is applied ([#30840](https://github.com/angular/components/pull/30840)) | -| [92bcc67c2d](https://github.com/angular/components/commit/92bcc67c2d2be8c9560c0e1ab59598b20f7753da) | fix | **core:** include density tokens in system theme ([#30845](https://github.com/angular/components/pull/30845)) | -| [a5aade2df3](https://github.com/angular/components/commit/a5aade2df3a34532924365c3ac797e4b343633f6) | fix | **form-field:** preserve aria-describedby set externally across all form controls ([#30699](https://github.com/angular/components/pull/30699)) | -| [8b033742b6](https://github.com/angular/components/commit/8b033742b68c08b733e2df4caf0fe0ced61db57a) | fix | **snack-bar:** change politeness level ([#30851](https://github.com/angular/components/pull/30851)) | -### youtube-player -| Commit | Type | Description | -| -- | -- | -- | -| [18ddea3307](https://github.com/angular/components/commit/18ddea33077d5c7c805f6dcf8f6e31f7d1863dd2) | fix | use safevalues ([#30773](https://github.com/angular/components/pull/30773)) | -### cdk-experimental -| Commit | Type | Description | -| -- | -- | -- | -| [9caed2a8a7](https://github.com/angular/components/commit/9caed2a8a7b0433383b19526cadbf20325685040) | feat | **ui-patterns:** tabs ui pattern ([#30568](https://github.com/angular/components/pull/30568)) | -| [184ceee3a1](https://github.com/angular/components/commit/184ceee3a1abd829f303c606a05403b97a93a1b6) | fix | **listbox:** ignore spaces during typeahead ([#30766](https://github.com/angular/components/pull/30766)) | -| [a7df65f3e0](https://github.com/angular/components/commit/a7df65f3e0faf6c3dca96876d99df67c4e2f42b4) | fix | **listbox:** use meta as alternative to ctrl ([#30852](https://github.com/angular/components/pull/30852)) | -| [ae8f9dd282](https://github.com/angular/components/commit/ae8f9dd2828107af387062bae1aad03a9c8d6204) | fix | **ui-patterns:** fix lint and build config ([#30877](https://github.com/angular/components/pull/30877)) | -| [10890983c8](https://github.com/angular/components/commit/10890983c8b91a5d5280e85b158b1a28eedda951) | fix | **ui-patterns:** listbox pointer event handler ([#30843](https://github.com/angular/components/pull/30843)) | +| [4d0dcc49f8](https://github.com/angular/components/commit/4d0dcc49f8d58852963dabab0ab18c98b72d7c0c) | fix | **list:** update documentation ([#30883](https://github.com/angular/components/pull/30883)) | @@ -131,33 +398,6 @@ - -# 20.0.0-next.6 "abelsonite-avocado" (2025-04-09) -### cdk -| Commit | Type | Description | -| -- | -- | -- | -| [b1cc0501e](https://github.com/angular/components/commit/b1cc0501e969362d8c051568eee98767442cc3fd) | fix | **table:** error if data is accessed too early ([#30817](https://github.com/angular/components/pull/30817)) | -### material -| Commit | Type | Description | -| -- | -- | -- | -| [82f0fa6fa](https://github.com/angular/components/commit/82f0fa6fa750779a04687d2564537e672a326b90) | feat | **core:** handle prefers-reduced-motion automatically ([#30796](https://github.com/angular/components/pull/30796)) | -### cdk-experimental -| Commit | Type | Description | -| -- | -- | -- | -| [6ac4c1ae2](https://github.com/angular/components/commit/6ac4c1ae2ca67564eef15e11f5b4363c5262747e) | feat | **listbox:** readonly mode ([#30803](https://github.com/angular/components/pull/30803)) | -| [2e34b20cb](https://github.com/angular/components/commit/2e34b20cb7d2ede488c5df4ca858684524ef8073) | fix | **ui-patterns:** add missing event handlers ([#30786](https://github.com/angular/components/pull/30786)) | -### material-angular-io -| Commit | Type | Description | -| -- | -- | -- | -| [3a1a0b368](https://github.com/angular/components/commit/3a1a0b3685866103d297b80b1b6bbb27b95fb307) | fix | streamline directive and component metadata handling in dgeni ([#30661](https://github.com/angular/components/pull/30661)) | -### multiple -| Commit | Type | Description | -| -- | -- | -- | -| [5229fe0a0](https://github.com/angular/components/commit/5229fe0a0824a94baf1c595b798ae11fd19f1a09) | fix | rename hardcoded tokens that were renamed ([#30807](https://github.com/angular/components/pull/30807)) | -| [9bf4be9e2](https://github.com/angular/components/commit/9bf4be9e231add2698324f020ef2c8c3c28769cc) | fix | rename token prefixes to match components ([#30802](https://github.com/angular/components/pull/30802)) | - - - # 19.2.9 "abellaite-apron" (2025-04-09) ### cdk @@ -167,36 +407,6 @@ - -# 20.0.0-next.5 "chitin-enigma" (2025-04-02) -### cdk -| Commit | Type | Description | -| -- | -- | -- | -| [f9973ee231](https://github.com/angular/components/commit/f9973ee231166e02bb176f5c6172f1d4a1e0bfef) | fix | **overlay:** use MutationObserver to detach overlay ([#30703](https://github.com/angular/components/pull/30703)) | -| [1372f52ae6](https://github.com/angular/components/commit/1372f52ae6774015b1a956aa1dbcde12d62eb42f) | perf | **drag-drop:** fix performance regression when destroying items ([#30751](https://github.com/angular/components/pull/30751)) | -### material -| Commit | Type | Description | -| -- | -- | -- | -| [3daa360300](https://github.com/angular/components/commit/3daa360300a98e0c63db770f6812faa94eea1133) | feat | **core:** introduce custom token for controlling animations ([#30749](https://github.com/angular/components/pull/30749)) | -| [907815dc5c](https://github.com/angular/components/commit/907815dc5cecc2b56d54fd9c525b1e37fb9002be) | fix | **checkbox:** hardcode token renames ([#30752](https://github.com/angular/components/pull/30752)) | -| [e9b015737c](https://github.com/angular/components/commit/e9b015737ca86eabd18e1ce2411dd4e5c8882dc4) | fix | **form-field:** move error aria-live to parent container ([#30678](https://github.com/angular/components/pull/30678)) | -| [ec538b598b](https://github.com/angular/components/commit/ec538b598bb7e4b5184d460de852b31523b99710) | fix | **form-field:** use ResizeObserver for label offset calculation ([#30702](https://github.com/angular/components/pull/30702)) | -| [2d86ad441e](https://github.com/angular/components/commit/2d86ad441eeb3ec5fac3e99d7c9081db356615b3) | fix | **slider:** fix clobbered token for hover/focus/pressed ([#30754](https://github.com/angular/components/pull/30754)) | -| [40d0ab4fc7](https://github.com/angular/components/commit/40d0ab4fc73f059cb5496c4c035d4dcebc08b8b0) | fix | **snack-bar:** rename snackbar token prefix ([#30750](https://github.com/angular/components/pull/30750)) | -| [387313fc41](https://github.com/angular/components/commit/387313fc4112399d05d2b677c9e5fcf7f2424e49) | fix | **tabs:** allow ID to be set on tab ([#30768](https://github.com/angular/components/pull/30768)) | -### cdk-experimental -| Commit | Type | Description | -| -- | -- | -- | -| [149fe6129c](https://github.com/angular/components/commit/149fe6129c9647b0d330ec47d901909f01f933a5) | fix | **popover-edit:** use MutationObserver to detect table rows ([#30713](https://github.com/angular/components/pull/30713)) | -| [4bf3591c77](https://github.com/angular/components/commit/4bf3591c77af15881a5d49b73492ddfa023d9833) | fix | **ui-patterns:** add modifier checks for regexps ([#30738](https://github.com/angular/components/pull/30738)) | -| [dd69023340](https://github.com/angular/components/commit/dd69023340987ccc0371abfed30603df6982e36f) | perf | **column-resize:** improve hover responsiveness of… ([#30708](https://github.com/angular/components/pull/30708)) | -### multiple -| Commit | Type | Description | -| -- | -- | -- | -| [590971359f](https://github.com/angular/components/commit/590971359f053f93e891d84d8544c6295d6d321e) | fix | remove backwards-compatibility code ([#30747](https://github.com/angular/components/pull/30747)) | - - - # 19.2.8 "sapphire-kraken" (2025-04-02) ### cdk @@ -215,20 +425,6 @@ - -# 20.0.0-next.4 "bohrium-beans" (2025-03-26) -### cdk -| Commit | Type | Description | -| -- | -- | -- | -| [985721c5d](https://github.com/angular/components/commit/985721c5d013f43ee5b779e1b87b2e5ec7eddf28) | fix | **menu:** not clearing aria-controls on context menu ([#30680](https://github.com/angular/components/pull/30680)) | -### material -| Commit | Type | Description | -| -- | -- | -- | -| [3aecb3521](https://github.com/angular/components/commit/3aecb3521614d1bae40a5233d7fcc609fed0e632) | fix | **button:** tonal touch target token transgression ([#30688](https://github.com/angular/components/pull/30688)) | -| [2d7da0ac7](https://github.com/angular/components/commit/2d7da0ac706d6eb32439c256d9b3fa2e1d7c9225) | fix | **sort:** set cursor on entire clickable area ([#30696](https://github.com/angular/components/pull/30696)) | - - - # 19.2.7 "bohrium-banana" (2025-03-26) ### cdk @@ -242,26 +438,6 @@ - -# 20.0.0-next.3 "iridium-irrigator" (2025-03-21) -### cdk -| Commit | Type | Description | -| -- | -- | -- | -| [1e5082dd5](https://github.com/angular/components/commit/1e5082dd5a47af38e3716bd384d89195ecdd09d7) | fix | **overlay:** ensure re-exported transitive `Dir` directive can be imported ([#30679](https://github.com/angular/components/pull/30679)) | -| [1b4cae7f2](https://github.com/angular/components/commit/1b4cae7f2914cefdb526461ed0405432fcf738b7) | fix | **tree:** retainining previous objects ([#30431](https://github.com/angular/components/pull/30431)) | -### material -| Commit | Type | Description | -| -- | -- | -- | -| [aba4c4437](https://github.com/angular/components/commit/aba4c44371c119e1c95e52874d58d50a13e6e296) | fix | **chips:** chip input not showing placeholder ([#30664](https://github.com/angular/components/pull/30664)) | -| [33795a1a1](https://github.com/angular/components/commit/33795a1a138cb4930b9e20773403ec712e0ace73) | fix | **chips:** implement disabledInteractive in chip input ([#30665](https://github.com/angular/components/pull/30665)) | -| [ee442555e](https://github.com/angular/components/commit/ee442555e245184dea2703d14d58d9e8a6b34bd4) | fix | **timepicker:** TimepickerInput component in shadow DOM ([#30642](https://github.com/angular/components/pull/30642)) | -### multiple -| Commit | Type | Description | -| -- | -- | -- | -| [cb3b0a87a](https://github.com/angular/components/commit/cb3b0a87a7528aa2f3525f951a021398821df970) | fix | ensure re-exported module symbols can be imported ([#30667](https://github.com/angular/components/pull/30667)) | - - - # 19.2.6 "tellurium-transponder" (2025-03-21) ### cdk @@ -288,41 +464,6 @@ - -# 20.0.0-next.2 "bismite-bomb" (2025-03-19) -## Breaking Changes -### material -- * `ButtonVariant` which is returned by `MatButtonHarness.getVariant` no longer includes the appearance of the button. Use `MatButtonHarness.getAppearance` instead. -### cdk-experimental -| Commit | Type | Description | -| -- | -- | -- | -| [7343657f5](https://github.com/angular/components/commit/7343657f5a562990418e3859d5409c8681f03ea1) | fix | **ui-patterns:** small fixes for internal compatibi… ([#30625](https://github.com/angular/components/pull/30625)) | -### material -| Commit | Type | Description | -| -- | -- | -- | -| [fb81ab4f2](https://github.com/angular/components/commit/fb81ab4f234498acd5b9925087bb712a7db039f6) | feat | **button:** add support for tonal button ([#30638](https://github.com/angular/components/pull/30638)) | -| [e79f60558](https://github.com/angular/components/commit/e79f60558fec6055c78dddc9d6e291a600778bc5) | feat | **button:** allow appearance to be set dynamically | -| [097f49d90](https://github.com/angular/components/commit/097f49d90f5fe079a0399fd499202a8bdd1542cf) | fix | **autocomplete:** allow overlay backdrop by setting hasBackdrop option ([#30631](https://github.com/angular/components/pull/30631)) | -| [2c7199c02](https://github.com/angular/components/commit/2c7199c02886ebf0c8c668f9290ed47a1b7b20ab) | fix | **button:** align harness with new terminology | -| [443df2661](https://github.com/angular/components/commit/443df2661fa3174bd2c854d9a094c8ade1b47446) | fix | **card:** remove alternate token for container-color ([#30640](https://github.com/angular/components/pull/30640)) | -| [09b25a159](https://github.com/angular/components/commit/09b25a159da1bad70ac84ac2d26824e547db3bc8) | fix | **chips:** adds default placeholder for chips input ([#30452](https://github.com/angular/components/pull/30452)) | -| [b4fcae45d](https://github.com/angular/components/commit/b4fcae45d51f2c1da1df8145aca5d12a7616e2d4) | fix | **core:** include alternative tokens ([#30633](https://github.com/angular/components/pull/30633)) | -| [75713b0a0](https://github.com/angular/components/commit/75713b0a0ae95c8a54d381ffbf91e28fbf2db89a) | fix | **paginator:** prevent keyboard nav to disabled buttons ([#30627](https://github.com/angular/components/pull/30627)) | -| [688c430fe](https://github.com/angular/components/commit/688c430fe8df09152ee59eb8a2ca62b5ba2fb81e) | fix | **select:** close panel on detach output event ([#30634](https://github.com/angular/components/pull/30634)) | -| [d5ba63d00](https://github.com/angular/components/commit/d5ba63d00a635059ac0c8dcc5ebb495a55777f0a) | fix | **select:** use flexible overlay dimensions ([#30628](https://github.com/angular/components/pull/30628)) | -| [0e985f2c6](https://github.com/angular/components/commit/0e985f2c671b6472f6c4f5c3ecdce11e3c1c85f3) | fix | **slider:** handle null values in slider input ([#30621](https://github.com/angular/components/pull/30621)) | -| [43b08600c](https://github.com/angular/components/commit/43b08600cedc82d99fff75edf55837d02a70c276) | fix | **timepicker:** assertion error if reopened quickly ([#30639](https://github.com/angular/components/pull/30639)) | -### cdk -| Commit | Type | Description | -| -- | -- | -- | -| [dc70692ab](https://github.com/angular/components/commit/dc70692abafe578a140b7e2b6020663643cd271e) | fix | **text-field:** Long multiline textfield focus issue ([#30312](https://github.com/angular/components/pull/30312)) | -### multiple -| Commit | Type | Description | -| -- | -- | -- | -| [e1cf2844f](https://github.com/angular/components/commit/e1cf2844f047bfd044cb8b5306faad4bdc09bdbd) | fix | avoid repeating selectors in m3 themes ([#30645](https://github.com/angular/components/pull/30645)) | - - - # 19.2.4 "cafetite-cafe" (2025-03-19) ### cdk @@ -361,95 +502,6 @@ - -# 20.0.0-next.1 "fernium-fern" (2025-03-12) -## Breaking Changes -### cdk -- * `SelectionModel.clear` now returns a boolean. - * `SelectionModel.deselect` now returns a boolean. - * `SelectionModel.select` now returns a boolean. - * `SelectionModel.setSelection` now returns a boolean. - * `SelectionModel.toggle` now returns a boolean. -- * `DIALOG_SCROLL_STRATEGY_PROVIDER` has been removed. - * `DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY` has been removed. -- * `DragDropRegistry` is no longer generic. - * `DragDropRegistry.scroll` has been removed. Use the `scrolled` method instead. -- * `DomPortalHost` has been removed. Use `DomPortalOutlet` instead. - * `PortalInjector` has been removed. Use `Injector.create` instead. - * `PortalHost` has been removed. Use `PortalOutlet` instead. - * `BasePortalHost` has been removed. Use `BasePortalOutlet` instead. -- * `Constructor` symbol has been removed. - * `CanStickCtor` symbol has been removed. - * `mixinHasStickyInput` has been removed. Implement the `CanStick` interface instead. -### material -- * `MAT_CHECKBOX_REQUIRED_VALIDATOR` has been removed. - * `MAT_CHECKBOX_VALUE_ACCESSOR` has been removed. - * `MatCheckboxRequiredValidator` has been removed. - * `_MatCheckboxRequiredValidatorModule` has been removed. -- * `MAT_DIALOG_SCROLL_STRATEGY_PROVIDER` has been removed. - * `MAT_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY` has been removed. -- * `matSelectAnimations.transformPanelWrap` has been removed. -- * `MAT_SLIDE_TOGGLE_REQUIRED_VALIDATOR` has been removed. - * `MAT_SLIDE_TOGGLE_VALUE_ACCESSOR` has been removed. - * `MatSlideToggleRequiredValidator` has been removed. - * `_MatSlideToggleRequiredValidatorModule` has been removed. -### material -| Commit | Type | Description | -| -- | -- | -- | -| [e61d152e0](https://github.com/angular/components/commit/e61d152e002937f4bc9df96851c2932e15ce7461) | fix | **autocomplete:** incorrectly resolving focused element in shadow DOM ([#30619](https://github.com/angular/components/pull/30619)) | -| [77d6b69e7](https://github.com/angular/components/commit/77d6b69e7789d97220afac64144e8025c5960353) | fix | **checkbox:** breaking changes for v20 | -| [227e83d24](https://github.com/angular/components/commit/227e83d242a83d680d8cd39b36f6385c9f653102) | fix | **dialog:** breaking changes for v20 | -| [da371e9ca](https://github.com/angular/components/commit/da371e9ca41013f427fd8f5ee99eef611fe42c46) | fix | **schematics:** some options missing from schema ([#30602](https://github.com/angular/components/pull/30602)) | -| [db090cac3](https://github.com/angular/components/commit/db090cac310118915c91cec6187ef9c1cfa7ced5) | fix | **select:** breaking changes for v20 | -| [c6ad44c68](https://github.com/angular/components/commit/c6ad44c68d478833ddfa5d55a14922921f858771) | fix | **slide-toggle:** breaking changes for v20 | -| [3a970116a](https://github.com/angular/components/commit/3a970116af15928d47958fd353fc091049416b38) | fix | **timepicker:** wrong default scroll strategy ([#30593](https://github.com/angular/components/pull/30593)) | -### cdk -| Commit | Type | Description | -| -- | -- | -- | -| [1167d0638](https://github.com/angular/components/commit/1167d063882d8f85f30c682244f92804f59afc2b) | fix | **collections:** breaking changes for v20 | -| [79e887219](https://github.com/angular/components/commit/79e887219316c5d606d687c7a31fc387507da40e) | fix | **dialog:** breaking changes for v20 | -| [b3e516f2d](https://github.com/angular/components/commit/b3e516f2d1d0cb09b6e1c150d094717f8e68dee3) | fix | **drag-drop:** breaking changes for v20 | -| [d0d5de489](https://github.com/angular/components/commit/d0d5de489750f396c63a92402623cf3b7abea523) | fix | **portal:** fix incorrect injector hierarchy with DomPortalOutlet and child environment injectors ([#30610](https://github.com/angular/components/pull/30610)) | -| [11599f808](https://github.com/angular/components/commit/11599f808f823b38e7c3eca99fb5bbf1b48ad922) | fix | **portal:** remove deprecated symbols ([#30584](https://github.com/angular/components/pull/30584)) | -| [8078efc21](https://github.com/angular/components/commit/8078efc21403d5fc83a0cb40a17df43b64a28c67) | fix | **table:** breaking changes for v20 | -### multiple -| Commit | Type | Description | -| -- | -- | -- | -| [810495ce0](https://github.com/angular/components/commit/810495ce0085c4faff49143e340f8bf46a02fe31) | fix | deprecate exported factories ([#30608](https://github.com/angular/components/pull/30608)) | -| [32353fb3a](https://github.com/angular/components/commit/32353fb3ab97de9a5617115b92dafa667d53f1e0) | fix | resolve directive host binding issues ([#30606](https://github.com/angular/components/pull/30606)) | - - - - -# 20.0.0-next.0 "metal-cat" (2025-03-05) -## Breaking Changes -### material -- `tabindex` values set as `[attr.tabindex]` set on a Material button might not work as expected. Use `tabindex` for static values, or `[tabindex]`/`[tabIndex]` for dynamic ones. -### multiple -- * `DialogConfig.componentFactoryResolver` has been removed. - * The constructor of `DomPortalOutlet` has changed. - * `ComponentPortal.componentFactoryResolver` has been removed. - * The constructor signature of `ComponentPortal` has changed. - * `MatDialogConfig.componentFactoryResolver` has been removed. -### cdk-experimental -| Commit | Type | Description | -| -- | -- | -- | -| [fc46997442](https://github.com/angular/components/commit/fc46997442b72bc1ba395fcd5f008b0358e5c91f) | feat | **ui-patterns:** listbox ui pattern ([#30495](https://github.com/angular/components/pull/30495)) | -### material -| Commit | Type | Description | -| -- | -- | -- | -| [6a5943d8b0](https://github.com/angular/components/commit/6a5943d8b0fe6042c885e608d7d2a071e27dd802) | fix | **button:** combine MatButton and MatAnchor ([#30492](https://github.com/angular/components/pull/30492)) | -### cdk -| Commit | Type | Description | -| -- | -- | -- | -| [69eedd75f7](https://github.com/angular/components/commit/69eedd75f79ceec549ecaf0a8dfc38c36e33f02a) | fix | **drag-drop:** incorrect type DragConstrainPosition ([#30510](https://github.com/angular/components/pull/30510)) | -### multiple -| Commit | Type | Description | -| -- | -- | -- | -| [cda5c8e686](https://github.com/angular/components/commit/cda5c8e686b0c4394b382db08c2d0b4b793ac7d6) | fix | remove remaining references to ComponentFactoryResolver ([#30569](https://github.com/angular/components/pull/30569)) | - - - # 19.2.2 "metal-llama" (2025-03-05) ### cdk @@ -3319,7 +3371,7 @@ Andy Chrzaszcz, Bobby Galli, Joey Perrott, Kristiyan Kostadinov, Miles Malerba, - `mat-tabs` - The TypeScript API should remain largely unchanged, but the DOM and CSS classes for these components have changed. - See the MDC migration guide for more information about the changes and - how to migrate your app: https://material.angular.io/guide/mdc-migration. + how to migrate your app: https://material.angular.dev/guide/mdc-migration. - Buttons inside the datepicker popup and datepicker toggle now use the MDC-based button implementation. They have different CSS classes and styles, so custom style overrides may need to be updated. @@ -6158,4 +6210,4 @@ _Breaking changes:_ # Changes Prior to 12.0.0 -To view changes that occurred prior to 12.0.0, see [CHANGELOG_ARCHIVE.md](https://github.com/angular/components/blob/main/CHANGELOG_ARCHIVE.md). \ No newline at end of file +To view changes that occurred prior to 12.0.0, see [CHANGELOG_ARCHIVE.md](https://github.com/angular/components/blob/main/CHANGELOG_ARCHIVE.md). diff --git a/CHANGELOG_ARCHIVE.md b/CHANGELOG_ARCHIVE.md index 9de1841eee84..19ab29f157a7 100644 --- a/CHANGELOG_ARCHIVE.md +++ b/CHANGELOG_ARCHIVE.md @@ -1891,8 +1891,8 @@ And the same test with harnesses: ``` Learn more about -[Angular Material's component harnesses](https://material.angular.io/guide/using-component-harnesses) -or [building your own with the CDK](https://v9.material.angular.io/cdk/testing/overview). +[Angular Material's component harnesses](https://material.angular.dev/guide/using-component-harnesses) +or [building your own with the CDK](https://v9.material.angular.dev/cdk/testing/overview). #### New `@angular/google-maps` package! @@ -1911,7 +1911,7 @@ Big thanks to @mbehrlich for contributing this! #### New `cdk/clipboard` subpackage containing helpers for working with the clipboard. This new capability for `@angular/cdk` provides some convenient helpers for dealign with -the clipboard. [See the documentation for more information](https://material.angular.io/cdk/clipboard). +the clipboard. [See the documentation for more information](https://material.angular.dev/cdk/clipboard). Big thanks to @xkxx for contributing this! @@ -3242,8 +3242,8 @@ It was always required, but is now correctly listed in the `package.json`. ### Highlights -* Support for [Drag and Drop](https://material.angular.io/cdk/drag-drop/overview) in `@angular/cdk/drag-drop`. -* Support for [Virtual Scrolling](https://material.angular.io/cdk/scrolling/overview) in `@angular/cdk/scrolling`. +* Support for [Drag and Drop](https://material.angular.dev/cdk/drag-drop/overview) in `@angular/cdk/drag-drop`. +* Support for [Virtual Scrolling](https://material.angular.dev/cdk/scrolling/overview) in `@angular/cdk/scrolling`. * Support for native ` - -
- - - - - - `; - -const WEIGHT_EDIT_TEMPLATE = ` -
-
- -
-
- `; - -const CELL_TEMPLATE = ` - {{element.name}} - - - - - `; - -const POPOVER_EDIT_DIRECTIVE_NAME = ` - [cdkPopoverEdit]="nameEdit" - [cdkPopoverEditColspan]="colspan" - [cdkPopoverEditDisabled]="nameEditDisabled" - [cdkPopoverEditAriaLabel]="nameEditAriaLabel" - `; - -const POPOVER_EDIT_DIRECTIVE_WEIGHT = `[cdkPopoverEdit]="weightEdit" cdkPopoverEditTabOut`; - interface PeriodicElement { name: string; weight: number; } -@Directive() -abstract class BaseTestComponent { - @ViewChild('table') table: ElementRef; - - preservedValues = new FormValueContainer(); - nameEditDisabled = false; - nameEditAriaLabel: string | undefined = undefined; - ignoreSubmitUnlessValid = true; - clickOutBehavior: PopoverEditClickOutBehavior = 'close'; - colspan: CdkPopoverEditColspan = {}; - direction: Direction = 'ltr'; - cdr = inject(ChangeDetectorRef); - - constructor() { - this.renderData(); - } - - abstract renderData(): void; - - onSubmit(element: PeriodicElement, form: NgForm) { - if (!form.valid) { - return; - } - - element.name = form.value['name']; - } - - triggerHoverState(rowIndex = 0) { - const row = getRows(this.table.nativeElement)[rowIndex]; - row.dispatchEvent(new Event('mouseover', {bubbles: true})); - row.dispatchEvent(new Event('mousemove', {bubbles: true})); - - // Wait for the mouse hover debounce in edit-event-dispatcher. - tick(41); - } - - getRows() { - return getRows(this.table.nativeElement); - } - - getEditCell(rowIndex = 0, cellIndex = 1) { - const row = this.getRows()[rowIndex]; - return getCells(row)[cellIndex]; - } - - focusEditCell(rowIndex = 0, cellIndex = 1) { - this.getEditCell(rowIndex, cellIndex).focus(); - } - - hoverContentStateForRow(rowIndex = 0) { - const openButton = this.getOpenButton(rowIndex); - - if (!openButton) { - return HoverContentState.OFF; - } - return parseInt(getComputedStyle(openButton.parentNode as Element).opacity || '', 10) === 0 - ? HoverContentState.FOCUSABLE - : HoverContentState.ON; - } - - getOpenButton(rowIndex = 0) { - return this.getEditCell(rowIndex).querySelector('.open') as HTMLElement | null; - } - - clickOpenButton(rowIndex = 0) { - this.getOpenButton(rowIndex)!.click(); - } - - openLens(rowIndex = 0, cellIndex = 1) { - this.focusEditCell(rowIndex, cellIndex); - this.getEditCell(rowIndex, cellIndex).dispatchEvent( - new KeyboardEvent('keydown', {bubbles: true, key: 'Enter'}), - ); - flush(); - } - - getEditPane() { - return document.querySelector('.cdk-edit-pane'); - } - - getEditBoundingBox() { - return document.querySelector('.cdk-overlay-connected-position-bounding-box'); - } - - getNameInput() { - return document.querySelector('input[name="name"]') as HTMLInputElement | null; - } - - getWeightInput() { - return document.querySelector('input[name="weight"]') as HTMLInputElement | null; - } - - lensIsOpen() { - return !!this.getNameInput(); - } - - getSubmitButton() { - return document.querySelector('.submit') as HTMLElement | null; - } - - clickSubmitButton() { - this.getSubmitButton()!.click(); - } - - getRevertButton() { - return document.querySelector('.revert') as HTMLElement | null; - } - - clickRevertButton() { - this.getRevertButton()!.click(); - } - - getCloseButton() { - return document.querySelector('.close') as HTMLElement | null; - } - - clickCloseButton() { - this.getCloseButton()!.click(); - } -} - -@Component({ - template: ` - - - ${NAME_EDIT_TEMPLATE} - - - - ${WEIGHT_EDIT_TEMPLATE} - - - @for (element of elements; track element) { - - - - - - - - } -
just a cell - ${CELL_TEMPLATE} - - {{element.weight}} -
- `, - standalone: false, -}) -class VanillaTableOutOfCell extends BaseTestComponent { - elements: ChemicalElement[]; - - renderData() { - this.elements = createElementData(); - this.cdr.markForCheck(); - } -} - -@Component({ - template: ` - - @for (element of elements; track element) { - - - - - - - - } -
just a cell - ${CELL_TEMPLATE} - - - ${NAME_EDIT_TEMPLATE} - - - {{element.weight}} - - - ${WEIGHT_EDIT_TEMPLATE} - -
- `, - standalone: false, -}) -class VanillaTableInCell extends BaseTestComponent { - elements: ChemicalElement[]; - - renderData() { - this.elements = createElementData(); - this.cdr.markForCheck(); - } -} - -class ElementDataSource extends DataSource { - /** Stream of data that is provided to the table. */ - data = new BehaviorSubject(createElementData()); - - /** Connect function called by the table to retrieve one stream containing the data to render. */ - connect() { - return this.data; - } - - disconnect() {} -} - -@Component({ - template: ` -
- - - - just a cell - - - - - - ${CELL_TEMPLATE} - - - ${NAME_EDIT_TEMPLATE} - - - - - - - - - - - {{element.weight}} - - - ${WEIGHT_EDIT_TEMPLATE} - - - - - - -
- `, - standalone: false, -}) -class CdkFlexTableInCell extends BaseTestComponent { - displayedColumns = ['before', 'name', 'weight']; - dataSource: ElementDataSource; - - renderData() { - this.dataSource = new ElementDataSource(); - this.cdr.markForCheck(); - } -} - -@Component({ - template: ` -
- - - - - - - - - - - - - - -
- just a cell - - ${CELL_TEMPLATE} - - - ${NAME_EDIT_TEMPLATE} - - - - - - - {{element.weight}} - - - ${WEIGHT_EDIT_TEMPLATE} - -
-
- `, - standalone: false, -}) -class CdkTableInCell extends BaseTestComponent { - displayedColumns = ['before', 'name', 'weight']; - dataSource: ElementDataSource; - - renderData() { - this.dataSource = new ElementDataSource(); - this.cdr.markForCheck(); - } -} - const testCases = [ - [VanillaTableOutOfCell, 'Vanilla HTML table; edit defined outside of cell'], - [VanillaTableInCell, 'Vanilla HTML table; edit defined within cell'], - [CdkFlexTableInCell, 'Flex cdk-table; edit defined within cell'], - [CdkTableInCell, 'Table cdk-table; edit defined within cell'], + [() => VanillaTableOutOfCell, 'Vanilla HTML table; edit defined outside of cell'], + [() => VanillaTableInCell, 'Vanilla HTML table; edit defined within cell'], + [() => CdkFlexTableInCell, 'Flex cdk-table; edit defined within cell'], + [() => CdkTableInCell, 'Table cdk-table; edit defined within cell'], ] as const; describe('CDK Popover Edit', () => { - for (const [componentClass, label] of testCases) { + for (const [getComponentClass, label] of testCases) { describe(label, () => { let component: BaseTestComponent; let fixture: ComponentFixture; beforeEach(fakeAsync(() => { - TestBed.configureTestingModule({ - imports: [CdkTableModule, CdkPopoverEditModule, FormsModule, BidiModule], - declarations: [componentClass], - }); - fixture = TestBed.createComponent(componentClass); + fixture = TestBed.createComponent(getComponentClass()); component = fixture.componentInstance; component.renderData(); fixture.detectChanges(); @@ -759,290 +400,694 @@ cdkPopoverEditTabOut`, fakeAsync(() => { component.openLens(); fixture.detectChanges(); - let paneRect = component.getEditPane()!.getBoundingClientRect(); - expectPixelsToEqual(paneRect.top, cellRects[0].top); - expectPixelsToEqual(paneRect.left, cellRects[0].left); - expectPixelsToEqual(paneRect.right, cellRects[1].right); + let paneRect = component.getEditPane()!.getBoundingClientRect(); + expectPixelsToEqual(paneRect.top, cellRects[0].top); + expectPixelsToEqual(paneRect.left, cellRects[0].left); + expectPixelsToEqual(paneRect.right, cellRects[1].right); + + component.colspan = {after: 1}; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + paneRect = component.getEditPane()!.getBoundingClientRect(); + expectPixelsToEqual(paneRect.top, cellRects[1].top); + expectPixelsToEqual(paneRect.left, cellRects[1].left); + expectPixelsToEqual(paneRect.right, cellRects[2].right); + + component.colspan = {before: 1, after: 1}; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + paneRect = component.getEditPane()!.getBoundingClientRect(); + expectPixelsToEqual(paneRect.top, cellRects[0].top); + expectPixelsToEqual(paneRect.left, cellRects[0].left); + expectPixelsToEqual(paneRect.right, cellRects[2].right); + clearLeftoverTimers(); + })); + + it('updates the form and submits, closing the lens', fakeAsync(() => { + component.openLens(); + fixture.detectChanges(); + + component.getNameInput()!.value = 'Hydragon'; + component.getNameInput()!.dispatchEvent(new Event('input')); + + component.clickSubmitButton(); + fixture.detectChanges(); + expect(component.getEditCell().firstChild!.textContent!.trim()).toBe('Hydragon'); + expect(component.lensIsOpen()).toBe(false); + clearLeftoverTimers(); + })); + + it('does not close the lens on submit when form is invalid', fakeAsync(() => { + component.openLens(); + fixture.detectChanges(); + + component.getNameInput()!.value = ''; + component.getNameInput()!.dispatchEvent(new Event('input')); + + component.clickSubmitButton(); + + expect(component.lensIsOpen()).toBe(true); + clearLeftoverTimers(); + })); + + it( + 'closes lens on submit when form is invalid with ' + + 'cdkEditControlIgnoreSubmitUnlessValid = false', + fakeAsync(() => { + component.ignoreSubmitUnlessValid = false; + component.openLens(); + fixture.detectChanges(); + + component.getNameInput()!.value = ''; + component.getNameInput()!.dispatchEvent(new Event('input')); + + component.clickSubmitButton(); + + expect(component.lensIsOpen()).toBe(false); + clearLeftoverTimers(); + }), + ); + + it('closes the lens on close', fakeAsync(() => { + component.openLens(); + fixture.detectChanges(); + + component.clickCloseButton(); + + expect(component.lensIsOpen()).toBe(false); + clearLeftoverTimers(); + })); + + it('closes and reopens a lens with modified value persisted', fakeAsync(() => { + component.openLens(); + fixture.detectChanges(); + + component.getNameInput()!.value = 'Hydragon'; + component.getNameInput()!.dispatchEvent(new Event('input')); + + component.clickCloseButton(); + fixture.detectChanges(); + + expect(component.getEditCell().firstChild!.textContent!.trim()).toBe('Hydrogen'); + expect(component.lensIsOpen()).toBe(false); + + component.openLens(); + fixture.detectChanges(); + + expect(component.getNameInput()!.value).toBe('Hydragon'); + clearLeftoverTimers(); + })); + + it('resets the lens to original value', fakeAsync(() => { + component.openLens(); + fixture.detectChanges(); + + component.getNameInput()!.value = 'Hydragon'; + component.getNameInput()!.dispatchEvent(new Event('input')); + + component.clickRevertButton(); + + expect(component.getNameInput()!.value).toBe('Hydrogen'); + clearLeftoverTimers(); + })); + + it('should not reset the values when clicking revert without making changes', fakeAsync(() => { + component.openLens(); + fixture.detectChanges(); + + expect(component.getNameInput()!.value).toBe('Hydrogen'); + expect(component.getWeightInput()!.value).toBe('1.007'); + + component.clickRevertButton(); + + expect(component.getNameInput()!.value).toBe('Hydrogen'); + expect(component.getWeightInput()!.value).toBe('1.007'); + clearLeftoverTimers(); + })); + + it('resets the lens to previously submitted value', fakeAsync(() => { + component.openLens(); + fixture.detectChanges(); + + component.getNameInput()!.value = 'Hydragon'; + component.getNameInput()!.dispatchEvent(new Event('input')); + + component.clickSubmitButton(); + fixture.detectChanges(); + + component.openLens(); + fixture.detectChanges(); + + component.getNameInput()!.value = 'Hydragon X'; + component.getNameInput()!.dispatchEvent(new Event('input')); + + component.clickRevertButton(); + + expect(component.getNameInput()!.value).toBe('Hydragon'); + clearLeftoverTimers(); + })); + + it('closes the lens on escape', fakeAsync(() => { + component.openLens(); + fixture.detectChanges(); + + const event = new KeyboardEvent('keydown', {bubbles: true, key: 'Escape'}); + spyOn(event, 'preventDefault').and.callThrough(); + component.getNameInput()!.dispatchEvent(event); - component.colspan = {after: 1}; - fixture.changeDetectorRef.markForCheck(); + expect(component.lensIsOpen()).toBe(false); + expect(event.preventDefault).toHaveBeenCalled(); + clearLeftoverTimers(); + })); + + it('does not close the lens on escape with a modifier key', fakeAsync(() => { + component.openLens(); fixture.detectChanges(); - paneRect = component.getEditPane()!.getBoundingClientRect(); - expectPixelsToEqual(paneRect.top, cellRects[1].top); - expectPixelsToEqual(paneRect.left, cellRects[1].left); - expectPixelsToEqual(paneRect.right, cellRects[2].right); + const event = new KeyboardEvent('keydown', {bubbles: true, key: 'Escape'}); + Object.defineProperty(event, 'altKey', {get: () => true}); - component.colspan = {before: 1, after: 1}; - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); + spyOn(event, 'preventDefault').and.callThrough(); + component.getNameInput()!.dispatchEvent(event); - paneRect = component.getEditPane()!.getBoundingClientRect(); - expectPixelsToEqual(paneRect.top, cellRects[0].top); - expectPixelsToEqual(paneRect.left, cellRects[0].left); - expectPixelsToEqual(paneRect.right, cellRects[2].right); + expect(component.lensIsOpen()).toBe(true); + expect(event.preventDefault).not.toHaveBeenCalled(); clearLeftoverTimers(); })); - it('updates the form and submits, closing the lens', fakeAsync(() => { + it('does not close the lens on click within lens', fakeAsync(() => { component.openLens(); fixture.detectChanges(); - component.getNameInput()!.value = 'Hydragon'; - component.getNameInput()!.dispatchEvent(new Event('input')); + component.getNameInput()!.dispatchEvent(new Event('click', {bubbles: true})); - component.clickSubmitButton(); - fixture.detectChanges(); - expect(component.getEditCell().firstChild!.textContent!.trim()).toBe('Hydragon'); - expect(component.lensIsOpen()).toBe(false); + expect(component.lensIsOpen()).toBe(true); clearLeftoverTimers(); })); - it('does not close the lens on submit when form is invalid', fakeAsync(() => { + it('closes the lens on outside click', fakeAsync(() => { component.openLens(); fixture.detectChanges(); - component.getNameInput()!.value = ''; + component.getNameInput()!.value = 'Hydragon'; component.getNameInput()!.dispatchEvent(new Event('input')); + document.body.dispatchEvent(new Event('click', {bubbles: true})); + fixture.detectChanges(); - component.clickSubmitButton(); - - expect(component.lensIsOpen()).toBe(true); + expect(component.lensIsOpen()).toBe(false); + expect(component.getEditCell().firstChild!.textContent!.trim()).toBe('Hydrogen'); clearLeftoverTimers(); })); it( - 'closes lens on submit when form is invalid with ' + - 'cdkEditControlIgnoreSubmitUnlessValid = false', + 'submits the lens on outside click with ' + 'cdkEditControlClickOutBehavior = "submit"', fakeAsync(() => { - component.ignoreSubmitUnlessValid = false; + component.clickOutBehavior = 'submit'; component.openLens(); fixture.detectChanges(); - component.getNameInput()!.value = ''; + component.getNameInput()!.value = 'Hydragon'; component.getNameInput()!.dispatchEvent(new Event('input')); - - component.clickSubmitButton(); + document.body.dispatchEvent(new Event('click', {bubbles: true})); + fixture.detectChanges(); expect(component.lensIsOpen()).toBe(false); + expect(component.getEditCell().firstChild!.textContent!.trim()).toBe('Hydragon'); clearLeftoverTimers(); }), ); - it('closes the lens on close', fakeAsync(() => { + it( + 'does nothing on outside click with ' + 'cdkEditControlClickOutBehavior = "noop"', + fakeAsync(() => { + component.clickOutBehavior = 'noop'; + component.openLens(); + fixture.detectChanges(); + + component.getNameInput()!.value = 'Hydragon'; + component.getNameInput()!.dispatchEvent(new Event('input')); + document.body.dispatchEvent(new Event('click', {bubbles: true})); + fixture.detectChanges(); + + expect(component.lensIsOpen()).toBe(true); + expect(component.getEditCell().firstChild!.textContent!.trim()).toBe('Hydrogen'); + clearLeftoverTimers(); + }), + ); + + it('sets focus on the first input in the lens', fakeAsync(() => { component.openLens(); fixture.detectChanges(); - component.clickCloseButton(); - - expect(component.lensIsOpen()).toBe(false); + expect(document.activeElement).toBe(component.getNameInput()); clearLeftoverTimers(); })); - it('closes and reopens a lens with modified value persisted', fakeAsync(() => { + it('returns focus to the edited cell after closing', fakeAsync(() => { component.openLens(); fixture.detectChanges(); - component.getNameInput()!.value = 'Hydragon'; - component.getNameInput()!.dispatchEvent(new Event('input')); - component.clickCloseButton(); - fixture.detectChanges(); - expect(component.getEditCell().firstChild!.textContent!.trim()).toBe('Hydrogen'); - expect(component.lensIsOpen()).toBe(false); + expect(document.activeElement).toBe(component.getEditCell()); + clearLeftoverTimers(); + })); + + it( + 'does not focus to the edited cell after closing if another element ' + + 'outside the lens is already focused', + fakeAsync(() => { + component.openLens(0); + + component.getEditCell(1).focus(); + component.getEditCell(1).dispatchEvent(new Event('click', {bubbles: true})); + + expect(document.activeElement).toBe(component.getEditCell(1)); + clearLeftoverTimers(); + }), + ); + + it('should pass the directionality to the overlay', fakeAsync(() => { + component.direction = 'rtl'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); component.openLens(); fixture.detectChanges(); - expect(component.getNameInput()!.value).toBe('Hydragon'); + expect(component.getEditBoundingBox()!.getAttribute('dir')).toBe('rtl'); clearLeftoverTimers(); })); + }); + }); + } +}); - it('resets the lens to original value', fakeAsync(() => { - component.openLens(); - fixture.detectChanges(); +@Directive() +abstract class BaseTestComponent { + @ViewChild('table') table: ElementRef; - component.getNameInput()!.value = 'Hydragon'; - component.getNameInput()!.dispatchEvent(new Event('input')); + preservedValues = new FormValueContainer(); + nameEditDisabled = false; + nameEditAriaLabel: string | undefined = undefined; + ignoreSubmitUnlessValid = true; + clickOutBehavior: PopoverEditClickOutBehavior = 'close'; + colspan: CdkPopoverEditColspan = {}; + direction: Direction = 'ltr'; + cdr = inject(ChangeDetectorRef); - component.clickRevertButton(); + constructor() { + this.renderData(); + } - expect(component.getNameInput()!.value).toBe('Hydrogen'); - clearLeftoverTimers(); - })); + abstract renderData(): void; - it('should not reset the values when clicking revert without making changes', fakeAsync(() => { - component.openLens(); - fixture.detectChanges(); + onSubmit(element: PeriodicElement, form: NgForm) { + if (!form.valid) { + return; + } - expect(component.getNameInput()!.value).toBe('Hydrogen'); - expect(component.getWeightInput()!.value).toBe('1.007'); + element.name = form.value['name']; + } - component.clickRevertButton(); + triggerHoverState(rowIndex = 0) { + const row = getRows(this.table.nativeElement)[rowIndex]; + row.dispatchEvent(new Event('mouseover', {bubbles: true})); + row.dispatchEvent(new Event('mousemove', {bubbles: true})); - expect(component.getNameInput()!.value).toBe('Hydrogen'); - expect(component.getWeightInput()!.value).toBe('1.007'); - clearLeftoverTimers(); - })); + // Wait for the mouse hover debounce in edit-event-dispatcher. + tick(41); + } - it('resets the lens to previously submitted value', fakeAsync(() => { - component.openLens(); - fixture.detectChanges(); + getRows() { + return getRows(this.table.nativeElement); + } - component.getNameInput()!.value = 'Hydragon'; - component.getNameInput()!.dispatchEvent(new Event('input')); + getEditCell(rowIndex = 0, cellIndex = 1) { + const row = this.getRows()[rowIndex]; + return getCells(row)[cellIndex]; + } - component.clickSubmitButton(); - fixture.detectChanges(); + focusEditCell(rowIndex = 0, cellIndex = 1) { + this.getEditCell(rowIndex, cellIndex).focus(); + } - component.openLens(); - fixture.detectChanges(); + hoverContentStateForRow(rowIndex = 0) { + const openButton = this.getOpenButton(rowIndex); + + if (!openButton) { + return HoverContentState.OFF; + } + return parseInt(getComputedStyle(openButton.parentNode as Element).opacity || '', 10) === 0 + ? HoverContentState.FOCUSABLE + : HoverContentState.ON; + } + + getOpenButton(rowIndex = 0) { + return this.getEditCell(rowIndex).querySelector('.open') as HTMLElement | null; + } + + clickOpenButton(rowIndex = 0) { + this.getOpenButton(rowIndex)!.click(); + } + + openLens(rowIndex = 0, cellIndex = 1) { + this.focusEditCell(rowIndex, cellIndex); + this.getEditCell(rowIndex, cellIndex).dispatchEvent( + new KeyboardEvent('keydown', {bubbles: true, key: 'Enter'}), + ); + flush(); + } + + getEditPane() { + return document.querySelector('.cdk-edit-pane'); + } + + getEditBoundingBox() { + return document.querySelector('.cdk-overlay-connected-position-bounding-box'); + } + + getNameInput() { + return document.querySelector('input[name="name"]') as HTMLInputElement | null; + } + + getWeightInput() { + return document.querySelector('input[name="weight"]') as HTMLInputElement | null; + } + + lensIsOpen() { + return !!this.getNameInput(); + } + + getSubmitButton() { + return document.querySelector('.submit') as HTMLElement | null; + } + + clickSubmitButton() { + this.getSubmitButton()!.click(); + } + + getRevertButton() { + return document.querySelector('.revert') as HTMLElement | null; + } + + clickRevertButton() { + this.getRevertButton()!.click(); + } - component.getNameInput()!.value = 'Hydragon X'; - component.getNameInput()!.dispatchEvent(new Event('input')); + getCloseButton() { + return document.querySelector('.close') as HTMLElement | null; + } - component.clickRevertButton(); + clickCloseButton() { + this.getCloseButton()!.click(); + } +} - expect(component.getNameInput()!.value).toBe('Hydragon'); - clearLeftoverTimers(); - })); +@Component({ + template: ` + + +
+
+ + +
+ + + + +
+
- it('closes the lens on escape', fakeAsync(() => { - component.openLens(); - fixture.detectChanges(); + +
+
+ + +
+
- const event = new KeyboardEvent('keydown', {bubbles: true, key: 'Escape'}); - spyOn(event, 'preventDefault').and.callThrough(); - component.getNameInput()!.dispatchEvent(event); + @for (element of elements; track element) { + + - expect(component.lensIsOpen()).toBe(false); - expect(event.preventDefault).toHaveBeenCalled(); - clearLeftoverTimers(); - })); + - it('does not close the lens on escape with a modifier key', fakeAsync(() => { - component.openLens(); - fixture.detectChanges(); + + + } +
just a cell + {{element.name}} + + + + + {{element.weight}} +
+ `, + imports: [CdkPopoverEditModule, FormsModule, CdkTableModule, BidiModule], +}) +class VanillaTableOutOfCell extends BaseTestComponent { + elements: ChemicalElement[]; - const event = new KeyboardEvent('keydown', {bubbles: true, key: 'Escape'}); - Object.defineProperty(event, 'altKey', {get: () => true}); + renderData() { + this.elements = createElementData(); + this.cdr.markForCheck(); + } +} - spyOn(event, 'preventDefault').and.callThrough(); - component.getNameInput()!.dispatchEvent(event); +@Component({ + template: ` + + @for (element of elements; track element) { + + - expect(component.lensIsOpen()).toBe(true); - expect(event.preventDefault).not.toHaveBeenCalled(); - clearLeftoverTimers(); - })); + - component.getNameInput()!.dispatchEvent(new Event('click', {bubbles: true})); + + + } +
just a cell + {{element.name}} + + + - it('does not close the lens on click within lens', fakeAsync(() => { - component.openLens(); - fixture.detectChanges(); + +
+
+ + +
+ + + +
+
+
+
+ {{element.weight}} - expect(component.lensIsOpen()).toBe(true); - clearLeftoverTimers(); - })); + +
+
+ +
+
+
+
+ `, + imports: [CdkPopoverEditModule, FormsModule, CdkTableModule, BidiModule], +}) +class VanillaTableInCell extends BaseTestComponent { + elements: ChemicalElement[]; - it('closes the lens on outside click', fakeAsync(() => { - component.openLens(); - fixture.detectChanges(); + renderData() { + this.elements = createElementData(); + this.cdr.markForCheck(); + } +} - component.getNameInput()!.value = 'Hydragon'; - component.getNameInput()!.dispatchEvent(new Event('input')); - document.body.dispatchEvent(new Event('click', {bubbles: true})); - fixture.detectChanges(); +class ElementDataSource extends DataSource { + /** Stream of data that is provided to the table. */ + data = new BehaviorSubject(createElementData()); - expect(component.lensIsOpen()).toBe(false); - expect(component.getEditCell().firstChild!.textContent!.trim()).toBe('Hydrogen'); - clearLeftoverTimers(); - })); + /** Connect function called by the table to retrieve one stream containing the data to render. */ + connect() { + return this.data; + } - it( - 'submits the lens on outside click with ' + 'cdkEditControlClickOutBehavior = "submit"', - fakeAsync(() => { - component.clickOutBehavior = 'submit'; - component.openLens(); - fixture.detectChanges(); + disconnect() {} +} - component.getNameInput()!.value = 'Hydragon'; - component.getNameInput()!.dispatchEvent(new Event('input')); - document.body.dispatchEvent(new Event('click', {bubbles: true})); - fixture.detectChanges(); +@Component({ + template: ` +
+ + + + just a cell + + - expect(component.lensIsOpen()).toBe(false); - expect(component.getEditCell().firstChild!.textContent!.trim()).toBe('Hydragon'); - clearLeftoverTimers(); - }), - ); + + + {{element.name}} + + + - it( - 'does nothing on outside click with ' + 'cdkEditControlClickOutBehavior = "noop"', - fakeAsync(() => { - component.clickOutBehavior = 'noop'; - component.openLens(); - fixture.detectChanges(); + +
+
+ + +
+ + + +
+
+
- component.getNameInput()!.value = 'Hydragon'; - component.getNameInput()!.dispatchEvent(new Event('input')); - document.body.dispatchEvent(new Event('click', {bubbles: true})); - fixture.detectChanges(); + + + +
+
- expect(component.lensIsOpen()).toBe(true); - expect(component.getEditCell().firstChild!.textContent!.trim()).toBe('Hydrogen'); - clearLeftoverTimers(); - }), - ); + + + {{element.weight}} - it('sets focus on the first input in the lens', fakeAsync(() => { - component.openLens(); - fixture.detectChanges(); + +
+
+ +
+
+
+
+
- expect(document.activeElement).toBe(component.getNameInput()); - clearLeftoverTimers(); - })); + +
+
+ `, + imports: [CdkPopoverEditModule, FormsModule, CdkTableModule, BidiModule], +}) +class CdkFlexTableInCell extends BaseTestComponent { + displayedColumns = ['before', 'name', 'weight']; + dataSource: ElementDataSource; - it('returns focus to the edited cell after closing', fakeAsync(() => { - component.openLens(); - fixture.detectChanges(); + renderData() { + this.dataSource = new ElementDataSource(); + this.cdr.markForCheck(); + } +} - component.clickCloseButton(); +@Component({ + template: ` +
+ + + + - expect(document.activeElement).toBe(component.getEditCell()); - clearLeftoverTimers(); - })); + + + - expect(document.activeElement).toBe(component.getEditCell(1)); - clearLeftoverTimers(); - }), - ); + + + - component.openLens(); - fixture.detectChanges(); + +
+ just a cell + + {{element.name}} + + + - it( - 'does not focus to the edited cell after closing if another element ' + - 'outside the lens is already focused', - fakeAsync(() => { - component.openLens(0); + +
+
+ + +
+ + + +
+
+
- component.getEditCell(1).focus(); - component.getEditCell(1).dispatchEvent(new Event('click', {bubbles: true})); + + + +
+ {{element.weight}} - it('should pass the directionality to the overlay', fakeAsync(() => { - component.direction = 'rtl'; - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); + +
+
+ +
+
+
+
+
+ `, + imports: [CdkPopoverEditModule, FormsModule, CdkTableModule, BidiModule], +}) +class CdkTableInCell extends BaseTestComponent { + displayedColumns = ['before', 'name', 'weight']; + dataSource: ElementDataSource; - expect(component.getEditBoundingBox()!.getAttribute('dir')).toBe('rtl'); - clearLeftoverTimers(); - })); - }); - }); + renderData() { + this.dataSource = new ElementDataSource(); + this.cdr.markForCheck(); } -}); +} @Component({ template: ` @@ -1056,11 +1101,31 @@ cdkPopoverEditTabOut`, fakeAsync(() => { - ${CELL_TEMPLATE} + [cdkPopoverEdit]="nameEdit" + [cdkPopoverEditColspan]="colspan" + [cdkPopoverEditDisabled]="nameEditDisabled" + [cdkPopoverEditAriaLabel]="nameEditAriaLabel"> + {{element.name}} + + + - ${NAME_EDIT_TEMPLATE} +
+
+ + +
+ + + +
+
@@ -1070,12 +1135,15 @@ cdkPopoverEditTabOut`, fakeAsync(() => {
- + {{element.weight}} - ${WEIGHT_EDIT_TEMPLATE} +
+
+ +
+
@@ -1090,7 +1158,7 @@ cdkPopoverEditTabOut`, fakeAsync(() => {
`, - standalone: false, + imports: [CdkPopoverEditModule, FormsModule, CdkTableModule, BidiModule], }) class CdkTableWithSkipRows extends BaseTestComponent { displayedColumns = ['before', 'name', 'weight']; @@ -1110,10 +1178,6 @@ describe('CDK Popover Edit - with focus ignore rows', () => { dispatchKeyboardEvent(cell, 'keydown', keyCode); beforeEach(fakeAsync(() => { - TestBed.configureTestingModule({ - imports: [CdkTableModule, CdkPopoverEditModule, FormsModule, BidiModule], - declarations: [CdkTableWithSkipRows], - }); fixture = TestBed.createComponent(CdkTableWithSkipRows); component = fixture.componentInstance; component.renderData(); diff --git a/src/cdk-experimental/radio-group/BUILD.bazel b/src/cdk-experimental/radio-group/BUILD.bazel new file mode 100644 index 000000000000..cd303316332f --- /dev/null +++ b/src/cdk-experimental/radio-group/BUILD.bazel @@ -0,0 +1,37 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "radio-group", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns", + "//src/cdk/a11y", + "//src/cdk/bidi", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":radio-group", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/radio-group/index.ts b/src/cdk-experimental/radio-group/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/cdk-experimental/radio-group/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './public-api'; diff --git a/src/cdk-experimental/radio-group/public-api.ts b/src/cdk-experimental/radio-group/public-api.ts new file mode 100644 index 000000000000..56207c601c38 --- /dev/null +++ b/src/cdk-experimental/radio-group/public-api.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export {CdkRadioGroup, CdkRadioButton} from './radio-group'; diff --git a/src/cdk-experimental/radio-group/radio-group.spec.ts b/src/cdk-experimental/radio-group/radio-group.spec.ts new file mode 100644 index 000000000000..d4bfed343f3f --- /dev/null +++ b/src/cdk-experimental/radio-group/radio-group.spec.ts @@ -0,0 +1,572 @@ +import {Component, DebugElement, signal} from '@angular/core'; +import {CdkRadioButton, CdkRadioGroup} from './radio-group'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {BidiModule, Direction} from '@angular/cdk/bidi'; +import {provideFakeDirectionality, runAccessibilityChecks} from '@angular/cdk/testing/private'; + +describe('CdkRadioGroup', () => { + let fixture: ComponentFixture; + let radioGroup: DebugElement; + let radioButtons: DebugElement[]; + let radioGroupInstance: CdkRadioGroup; + let radioGroupElement: HTMLElement; + let radioButtonElements: HTMLElement[]; + + const keydown = (key: string) => { + radioGroupElement.dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, key})); + fixture.detectChanges(); + }; + + const click = (index: number) => { + radioButtonElements[index].dispatchEvent(new PointerEvent('pointerdown', {bubbles: true})); + fixture.detectChanges(); + }; + + const space = () => keydown(' '); + const enter = () => keydown('Enter'); + const up = () => keydown('ArrowUp'); + const down = () => keydown('ArrowDown'); + const left = () => keydown('ArrowLeft'); + const right = () => keydown('ArrowRight'); + const home = () => keydown('Home'); + const end = () => keydown('End'); + + function setupRadioGroup(opts?: { + orientation?: 'horizontal' | 'vertical'; + disabled?: boolean; + readonly?: boolean; + value?: number | null; + skipDisabled?: boolean; + focusMode?: 'roving' | 'activedescendant'; + disabledOptions?: number[]; + options?: TestOption[]; + textDirection?: Direction; + }) { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality(opts?.textDirection ?? 'ltr')], + imports: [BidiModule, RadioGroupExample], + }); + + fixture = TestBed.createComponent(RadioGroupExample); + const testComponent = fixture.componentInstance; + + if (opts?.orientation !== undefined) { + testComponent.orientation = opts.orientation; + } + if (opts?.disabled !== undefined) { + testComponent.disabled = opts.disabled; + } + if (opts?.readonly !== undefined) { + testComponent.readonly = opts.readonly; + } + if (opts?.value !== undefined) { + testComponent.value = opts.value; + } + if (opts?.skipDisabled !== undefined) { + testComponent.skipDisabled = opts.skipDisabled; + } + if (opts?.focusMode !== undefined) { + testComponent.focusMode = opts.focusMode; + } + if (opts?.options !== undefined) { + testComponent.options.set(opts.options); + } + if (opts?.disabledOptions !== undefined) { + opts.disabledOptions.forEach(index => { + testComponent.options()[index].disabled = true; + }); + } + + fixture.detectChanges(); + defineTestVariables(fixture); + } + + function setupDefaultRadioGroup() { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality('ltr')], + imports: [BidiModule, DefaultRadioGroupExample], + }); + + const fixture = TestBed.createComponent(DefaultRadioGroupExample); + fixture.detectChanges(); + defineTestVariables(fixture); + } + + function defineTestVariables(fixture: ComponentFixture) { + radioGroup = fixture.debugElement.query(By.directive(CdkRadioGroup)); + radioButtons = fixture.debugElement.queryAll(By.directive(CdkRadioButton)); + radioGroupInstance = radioGroup.injector.get>(CdkRadioGroup); + radioGroupElement = radioGroup.nativeElement; + radioButtonElements = radioButtons.map(radioButton => radioButton.nativeElement); + } + + afterEach(async () => { + await runAccessibilityChecks(radioGroupElement); + }); + + describe('ARIA attributes and roles', () => { + describe('default configuration', () => { + it('should correctly set the role attribute to "radiogroup"', () => { + setupDefaultRadioGroup(); + expect(radioGroupElement.getAttribute('role')).toBe('radiogroup'); + }); + + it('should correctly set the role attribute to "radio" for the radio buttons', () => { + setupDefaultRadioGroup(); + radioButtonElements.forEach(radioButtonElement => { + expect(radioButtonElement.getAttribute('role')).toBe('radio'); + }); + }); + + it('should set aria-orientation to "vertical"', () => { + setupDefaultRadioGroup(); + expect(radioGroupElement.getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('should set aria-disabled to false', () => { + setupDefaultRadioGroup(); + expect(radioGroupElement.getAttribute('aria-disabled')).toBe('false'); + }); + + it('should set aria-readonly to false', () => { + setupDefaultRadioGroup(); + expect(radioGroupElement.getAttribute('aria-readonly')).toBe('false'); + }); + }); + + describe('custom configuration', () => { + it('should be able to set aria-orientation to "vertical"', () => { + setupRadioGroup({orientation: 'vertical'}); + expect(radioGroupElement.getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('should be able to set aria-disabled to true', () => { + setupRadioGroup({disabled: true}); + expect(radioGroupElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should be able to set aria-readonly to true', () => { + setupRadioGroup({readonly: true}); + expect(radioGroupElement.getAttribute('aria-readonly')).toBe('true'); + }); + }); + + describe('roving focus mode', () => { + it('should have tabindex="-1" when focusMode is "roving"', () => { + setupRadioGroup({focusMode: 'roving'}); + expect(radioGroupElement.getAttribute('tabindex')).toBe('-1'); + }); + + it('should set tabindex="0" when disabled', () => { + setupRadioGroup({disabled: true, focusMode: 'roving'}); + expect(radioGroupElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should set initial focus on the selected option', () => { + setupRadioGroup({focusMode: 'roving', value: 3}); + expect(radioButtonElements[3].getAttribute('tabindex')).toBe('0'); + }); + + it('should set initial focus on the first option if none are selected', () => { + setupRadioGroup({focusMode: 'roving'}); + expect(radioButtonElements[0].getAttribute('tabindex')).toBe('0'); + }); + + it('should not have aria-activedescendant when focusMode is "roving"', () => { + setupRadioGroup({focusMode: 'roving'}); + expect(radioGroupElement.getAttribute('aria-activedescendant')).toBeNull(); + }); + }); + + describe('activedescendant focus mode', () => { + it('should have tabindex="0"', () => { + setupRadioGroup({focusMode: 'activedescendant'}); + expect(radioGroupElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should set initial focus on the selected option', () => { + setupRadioGroup({focusMode: 'activedescendant', value: 3}); + expect(radioGroupElement.getAttribute('aria-activedescendant')).toBe( + radioButtonElements[3].id, + ); + }); + + it('should set initial focus on the first option if none are selected', () => { + setupRadioGroup({focusMode: 'activedescendant'}); + expect(radioGroupElement.getAttribute('aria-activedescendant')).toBe( + radioButtonElements[0].id, + ); + }); + }); + }); + + describe('value and selection', () => { + it('should select the radio button corresponding to the value input', () => { + setupRadioGroup(); + radioGroupInstance.value.set(1); + fixture.detectChanges(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the value model when the value of a radio group is changed through the ui', () => { + setupRadioGroup(); + click(1); + expect(radioGroupInstance.value()).toBe(1); + }); + + describe('pointer interaction', () => { + it('should update the group value when a radio button is selected via pointer click', () => { + setupRadioGroup(); + click(1); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should only allow one radio button to be selected at a time', () => { + setupRadioGroup(); + click(1); + click(2); + expect(radioButtonElements[0].getAttribute('aria-checked')).toBe('false'); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('false'); + expect(radioButtonElements[2].getAttribute('aria-checked')).toBe('true'); + expect(radioButtonElements[3].getAttribute('aria-checked')).toBe('false'); + expect(radioButtonElements[4].getAttribute('aria-checked')).toBe('false'); + }); + + it('should not change the value if the radio group is readonly', () => { + setupRadioGroup({readonly: true}); + click(3); + expect(radioButtonElements[3].getAttribute('aria-checked')).toBe('false'); + }); + + it('should not change the value if the radio group is disabled', () => { + setupRadioGroup({disabled: true}); + click(3); + expect(radioButtonElements[3].getAttribute('aria-checked')).toBe('false'); + }); + + it('should not change the value if a disabled radio button is clicked', () => { + setupRadioGroup({disabledOptions: [2]}); + click(2); + expect(radioButtonElements[2].getAttribute('aria-checked')).toBe('false'); + }); + + it('should not change the value if a radio button is clicked in a readonly group', () => { + setupRadioGroup({readonly: true}); + click(1); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('false'); + }); + }); + + describe('keyboard interaction', () => { + it('should update the group value on Space', () => { + setupRadioGroup(); + space(); + expect(radioButtonElements[0].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the group value on Enter', () => { + setupRadioGroup(); + enter(); + expect(radioButtonElements[0].getAttribute('aria-checked')).toBe('true'); + }); + + it('should not change the value if the radio group is readonly', () => { + setupRadioGroup({orientation: 'horizontal', readonly: true}); + right(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('false'); + }); + + it('should not change the value if the radio group is disabled', () => { + setupRadioGroup({orientation: 'horizontal', disabled: true}); + right(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('false'); + }); + + describe('horizontal orientation', () => { + it('should update the group value on ArrowRight', () => { + setupRadioGroup({orientation: 'horizontal'}); + right(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the group value on ArrowLeft', () => { + setupRadioGroup({orientation: 'horizontal'}); + right(); + right(); + left(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + describe('text direction rtl', () => { + it('should update the group value on ArrowLeft', () => { + setupRadioGroup({orientation: 'horizontal', textDirection: 'rtl'}); + left(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the group value on ArrowRight', () => { + setupRadioGroup({orientation: 'horizontal', textDirection: 'rtl'}); + left(); + left(); + right(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + }); + }); + + describe('vertical orientation', () => { + it('should update the group value on ArrowDown', () => { + setupRadioGroup({orientation: 'vertical'}); + down(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + + it('should update the group value on ArrowUp', () => { + setupRadioGroup({orientation: 'vertical'}); + down(); + down(); + up(); + expect(radioButtonElements[1].getAttribute('aria-checked')).toBe('true'); + }); + }); + }); + }); + + function runNavigationTests( + focusMode: 'activedescendant' | 'roving', + isFocused: (index: number) => boolean, + ) { + describe(`keyboard navigation (focusMode="${focusMode}")`, () => { + it('should move focus to and select the last enabled radio button on End', () => { + setupRadioGroup({focusMode}); + end(); + expect(isFocused(4)).toBe(true); + }); + + it('should move focus to and select the first enabled radio button on Home', () => { + setupRadioGroup({focusMode}); + end(); + home(); + expect(isFocused(0)).toBe(true); + }); + + it('should not allow keyboard navigation or selection if the group is disabled', () => { + setupRadioGroup({focusMode, orientation: 'horizontal', disabled: true}); + right(); + expect(isFocused(0)).toBe(false); + }); + + it('should allow keyboard navigation if the group is readonly', () => { + setupRadioGroup({focusMode, orientation: 'horizontal', readonly: true}); + right(); + expect(isFocused(1)).toBe(true); + }); + + describe('vertical orientation', () => { + it('should move focus to the next radio button on ArrowDown', () => { + setupRadioGroup({focusMode, orientation: 'vertical'}); + down(); + expect(isFocused(1)).toBe(true); + }); + + it('should move focus to the previous radio button on ArrowUp', () => { + setupRadioGroup({focusMode, orientation: 'vertical'}); + down(); + down(); + up(); + expect(isFocused(1)).toBe(true); + }); + + it('should skip disabled radio buttons (skipDisabled="true")', () => { + setupRadioGroup({ + focusMode, + orientation: 'vertical', + skipDisabled: true, + disabledOptions: [1, 2], + }); + down(); + expect(isFocused(3)).toBe(true); + }); + + it('should not skip disabled radio buttons (skipDisabled="false")', () => { + setupRadioGroup({ + focusMode, + orientation: 'vertical', + skipDisabled: false, + disabledOptions: [1, 2], + }); + down(); + expect(isFocused(1)).toBe(true); + }); + }); + + describe('horizontal orientation', () => { + it('should move focus to the next radio button on ArrowRight', () => { + setupRadioGroup({focusMode, orientation: 'horizontal'}); + right(); + expect(isFocused(1)).toBe(true); + }); + + it('should move focus to the previous radio button on ArrowLeft', () => { + setupRadioGroup({focusMode, orientation: 'horizontal'}); + right(); + right(); + left(); + expect(isFocused(1)).toBe(true); + }); + + it('should skip disabled radio buttons (skipDisabled="true")', () => { + setupRadioGroup({ + focusMode, + orientation: 'horizontal', + skipDisabled: true, + disabledOptions: [1, 2], + }); + right(); + expect(isFocused(3)).toBe(true); + }); + + it('should not skip disabled radio buttons (skipDisabled="false")', () => { + setupRadioGroup({ + focusMode, + orientation: 'horizontal', + skipDisabled: false, + disabledOptions: [1, 2], + }); + right(); + expect(isFocused(1)).toBe(true); + }); + + describe('text direction rtl', () => { + it('should move focus to the next radio button on ArrowLeft', () => { + setupRadioGroup({focusMode, textDirection: 'rtl', orientation: 'horizontal'}); + left(); + expect(isFocused(1)).toBe(true); + }); + + it('should move focus to the previous radio button on ArrowRight', () => { + setupRadioGroup({focusMode, textDirection: 'rtl', orientation: 'horizontal'}); + left(); + left(); + right(); + expect(isFocused(1)).toBe(true); + }); + + it('should skip disabled radio buttons when navigating', () => { + setupRadioGroup({ + focusMode, + skipDisabled: true, + textDirection: 'rtl', + disabledOptions: [1, 2], + orientation: 'horizontal', + }); + left(); + expect(isFocused(3)).toBe(true); + }); + }); + }); + }); + + describe(`pointer navigation (focusMode="${focusMode}")`, () => { + it('should move focus to the clicked radio button', () => { + setupRadioGroup({focusMode}); + click(3); + expect(isFocused(3)).toBe(true); + }); + + it('should move focus to the clicked radio button if the group is disabled (skipDisabled="true")', () => { + setupRadioGroup({focusMode, skipDisabled: true, disabled: true}); + click(3); + expect(isFocused(3)).toBe(false); + }); + + it('should not move focus to the clicked radio button if the group is disabled (skipDisabled="false")', () => { + setupRadioGroup({focusMode, skipDisabled: true, disabled: true}); + click(3); + expect(isFocused(0)).toBe(false); + }); + + it('should move focus to the clicked radio button if the group is readonly', () => { + setupRadioGroup({focusMode, readonly: true}); + click(3); + expect(isFocused(3)).toBe(true); + }); + }); + } + + runNavigationTests('roving', i => { + return radioButtonElements[i].getAttribute('tabindex') === '0'; + }); + + runNavigationTests('activedescendant', i => { + return radioGroupElement.getAttribute('aria-activedescendant') === radioButtonElements[i].id; + }); + + describe('failure cases', () => { + it('should handle an empty set of radio buttons gracefully', () => { + setupRadioGroup({options: []}); + expect(radioButtons.length).toBe(0); + }); + + describe('bad accessibility violations', () => { + it('should report when the selected radio button is disabled and skipDisabled is true', () => { + spyOn(console, 'error'); + setupRadioGroup({value: 1, skipDisabled: true, disabledOptions: [1]}); + expect(console.error).toHaveBeenCalled(); + }); + }); + }); +}); + +interface TestOption { + value: number; + label: string; + disabled: boolean; +} + +@Component({ + template: ` +
+ @for (option of options(); track option.value) { +
{{ option.label }}
+ } +
+ `, + imports: [CdkRadioGroup, CdkRadioButton], +}) +class RadioGroupExample { + options = signal([ + {value: 0, label: '0', disabled: false}, + {value: 1, label: '1', disabled: false}, + {value: 2, label: '2', disabled: false}, + {value: 3, label: '3', disabled: false}, + {value: 4, label: '4', disabled: false}, + ]); + + value: number | null = null; + disabled = false; + readonly = false; + skipDisabled = true; + focusMode: 'roving' | 'activedescendant' = 'roving'; + orientation: 'horizontal' | 'vertical' = 'horizontal'; +} + +@Component({ + template: ` +
+
0
+
1
+
2
+
+ `, + imports: [CdkRadioGroup, CdkRadioButton], +}) +class DefaultRadioGroupExample {} diff --git a/src/cdk-experimental/radio-group/radio-group.ts b/src/cdk-experimental/radio-group/radio-group.ts new file mode 100644 index 000000000000..40553d753616 --- /dev/null +++ b/src/cdk-experimental/radio-group/radio-group.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + afterRenderEffect, + booleanAttribute, + computed, + contentChildren, + Directive, + ElementRef, + inject, + input, + linkedSignal, + model, + signal, + WritableSignal, +} from '@angular/core'; +import {RadioButtonPattern, RadioGroupPattern} from '../ui-patterns'; +import {Directionality} from '@angular/cdk/bidi'; +import {_IdGenerator} from '@angular/cdk/a11y'; + +// TODO: Move mapSignal to it's own file so it can be reused across components. + +/** + * Creates a new writable signal (signal V) whose value is connected to the given original + * writable signal (signal T) such that updating signal V updates signal T and vice-versa. + * + * This function establishes a two-way synchronization between the source signal and the new mapped + * signal. When the source signal changes, the mapped signal updates by applying the `transform` + * function. When the mapped signal is explicitly set or updated, the change is propagated back to + * the source signal by applying the `reverse` function. + */ +export function mapSignal( + originalSignal: WritableSignal, + operations: { + transform: (value: T) => V; + reverse: (value: V) => T; + }, +) { + const mappedSignal = linkedSignal(() => operations.transform(originalSignal())); + const updateMappedSignal = mappedSignal.update; + const setMappedSignal = mappedSignal.set; + + mappedSignal.set = (newValue: V) => { + setMappedSignal(newValue); + originalSignal.set(operations.reverse(newValue)); + }; + + mappedSignal.update = (updateFn: (value: V) => V) => { + updateMappedSignal(oldValue => updateFn(oldValue)); + originalSignal.update(oldValue => operations.reverse(updateFn(operations.transform(oldValue)))); + }; + + return mappedSignal; +} + +/** + * A radio button group container. + * + * Radio groups are used to group multiple radio buttons or radio group labels so they function as + * a single form control. The CdkRadioGroup is meant to be used in conjunction with CdkRadioButton + * as follows: + * + * ```html + *
+ * + * + * + *
+ * ``` + */ +@Directive({ + selector: '[cdkRadioGroup]', + exportAs: 'cdkRadioGroup', + host: { + 'role': 'radiogroup', + 'class': 'cdk-radio-group', + '[attr.tabindex]': 'pattern.tabindex()', + '[attr.aria-readonly]': 'pattern.readonly()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.aria-orientation]': 'pattern.orientation()', + '[attr.aria-activedescendant]': 'pattern.activedescendant()', + '(keydown)': 'pattern.onKeydown($event)', + '(pointerdown)': 'pattern.onPointerdown($event)', + '(focusin)': 'onFocus()', + }, +}) +export class CdkRadioGroup { + /** The CdkRadioButtons nested inside of the CdkRadioGroup. */ + private readonly _cdkRadioButtons = contentChildren(CdkRadioButton, {descendants: true}); + + /** A signal wrapper for directionality. */ + protected textDirection = inject(Directionality).valueSignal; + + /** The RadioButton UIPatterns of the child CdkRadioButtons. */ + protected items = computed(() => this._cdkRadioButtons().map(radio => radio.pattern)); + + /** Whether the radio group is vertically or horizontally oriented. */ + orientation = input<'vertical' | 'horizontal'>('vertical'); + + /** Whether disabled items in the group should be skipped when navigating. */ + skipDisabled = input(true, {transform: booleanAttribute}); + + /** The focus strategy used by the radio group. */ + focusMode = input<'roving' | 'activedescendant'>('roving'); + + /** Whether the radio group is disabled. */ + disabled = input(false, {transform: booleanAttribute}); + + /** Whether the radio group is readonly. */ + readonly = input(false, {transform: booleanAttribute}); + + /** The value of the currently selected radio button. */ + value = model(null); + + /** The internal selection state for the radio group. */ + private readonly _value = mapSignal(this.value, { + transform: value => (value !== null ? [value] : []), + reverse: values => (values.length === 0 ? null : values[0]), + }); + + /** The RadioGroup UIPattern. */ + pattern: RadioGroupPattern = new RadioGroupPattern({ + ...this, + items: this.items, + value: this._value, + activeIndex: signal(0), + textDirection: this.textDirection, + }); + + /** Whether the radio group has received focus yet. */ + private _hasFocused = signal(false); + + constructor() { + afterRenderEffect(() => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + const violations = this.pattern.validate(); + for (const violation of violations) { + console.error(violation); + } + } + }); + + afterRenderEffect(() => { + if (!this._hasFocused()) { + this.pattern.setDefaultState(); + } + }); + } + + onFocus() { + this._hasFocused.set(true); + } +} + +/** A selectable radio button in a CdkRadioGroup. */ +@Directive({ + selector: '[cdkRadioButton]', + exportAs: 'cdkRadioButton', + host: { + 'role': 'radio', + 'class': 'cdk-radio-button', + '[class.cdk-active]': 'pattern.active()', + '[attr.tabindex]': 'pattern.tabindex()', + '[attr.aria-checked]': 'pattern.selected()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[id]': 'pattern.id()', + }, +}) +export class CdkRadioButton { + /** A reference to the radio button element. */ + private readonly _elementRef = inject(ElementRef); + + /** The parent CdkRadioGroup. */ + private readonly _cdkRadioGroup = inject(CdkRadioGroup); + + /** A unique identifier for the radio button. */ + private readonly _generatedId = inject(_IdGenerator).getId('cdk-radio-button-'); + + /** A unique identifier for the radio button. */ + protected id = computed(() => this._generatedId); + + /** The value associated with the radio button. */ + protected value = input.required(); + + /** The parent RadioGroup UIPattern. */ + protected group = computed(() => this._cdkRadioGroup.pattern); + + /** A reference to the radio button element to be focused on navigation. */ + protected element = computed(() => this._elementRef.nativeElement); + + /** Whether the radio button is disabled. */ + disabled = input(false, {transform: booleanAttribute}); + + /** The RadioButton UIPattern. */ + pattern = new RadioButtonPattern({ + ...this, + id: this.id, + value: this.value, + group: this.group, + element: this.element, + }); +} diff --git a/src/cdk-experimental/tabs/BUILD.bazel b/src/cdk-experimental/tabs/BUILD.bazel index d9993da6e049..96828e6d98be 100644 --- a/src/cdk-experimental/tabs/BUILD.bazel +++ b/src/cdk-experimental/tabs/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ng_project") +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -16,3 +16,22 @@ ng_project( "//src/cdk/bidi", ], ) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "tabs.spec.ts", + ], + deps = [ + ":tabs", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/tabs/tabs.spec.ts b/src/cdk-experimental/tabs/tabs.spec.ts new file mode 100644 index 000000000000..260d9da3f314 --- /dev/null +++ b/src/cdk-experimental/tabs/tabs.spec.ts @@ -0,0 +1,724 @@ +import {Component, DebugElement, signal} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {BidiModule, Direction} from '@angular/cdk/bidi'; +import {provideFakeDirectionality, runAccessibilityChecks} from '@angular/cdk/testing/private'; +import {CdkTabs, CdkTabList, CdkTab, CdkTabPanel, CdkTabContent} from './tabs'; + +interface ModifierKeys { + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} + +interface TestTabDefinition { + value: string; + label: string; + content: string; + disabled?: boolean; +} + +describe('CdkTabs', () => { + let fixture: ComponentFixture; + let testComponent: TestTabsComponent; + + let tabsDebugElement: DebugElement; + let tabListDebugElement: DebugElement; + let tabDebugElements: DebugElement[]; + let tabPanelDebugElements: DebugElement[]; + + let tabsElement: HTMLElement; + let tabListElement: HTMLElement; + let tabElements: HTMLElement[]; + let tabPanelElements: HTMLElement[]; + + const keydown = (key: string, modifierKeys: ModifierKeys = {}) => { + tabListElement.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); + fixture.detectChanges(); + defineTestVariables(); + }; + + const pointerDown = (target: HTMLElement, eventInit?: PointerEventInit) => { + target.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, ...eventInit})); + fixture.detectChanges(); + defineTestVariables(); + }; + + const space = (modifierKeys?: ModifierKeys) => keydown(' ', modifierKeys); + const enter = (modifierKeys?: ModifierKeys) => keydown('Enter', modifierKeys); + const up = (modifierKeys?: ModifierKeys) => keydown('ArrowUp', modifierKeys); + const down = (modifierKeys?: ModifierKeys) => keydown('ArrowDown', modifierKeys); + const left = (modifierKeys?: ModifierKeys) => keydown('ArrowLeft', modifierKeys); + const right = (modifierKeys?: ModifierKeys) => keydown('ArrowRight', modifierKeys); + const home = (modifierKeys?: ModifierKeys) => keydown('Home', modifierKeys); + const end = (modifierKeys?: ModifierKeys) => keydown('End', modifierKeys); + + function setupTestTabs(options: {textDirection?: Direction} = {}) { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality(options.textDirection ?? 'ltr')], + imports: [BidiModule, TestTabsComponent], + }); + + fixture = TestBed.createComponent(TestTabsComponent); + testComponent = fixture.componentInstance; + + fixture.detectChanges(); + defineTestVariables(); + } + + function updateTabs( + options: { + initialTabs?: TestTabDefinition[]; + selectedTab?: string | undefined; + orientation?: 'horizontal' | 'vertical'; + disabled?: boolean; + wrap?: boolean; + skipDisabled?: boolean; + focusMode?: 'roving' | 'activedescendant'; + selectionMode?: 'follow' | 'explicit'; + } = {}, + ) { + if (options.initialTabs !== undefined) testComponent.tabsData.set(options.initialTabs); + if (options.selectedTab !== undefined) testComponent.selectedTab.set(options.selectedTab); + if (options.orientation !== undefined) testComponent.orientation.set(options.orientation); + if (options.disabled !== undefined) testComponent.disabled.set(options.disabled); + if (options.wrap !== undefined) testComponent.wrap.set(options.wrap); + if (options.skipDisabled !== undefined) testComponent.skipDisabled.set(options.skipDisabled); + if (options.focusMode !== undefined) testComponent.focusMode.set(options.focusMode); + if (options.selectionMode !== undefined) testComponent.selectionMode.set(options.selectionMode); + + fixture.detectChanges(); + defineTestVariables(); + } + + function defineTestVariables() { + tabsDebugElement = fixture.debugElement.query(By.directive(CdkTabs)); + tabListDebugElement = fixture.debugElement.query(By.directive(CdkTabList)); + tabDebugElements = fixture.debugElement.queryAll(By.directive(CdkTab)); + tabPanelDebugElements = fixture.debugElement.queryAll(By.directive(CdkTabPanel)); + + tabsElement = tabsDebugElement.nativeElement; + tabListElement = tabListDebugElement.nativeElement; + tabElements = tabDebugElements.map(debugEl => debugEl.nativeElement); + tabPanelElements = tabPanelDebugElements.map(debugEl => debugEl.nativeElement); + } + + function isTabFocused(index: number): boolean { + if (testComponent.focusMode() === 'roving') { + return tabElements[index]?.getAttribute('tabindex') === '0'; + } else { + return tabListElement?.getAttribute('aria-activedescendant') === tabElements[index]?.id; + } + } + + afterEach(async () => { + if (tabsElement) { + await runAccessibilityChecks(tabsElement); + } + }); + + describe('ARIA attributes and roles', () => { + beforeEach(() => { + setupTestTabs(); + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + }); + }); + + describe('CdkTabList', () => { + it('should have role="tablist"', () => { + expect(tabListElement.getAttribute('role')).toBe('tablist'); + }); + + it('should set aria-orientation based on input', () => { + expect(tabListElement.getAttribute('aria-orientation')).toBe('horizontal'); + updateTabs({orientation: 'vertical'}); + expect(tabListElement.getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('should set aria-disabled based on input', () => { + expect(tabListElement.getAttribute('aria-disabled')).toBe('false'); + updateTabs({disabled: true}); + expect(tabListElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should have tabindex set by focusMode', () => { + updateTabs({focusMode: 'roving'}); + expect(tabListElement.getAttribute('tabindex')).toBe('-1'); + + updateTabs({focusMode: 'activedescendant'}); + expect(tabListElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should set aria-activedescendant in activedescendant mode', () => { + updateTabs({focusMode: 'activedescendant', selectedTab: 'tab1'}); + expect(tabListElement.getAttribute('aria-activedescendant')).toBe(tabElements[0].id); + }); + + it('should not set aria-activedescendant in roving mode', () => { + updateTabs({selectedTab: 'tab1'}); + expect(tabListElement.hasAttribute('aria-activedescendant')).toBe(false); + }); + }); + + describe('CdkTab', () => { + it('should have role="tab"', () => { + tabElements.forEach(tabElement => { + expect(tabElement.getAttribute('role')).toBe('tab'); + }); + }); + + it('should have aria-selected based on selection state', () => { + updateTabs({selectedTab: 'tab1'}); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + expect(tabElements[2].getAttribute('aria-selected')).toBe('false'); + + updateTabs({selectedTab: 'tab3'}); + expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + expect(tabElements[2].getAttribute('aria-selected')).toBe('true'); + }); + + it('should have aria-controls pointing to its panel id', () => { + tabElements.forEach((tabElement, index) => { + expect(tabElement.getAttribute('aria-controls')).toBe(tabPanelElements[index].id); + }); + }); + + it('should have aria-disabled based on input', () => { + expect(tabElements[0].getAttribute('aria-disabled')).toBe('false'); + expect(tabElements[1].getAttribute('aria-disabled')).toBe('true'); + expect(tabElements[2].getAttribute('aria-disabled')).toBe('false'); + }); + + it('should have tabindex set by focusMode and active state', () => { + updateTabs({focusMode: 'roving', selectedTab: 'tab1'}); + expect(tabElements[0].getAttribute('tabindex')).toBe('0'); + expect(tabElements[1].getAttribute('tabindex')).toBe('-1'); + expect(tabElements[2].getAttribute('tabindex')).toBe('-1'); + + updateTabs({focusMode: 'activedescendant'}); + tabElements.forEach(tabElement => { + expect(tabElement.getAttribute('tabindex')).toBe('-1'); + }); + }); + }); + + describe('CdkTabPanel', () => { + it('should have role="tabpanel"', () => { + tabPanelElements.forEach(panelElement => { + expect(panelElement.getAttribute('role')).toBe('tabpanel'); + }); + }); + + it('should have tabindex="0" when visible.', () => { + updateTabs({selectedTab: 'tab1'}); + expect(tabPanelElements[0].getAttribute('tabindex')).toBe('0'); + }); + + it('should have tabindex="-1" when hidden.', () => { + updateTabs({selectedTab: 'tab1'}); + expect(tabPanelElements[1].getAttribute('tabindex')).toBe('-1'); + expect(tabPanelElements[2].getAttribute('tabindex')).toBe('-1'); + }); + + it('should have inert attribute when hidden and not when visible', () => { + updateTabs({selectedTab: 'tab1'}); + expect(tabPanelElements[0].hasAttribute('inert')).toBe(false); + expect(tabPanelElements[1].hasAttribute('inert')).toBe(true); + expect(tabPanelElements[2].hasAttribute('inert')).toBe(true); + + updateTabs({selectedTab: 'tab3'}); + expect(tabPanelElements[0].hasAttribute('inert')).toBe(true); + expect(tabPanelElements[1].hasAttribute('inert')).toBe(true); + expect(tabPanelElements[2].hasAttribute('inert')).toBe(false); + }); + }); + }); + + describe('Keyboard navigation', () => { + for (const focusMode of ['roving', 'activedescendant'] as const) { + describe(`focusMode="${focusMode}"`, () => { + describe('LTR', () => { + beforeEach(() => { + setupTestTabs({textDirection: 'ltr'}); + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + focusMode, + selectedTab: 'tab1', + }); + }); + + it('should move focus with ArrowRight', () => { + expect(isTabFocused(0)).toBe(true); + right(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should move focus with ArrowLeft', () => { + right(); + expect(isTabFocused(2)).toBe(true); + left(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should wrap focus with ArrowRight if wrap is true', () => { + updateTabs({wrap: true}); + right(); + expect(isTabFocused(2)).toBe(true); + right(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should not wrap focus with ArrowRight if wrap is false', () => { + updateTabs({wrap: false}); + right(); + expect(isTabFocused(2)).toBe(true); + right(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should wrap focus with ArrowLeft if wrap is true', () => { + updateTabs({wrap: true}); + expect(isTabFocused(0)).toBe(true); + left(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should not wrap focus with ArrowLeft if wrap is false', () => { + updateTabs({wrap: false}); + expect(isTabFocused(0)).toBe(true); + left(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should move focus to first tab with Home', () => { + left(); + expect(isTabFocused(2)).toBe(true); + home(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should move focus to last tab with End', () => { + expect(isTabFocused(0)).toBe(true); + end(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should skip disabled tabs if skipDisabled is true', () => { + updateTabs({skipDisabled: true}); + expect(isTabFocused(0)).toBe(true); + right(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should not skip disabled tabs if skipDisabled is false', () => { + updateTabs({skipDisabled: false}); + tabListElement.focus(); + fixture.detectChanges(); + expect(isTabFocused(0)).toBe(true); + right(); + expect(isTabFocused(1)).toBe(true); + }); + }); + + describe('RTL', () => { + beforeEach(() => { + setupTestTabs({textDirection: 'rtl'}); + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + focusMode, + selectedTab: 'tab1', + }); + }); + it('should move focus with ArrowLeft (effectively next)', () => { + expect(isTabFocused(0)).toBe(true); + left(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should move focus with ArrowRight (effectively previous)', () => { + left(); + expect(isTabFocused(2)).toBe(true); + right(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should wrap focus with ArrowLeft if wrap is true', () => { + updateTabs({wrap: true}); + left(); + left(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should not wrap focus with ArrowLeft if wrap is false', () => { + updateTabs({wrap: false}); + left(); + left(); + expect(isTabFocused(2)).toBe(true); + }); + }); + + describe('orientation="vertical"', () => { + beforeEach(() => { + setupTestTabs({textDirection: 'ltr'}); + updateTabs({ + orientation: 'vertical', + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + focusMode, + selectedTab: 'tab1', + }); + }); + + it('should move focus with ArrowDown', () => { + expect(isTabFocused(0)).toBe(true); + down(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should move focus with ArrowUp', () => { + down(); + expect(isTabFocused(2)).toBe(true); + up(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should wrap focus with ArrowDown if wrap is true', () => { + updateTabs({wrap: true}); + down(); + expect(isTabFocused(2)).toBe(true); + down(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should not wrap focus with ArrowDown if wrap is false', () => { + updateTabs({wrap: false}); + down(); + expect(isTabFocused(2)).toBe(true); + down(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should wrap focus with ArrowUp if wrap is true', () => { + updateTabs({wrap: true}); + expect(isTabFocused(0)).toBe(true); + up(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should not wrap focus with ArrowUp if wrap is false', () => { + updateTabs({wrap: false}); + expect(isTabFocused(0)).toBe(true); + up(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should move focus to first tab with Home', () => { + down(); + expect(isTabFocused(2)).toBe(true); + home(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should move focus to last tab with End', () => { + expect(isTabFocused(0)).toBe(true); + end(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should not move focus with ArrowLeft/ArrowRight', () => { + expect(isTabFocused(0)).toBe(true); + left(); + expect(isTabFocused(0)).toBe(true); + right(); + expect(isTabFocused(0)).toBe(true); + }); + }); + }); + } + }); + + describe('Tab selection', () => { + beforeEach(() => { + setupTestTabs(); + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2'}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + selectedTab: 'tab1', + }); + }); + + describe('selectionMode="follow"', () => { + beforeEach(() => { + updateTabs({selectionMode: 'follow'}); + }); + + it('should select tab on focus via ArrowKeys', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + + right(); + expect(testComponent.selectedTab()).toBe('tab2'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); + + left(); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + }); + + it('should select tab on focus via Home/End', () => { + updateTabs({selectedTab: 'tab2'}); + expect(testComponent.selectedTab()).toBe('tab2'); + + home(); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + + end(); + expect(testComponent.selectedTab()).toBe('tab3'); + expect(tabElements[2].getAttribute('aria-selected')).toBe('true'); + }); + + it('should select tab on click', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + pointerDown(tabElements[1]); + expect(testComponent.selectedTab()).toBe('tab2'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + }); + + it('should not change selection with Space/Enter on already selected tab', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + space(); + expect(testComponent.selectedTab()).toBe('tab1'); + enter(); + expect(testComponent.selectedTab()).toBe('tab1'); + }); + }); + + describe('selectionMode="explicit"', () => { + beforeEach(() => { + updateTabs({selectionMode: 'explicit'}); + }); + + it('should not select tab on focus via ArrowKeys', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + + right(); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + expect(isTabFocused(1)).toBe(true); + + left(); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + expect(isTabFocused(0)).toBe(true); + }); + + it('should select focused tab on Space', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + + right(); + expect(isTabFocused(1)).toBe(true); + expect(testComponent.selectedTab()).toBe('tab1'); + + space(); + expect(testComponent.selectedTab()).toBe('tab2'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + }); + + it('should select focused tab on Enter', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + + right(); + expect(isTabFocused(1)).toBe(true); + expect(testComponent.selectedTab()).toBe('tab1'); + + enter(); + expect(testComponent.selectedTab()).toBe('tab2'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + }); + + it('should select tab on click', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + pointerDown(tabElements[1]); + expect(testComponent.selectedTab()).toBe('tab2'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + }); + }); + + it('should update selectedTab model on selection change', () => { + updateTabs({selectedTab: 'tab1', selectionMode: 'follow'}); + expect(testComponent.selectedTab()).toBe('tab1'); + + right(); + expect(testComponent.selectedTab()).toBe('tab2'); + + updateTabs({selectionMode: 'explicit'}); + right(); + expect(testComponent.selectedTab()).toBe('tab2'); + enter(); + expect(testComponent.selectedTab()).toBe('tab3'); + + pointerDown(tabElements[0]); + expect(testComponent.selectedTab()).toBe('tab1'); + }); + + it('should update selection when selectedTab model changes', () => { + updateTabs({selectedTab: 'tab1'}); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + + updateTabs({selectedTab: 'tab2'}); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); + + updateTabs({selectedTab: 'tab3'}); + expect(tabElements[2].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + }); + + it('should not select a disabled tab via click', () => { + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + selectedTab: 'tab1', + }); + expect(testComponent.selectedTab()).toBe('tab1'); + pointerDown(tabElements[1]); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + }); + + it('should not select a disabled tab via keyboard', () => { + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + selectedTab: 'tab1', + selectionMode: 'explicit', + skipDisabled: false, + }); + expect(testComponent.selectedTab()).toBe('tab1'); + right(); + expect(isTabFocused(1)).toBe(true); + enter(); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + }); + + it('should not change selection if tablist is disabled', () => { + updateTabs({selectedTab: 'tab1', disabled: true}); + expect(testComponent.selectedTab()).toBe('tab1'); + pointerDown(tabElements[1]); + expect(testComponent.selectedTab()).toBe('tab1'); + right(); + expect(testComponent.selectedTab()).toBe('tab1'); + }); + + it('should handle initial selection via input', () => { + updateTabs({selectedTab: 'tab2'}); + expect(testComponent.selectedTab()).toBe('tab2'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); + }); + }); +}); + +@Component({ + template: ` +
+
    + @for (tabDef of tabsData(); track tabDef.value) { +
  • {{ tabDef.label }}
  • + } +
+ + @for (tabDef of tabsData(); track tabDef.value) { +
+ {{ tabDef.content }} +
+ } +
+ `, + imports: [CdkTabs, CdkTabList, CdkTab, CdkTabPanel, CdkTabContent], +}) +class TestTabsComponent { + tabsData = signal([ + { + value: 'tab1', + label: 'Tab 1', + content: 'Content 1', + disabled: false, + }, + { + value: 'tab2', + label: 'Tab 2', + content: 'Content 2', + disabled: false, + }, + { + value: 'tab3', + label: 'Tab 3', + content: 'Content 3', + disabled: true, + }, + ]); + + selectedTab = signal(undefined); + orientation = signal<'horizontal' | 'vertical'>('horizontal'); + disabled = signal(false); + wrap = signal(true); + skipDisabled = signal(true); + focusMode = signal<'roving' | 'activedescendant'>('roving'); + selectionMode = signal<'follow' | 'explicit'>('follow'); +} diff --git a/src/cdk-experimental/tabs/tabs.ts b/src/cdk-experimental/tabs/tabs.ts index bf17a9882732..4f311f63a643 100644 --- a/src/cdk-experimental/tabs/tabs.ts +++ b/src/cdk-experimental/tabs/tabs.ts @@ -12,19 +12,33 @@ import {Directionality} from '@angular/cdk/bidi'; import { booleanAttribute, computed, - contentChild, - contentChildren, Directive, - effect, ElementRef, inject, input, model, + linkedSignal, signal, + Signal, + afterRenderEffect, + OnInit, + OnDestroy, } from '@angular/core'; -import {toSignal} from '@angular/core/rxjs-interop'; import {TabListPattern, TabPanelPattern, TabPattern} from '../ui-patterns'; +interface HasElement { + element: Signal; +} + +/** + * Sort directives by their document order. + */ +function sortDirectives(a: HasElement, b: HasElement) { + return (a.element().compareDocumentPosition(b.element()) & Node.DOCUMENT_POSITION_PRECEDING) > 0 + ? 1 + : -1; +} + /** * A Tabs container. * @@ -34,15 +48,20 @@ import {TabListPattern, TabPanelPattern, TabPattern} from '../ui-patterns'; * ```html *
*
    - *
  • Tab 1
  • - *
  • Tab 2
  • - *
  • Tab 3
  • + *
  • Tab 1
  • + *
  • Tab 2
  • + *
  • Tab 3
  • *
* - *
Tab content 1
- *
Tab content 2
- *
Tab content 3
- *
+ *
+ * Tab content 1 + *
+ *
+ * Tab content 2 + *
+ *
+ * Tab content 3 + *
* ``` */ @Directive({ @@ -54,16 +73,40 @@ import {TabListPattern, TabPanelPattern, TabPattern} from '../ui-patterns'; }) export class CdkTabs { /** The CdkTabList nested inside of the container. */ - private readonly _cdkTabList = contentChild(CdkTabList); + private readonly _tablist = signal(undefined); /** The CdkTabPanels nested inside of the container. */ - private readonly _cdkTabPanels = contentChildren(CdkTabPanel); + private readonly _unorderedPanels = signal(new Set()); /** The Tab UIPattern of the child Tabs. */ - tabs = computed(() => this._cdkTabList()?.tabs()); + tabs = computed(() => this._tablist()?.tabs()); /** The TabPanel UIPattern of the child TabPanels. */ - tabpanels = computed(() => this._cdkTabPanels().map(tabpanel => tabpanel.pattern)); + unorderedTabpanels = computed(() => + [...this._unorderedPanels()].map(tabpanel => tabpanel.pattern), + ); + + register(child: CdkTabList | CdkTabPanel) { + if (child instanceof CdkTabList) { + this._tablist.set(child); + } + + if (child instanceof CdkTabPanel) { + this._unorderedPanels().add(child); + this._unorderedPanels.set(new Set(this._unorderedPanels())); + } + } + + deregister(child: CdkTabList | CdkTabPanel) { + if (child instanceof CdkTabList) { + this._tablist.set(undefined); + } + + if (child instanceof CdkTabPanel) { + this._unorderedPanels().delete(child); + this._unorderedPanels.set(new Set(this._unorderedPanels())); + } + } } /** @@ -83,51 +126,90 @@ export class CdkTabs { '[attr.aria-activedescendant]': 'pattern.activedescendant()', '(keydown)': 'pattern.onKeydown($event)', '(pointerdown)': 'pattern.onPointerdown($event)', + '(focusin)': 'onFocus()', }, }) -export class CdkTabList { - /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ - private readonly _directionality = inject(Directionality); +export class CdkTabList implements OnInit, OnDestroy { + /** The parent CdkTabs. */ + private readonly _cdkTabs = inject(CdkTabs); /** The CdkTabs nested inside of the CdkTabList. */ - private readonly _cdkTabs = contentChildren(CdkTab); + private readonly _unorderedTabs = signal(new Set()); - /** A signal wrapper for directionality. */ - protected textDirection = toSignal(this._directionality.change, { - initialValue: this._directionality.value, - }); + /** The internal tab selection state. */ + private readonly _selection = linkedSignal(() => (this.tab() ? [this.tab()!] : [])); + + /** Text direction. */ + readonly textDirection = inject(Directionality).valueSignal; /** The Tab UIPatterns of the child Tabs. */ - tabs = computed(() => this._cdkTabs().map(tab => tab.pattern)); + readonly tabs = computed(() => + [...this._unorderedTabs()].sort(sortDirectives).map(tab => tab.pattern), + ); /** Whether the tablist is vertically or horizontally oriented. */ - orientation = input<'vertical' | 'horizontal'>('horizontal'); + readonly orientation = input<'vertical' | 'horizontal'>('horizontal'); /** Whether focus should wrap when navigating. */ - wrap = input(true, {transform: booleanAttribute}); + readonly wrap = input(true, {transform: booleanAttribute}); /** Whether disabled items in the list should be skipped when navigating. */ - skipDisabled = input(true, {transform: booleanAttribute}); + readonly skipDisabled = input(true, {transform: booleanAttribute}); /** The focus strategy used by the tablist. */ - focusMode = input<'roving' | 'activedescendant'>('roving'); + readonly focusMode = input<'roving' | 'activedescendant'>('roving'); /** The selection strategy used by the tablist. */ - selectionMode = input<'follow' | 'explicit'>('follow'); + readonly selectionMode = input<'follow' | 'explicit'>('follow'); /** Whether the tablist is disabled. */ - disabled = input(false, {transform: booleanAttribute}); + readonly disabled = input(false, {transform: booleanAttribute}); - /** The current index that has been navigated to. */ - activeIndex = model(0); + /** The current selected tab. */ + readonly tab = model(); /** The TabList UIPattern. */ - pattern: TabListPattern = new TabListPattern({ + readonly pattern: TabListPattern = new TabListPattern({ ...this, items: this.tabs, - textDirection: this.textDirection, - value: signal([]), + value: this._selection, + activeIndex: signal(0), }); + + /** Whether the tree has received focus yet. */ + private _hasFocused = signal(false); + + constructor() { + afterRenderEffect(() => this.tab.set(this._selection()[0])); + + afterRenderEffect(() => { + if (!this._hasFocused()) { + this.pattern.setDefaultState(); + } + }); + } + + onFocus() { + this._hasFocused.set(true); + } + + ngOnInit() { + this._cdkTabs.register(this); + } + + ngOnDestroy() { + this._cdkTabs.deregister(this); + } + + register(child: CdkTab) { + this._unorderedTabs().add(child); + this._unorderedTabs.set(new Set(this._unorderedTabs())); + } + + deregister(child: CdkTab) { + this._unorderedTabs().delete(child); + this._unorderedTabs.set(new Set(this._unorderedTabs())); + } } /** A selectable tab in a TabList. */ @@ -145,7 +227,7 @@ export class CdkTabList { '[attr.aria-controls]': 'pattern.controls()', }, }) -export class CdkTab { +export class CdkTab implements HasElement, OnInit, OnDestroy { /** A reference to the tab element. */ private readonly _elementRef = inject(ElementRef); @@ -158,29 +240,39 @@ export class CdkTab { /** A global unique identifier for the tab. */ private readonly _id = inject(_IdGenerator).getId('cdk-tab-'); + /** The host native element. */ + readonly element = computed(() => this._elementRef.nativeElement); + /** The parent TabList UIPattern. */ - protected tablist = computed(() => this._cdkTabList.pattern); + readonly tablist = computed(() => this._cdkTabList.pattern); /** The TabPanel UIPattern associated with the tab */ - protected tabpanel = computed(() => - this._cdkTabs.tabpanels().find(tabpanel => tabpanel.value() === this.value()), + readonly tabpanel = computed(() => + this._cdkTabs.unorderedTabpanels().find(tabpanel => tabpanel.value() === this.value()), ); /** Whether a tab is disabled. */ - disabled = input(false, {transform: booleanAttribute}); + readonly disabled = input(false, {transform: booleanAttribute}); /** A local unique identifier for the tab. */ - value = input.required(); + readonly value = input.required(); /** The Tab UIPattern. */ - pattern: TabPattern = new TabPattern({ + readonly pattern: TabPattern = new TabPattern({ ...this, id: () => this._id, - element: () => this._elementRef.nativeElement, tablist: this.tablist, tabpanel: this.tabpanel, value: this.value, }); + + ngOnInit() { + this._cdkTabList.register(this); + } + + ngOnDestroy() { + this._cdkTabList.deregister(this); + } } /** @@ -196,9 +288,9 @@ export class CdkTab { exportAs: 'cdkTabPanel', host: { 'role': 'tabpanel', - 'tabindex': '0', 'class': 'cdk-tabpanel', '[attr.id]': 'pattern.id()', + '[attr.tabindex]': 'pattern.tabindex()', '[attr.inert]': 'pattern.hidden() ? true : null', }, hostDirectives: [ @@ -208,7 +300,7 @@ export class CdkTab { }, ], }) -export class CdkTabPanel { +export class CdkTabPanel implements OnInit, OnDestroy { /** The DeferredContentAware host directive. */ private readonly _deferredContentAware = inject(DeferredContentAware); @@ -219,20 +311,28 @@ export class CdkTabPanel { private readonly _id = inject(_IdGenerator).getId('cdk-tabpanel-'); /** The Tab UIPattern associated with the tabpanel */ - protected tab = computed(() => this._cdkTabs.tabs()?.find(tab => tab.value() === this.value())); + readonly tab = computed(() => this._cdkTabs.tabs()?.find(tab => tab.value() === this.value())); /** A local unique identifier for the tabpanel. */ - value = input.required(); + readonly value = input.required(); /** The TabPanel UIPattern. */ - pattern: TabPanelPattern = new TabPanelPattern({ + readonly pattern: TabPanelPattern = new TabPanelPattern({ ...this, id: () => this._id, tab: this.tab, }); constructor() { - effect(() => this._deferredContentAware.contentVisible.set(!this.pattern.hidden())); + afterRenderEffect(() => this._deferredContentAware.contentVisible.set(!this.pattern.hidden())); + } + + ngOnInit() { + this._cdkTabs.register(this); + } + + ngOnDestroy() { + this._cdkTabs.deregister(this); } } diff --git a/src/cdk-experimental/tree/BUILD.bazel b/src/cdk-experimental/tree/BUILD.bazel new file mode 100644 index 000000000000..67822af2acd2 --- /dev/null +++ b/src/cdk-experimental/tree/BUILD.bazel @@ -0,0 +1,37 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "tree", + srcs = [ + "index.ts", + "public-api.ts", + "tree.ts", + ], + deps = [ + "//src/cdk-experimental/deferred-content", + "//src/cdk-experimental/ui-patterns", + "//src/cdk/a11y", + "//src/cdk/bidi", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "tree.spec.ts", + ], + deps = [ + ":tree", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/tree/index.ts b/src/cdk-experimental/tree/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/cdk-experimental/tree/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './public-api'; diff --git a/src/cdk-experimental/tree/public-api.ts b/src/cdk-experimental/tree/public-api.ts new file mode 100644 index 000000000000..e61d3b72d41d --- /dev/null +++ b/src/cdk-experimental/tree/public-api.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export {CdkTreeItemGroup, CdkTreeItemGroupContent, CdkTree, CdkTreeItem} from './tree'; diff --git a/src/cdk-experimental/tree/tree.spec.ts b/src/cdk-experimental/tree/tree.spec.ts new file mode 100644 index 000000000000..22d33e0ede77 --- /dev/null +++ b/src/cdk-experimental/tree/tree.spec.ts @@ -0,0 +1,1408 @@ +import {Component, signal} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {BidiModule, Direction} from '@angular/cdk/bidi'; +import {provideFakeDirectionality, runAccessibilityChecks} from '@angular/cdk/testing/private'; +import {CdkTree, CdkTreeItem, CdkTreeItemGroup, CdkTreeItemGroupContent} from './tree'; + +interface ModifierKeys { + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} + +describe('CdkTree', () => { + let fixture: ComponentFixture; + let testComponent: TestTreeComponent; + let treeElement: HTMLElement; + let treeInstance: CdkTree; + let treeItemElements: HTMLElement[]; + let treeItemGroupElements: HTMLElement[]; + + const keydown = (key: string, modifierKeys: ModifierKeys = {}) => { + const event = new KeyboardEvent('keydown', {key, bubbles: true, ...modifierKeys}); + treeElement.dispatchEvent(event); + fixture.detectChanges(); + defineTestVariables(); + }; + + const pointerDown = (target: HTMLElement, eventInit: PointerEventInit = {}) => { + target.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, ...eventInit})); + fixture.detectChanges(); + defineTestVariables(); + }; + + const up = (modifierKeys?: ModifierKeys) => keydown('ArrowUp', modifierKeys); + const down = (modifierKeys?: ModifierKeys) => keydown('ArrowDown', modifierKeys); + const left = (modifierKeys?: ModifierKeys) => keydown('ArrowLeft', modifierKeys); + const right = (modifierKeys?: ModifierKeys) => keydown('ArrowRight', modifierKeys); + const home = (modifierKeys?: ModifierKeys) => keydown('Home', modifierKeys); + const end = (modifierKeys?: ModifierKeys) => keydown('End', modifierKeys); + const enter = (modifierKeys?: ModifierKeys) => keydown('Enter', modifierKeys); + const space = (modifierKeys?: ModifierKeys) => keydown(' ', modifierKeys); + const shift = () => keydown('Shift'); + const type = (chars: string) => { + for (const char of chars) { + keydown(char); + } + }; + const click = (target: HTMLElement) => pointerDown(target); + const shiftClick = (target: HTMLElement) => pointerDown(target, {shiftKey: true}); + const ctrlClick = (target: HTMLElement) => pointerDown(target, {ctrlKey: true}); + + function setupTestTree(textDirection: Direction = 'ltr') { + TestBed.configureTestingModule({ + imports: [TestTreeComponent, BidiModule], + providers: [provideFakeDirectionality(textDirection)], + }); + + fixture = TestBed.createComponent(TestTreeComponent); + testComponent = fixture.componentInstance; + + fixture.detectChanges(); + defineTestVariables(); + } + + function defineTestVariables() { + const treeDebugElement = fixture.debugElement.query(By.directive(CdkTree)); + const treeItemDebugElements = fixture.debugElement.queryAll(By.directive(CdkTreeItem)); + const treeItemGroupDebugElements = fixture.debugElement.queryAll( + By.directive(CdkTreeItemGroup), + ); + + treeElement = treeDebugElement.nativeElement as HTMLElement; + treeInstance = treeDebugElement.componentInstance as CdkTree; + treeItemElements = treeItemDebugElements.map(debugEl => debugEl.nativeElement); + treeItemGroupElements = treeItemGroupDebugElements.map(debugEl => debugEl.nativeElement); + } + + function updateTree( + config: { + nodes?: TestTreeNode[]; + value?: string[]; + disabled?: boolean; + orientation?: 'horizontal' | 'vertical'; + multi?: boolean; + wrap?: boolean; + skipDisabled?: boolean; + focusMode?: 'roving' | 'activedescendant'; + selectionMode?: 'follow' | 'explicit'; + } = {}, + ) { + if (config.nodes !== undefined) testComponent.nodes.set(config.nodes); + if (config.value !== undefined) treeInstance.value.set(config.value); + if (config.disabled !== undefined) testComponent.disabled.set(config.disabled); + if (config.orientation !== undefined) testComponent.orientation.set(config.orientation); + if (config.multi !== undefined) testComponent.multi.set(config.multi); + if (config.wrap !== undefined) testComponent.wrap.set(config.wrap); + if (config.skipDisabled !== undefined) testComponent.skipDisabled.set(config.skipDisabled); + if (config.focusMode !== undefined) testComponent.focusMode.set(config.focusMode); + if (config.selectionMode !== undefined) testComponent.selectionMode.set(config.selectionMode); + + fixture.detectChanges(); + defineTestVariables(); + } + + function updateTreeItemByValue(value: string, config: Partial>) { + const newNodes = JSON.parse(JSON.stringify(testComponent.nodes())); + const childrenList = [newNodes]; + while (childrenList.length > 0) { + const list = childrenList.shift()!; + for (const node of list) { + if (node.value === value) { + if (config.value !== undefined) node.value = config.value; + if (config.label !== undefined) node.label = config.label; + if (config.children !== undefined) node.children = config.children; + if (config.disabled !== undefined) node.disabled = config.disabled; + if (config.preserveContent !== undefined) node.preserveContent = config.preserveContent; + updateTree({nodes: newNodes}); + return; + } + if (node.children) { + childrenList.push(node.children); + } + } + } + } + + function getTreeItemElementByValue(value: string): HTMLElement | undefined { + return treeItemElements.find(el => el.getAttribute('data-value') === String(value)); + } + + function getTreeItemGroupElementByValue(value: string): HTMLElement | undefined { + return treeItemGroupElements.find(el => el.getAttribute('data-group-for') === String(value)); + } + + function getFocusedTreeItemValue(): string | undefined { + let item: HTMLElement | undefined; + if (testComponent.focusMode() === 'roving') { + item = treeItemElements.find(el => el.getAttribute('tabindex') === '0'); + } else { + const itemId = treeElement.getAttribute('aria-activedescendant'); + if (itemId) { + item = treeItemElements.find(el => el.id === itemId); + } + } + return item?.getAttribute('data-value') ?? undefined; + } + + afterEach(async () => { + fixture.detectChanges(); + await runAccessibilityChecks(fixture.nativeElement); + }); + + describe('ARIA attributes and roles', () => { + describe('default configuration', () => { + beforeEach(() => { + setupTestTree(); + // Preserve collapsed children nodes for checking attributes. + updateTreeItemByValue('fruits', {preserveContent: true}); + updateTreeItemByValue('berries', {preserveContent: true}); + updateTreeItemByValue('vegetables', {preserveContent: true}); + }); + + it('should correctly set the role attribute to "tree" for CdkTree', () => { + expect(treeElement.getAttribute('role')).toBe('tree'); + }); + + it('should correctly set the role attribute to "treeitem" for CdkTreeItem', () => { + expect(getTreeItemElementByValue('fruits')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('vegetables')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('grains')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('dairy')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('apple')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('banana')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('berries')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('strawberry')!.getAttribute('role')).toBe('treeitem'); + expect(getTreeItemElementByValue('blueberry')!.getAttribute('role')).toBe('treeitem'); + }); + + it('should correctly set the role attribute to "group" for CdkTreeItemGroup', () => { + expect(getTreeItemGroupElementByValue('fruits')!.getAttribute('role')).toBe('group'); + expect(getTreeItemGroupElementByValue('vegetables')!.getAttribute('role')).toBe('group'); + expect(getTreeItemGroupElementByValue('berries')!.getAttribute('role')).toBe('group'); + }); + + it('should set aria-orientation to "vertical" by default', () => { + expect(treeElement.getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('should set aria-multiselectable to "false" by default', () => { + expect(treeElement.getAttribute('aria-multiselectable')).toBe('false'); + }); + + it('should set aria-disabled to "false" by default for the tree', () => { + expect(treeElement.getAttribute('aria-disabled')).toBe('false'); + }); + + it('should set aria-disabled to "false" by default for items', () => { + expect(treeItemElements[0].getAttribute('aria-disabled')).toBe('false'); + }); + + it('should not have aria-expanded for items without children', () => { + const grainsItem = getTreeItemElementByValue('grains')!; + expect(grainsItem.hasAttribute('aria-expanded')).toBe(false); + }); + + it('should set aria-expanded to "false" by default for items with children', () => { + const fruitsItem = getTreeItemElementByValue('fruits')!; + expect(fruitsItem.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should set aria-level, aria-setsize, and aria-posinset correctly', () => { + const fruits = getTreeItemElementByValue('fruits')!; + expect(fruits.getAttribute('aria-level')).toBe('1'); + expect(fruits.getAttribute('aria-setsize')).toBe('4'); + expect(fruits.getAttribute('aria-posinset')).toBe('1'); + expect(fruits.getAttribute('aria-expanded')).toBe('false'); + + const vegetables = getTreeItemElementByValue('vegetables')!; + expect(vegetables.getAttribute('aria-level')).toBe('1'); + expect(vegetables.getAttribute('aria-setsize')).toBe('4'); + expect(vegetables.getAttribute('aria-posinset')).toBe('2'); + expect(vegetables.getAttribute('aria-expanded')).toBe('false'); + + const grains = getTreeItemElementByValue('grains')!; + expect(grains.getAttribute('aria-level')).toBe('1'); + expect(grains.getAttribute('aria-setsize')).toBe('4'); + expect(grains.getAttribute('aria-posinset')).toBe('3'); + + const dairy = getTreeItemElementByValue('dairy')!; + expect(dairy.getAttribute('aria-level')).toBe('1'); + expect(dairy.getAttribute('aria-setsize')).toBe('4'); + expect(dairy.getAttribute('aria-posinset')).toBe('4'); + + const apple = getTreeItemElementByValue('apple')!; + expect(apple.getAttribute('aria-level')).toBe('2'); + expect(apple.getAttribute('aria-setsize')).toBe('3'); + expect(apple.getAttribute('aria-posinset')).toBe('1'); + + const berries = getTreeItemElementByValue('berries')!; + expect(berries.getAttribute('aria-level')).toBe('2'); + expect(berries.getAttribute('aria-setsize')).toBe('3'); + expect(berries.getAttribute('aria-posinset')).toBe('3'); + expect(berries.getAttribute('aria-expanded')).toBe('false'); + + const strawberry = getTreeItemElementByValue('strawberry')!; + expect(strawberry.getAttribute('aria-level')).toBe('3'); + expect(strawberry.getAttribute('aria-setsize')).toBe('2'); + expect(strawberry.getAttribute('aria-posinset')).toBe('1'); + }); + + it('should set aria-owns on expandable items pointing to their group id', () => { + const fruitsItem = getTreeItemElementByValue('fruits')!; + const group = getTreeItemGroupElementByValue('fruits')!; + expect(fruitsItem.getAttribute('aria-owns')).toBe(group!.id); + }); + }); + + describe('custom configuration', () => { + beforeEach(() => { + setupTestTree(); + // Preserve collapsed children nodes for checking attributes. + updateTreeItemByValue('fruits', {preserveContent: true}); + updateTreeItemByValue('berries', {preserveContent: true}); + updateTreeItemByValue('vegetables', {preserveContent: true}); + }); + + it('should set aria-orientation to "horizontal"', () => { + updateTree({orientation: 'horizontal'}); + + expect(treeElement.getAttribute('aria-orientation')).toBe('horizontal'); + }); + + it('should set aria-multiselectable to "true"', () => { + updateTree({multi: true}); + + expect(treeElement.getAttribute('aria-multiselectable')).toBe('true'); + }); + + it('should set aria-disabled to "true" for the tree', () => { + updateTree({disabled: true}); + + expect(treeElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should set aria-disabled to "true" for disabled items', () => { + updateTreeItemByValue('fruits', {disabled: true}); + + const fruitsItem = getTreeItemElementByValue('fruits')!; + expect(fruitsItem.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should set aria-selected to "true" for selected items', () => { + updateTree({value: ['apple']}); + + const appleItem = getTreeItemElementByValue('apple')!; + expect(appleItem.getAttribute('aria-selected')).toBe('true'); + const fruitsItem = getTreeItemElementByValue('fruits')!; + expect(fruitsItem.getAttribute('aria-selected')).toBe('false'); + }); + + it('should set aria-expanded to "true" for expanded items', () => { + right(); + const fruitsItem = getTreeItemElementByValue('fruits')!; + expect(fruitsItem.getAttribute('aria-expanded')).toBe('true'); + }); + }); + + describe('roving focus mode (focusMode="roving")', () => { + beforeEach(() => { + setupTestTree(); + updateTree({focusMode: 'roving'}); + }); + + it('should set tabindex="-1" for the tree', () => { + expect(treeElement.getAttribute('tabindex')).toBe('-1'); + }); + + it('should set tabindex="0" for the tree when disabled', () => { + updateTree({disabled: true, focusMode: 'roving'}); + + expect(treeElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should set initial focus (tabindex="0") on the first non-disabled item if no value is set', () => { + const fruitsItem = getTreeItemElementByValue('fruits')!; + const vegetablesItem = getTreeItemElementByValue('vegetables')!; + const grainsItem = getTreeItemElementByValue('grains')!; + const dairyItem = getTreeItemElementByValue('dairy')!; + + expect(fruitsItem.getAttribute('tabindex')).toBe('0'); + expect(vegetablesItem.getAttribute('tabindex')).toBe('-1'); + expect(grainsItem.getAttribute('tabindex')).toBe('-1'); + expect(dairyItem.getAttribute('tabindex')).toBe('-1'); + }); + + it('should set initial focus (tabindex="0") on the first selected item', () => { + updateTree({value: ['vegetables', 'dairy'], focusMode: 'roving'}); + + const fruitsItem = getTreeItemElementByValue('fruits')!; + const vegetablesItem = getTreeItemElementByValue('vegetables')!; + const grainsItem = getTreeItemElementByValue('grains')!; + const dairyItem = getTreeItemElementByValue('dairy')!; + + expect(fruitsItem.getAttribute('tabindex')).toBe('-1'); + expect(vegetablesItem.getAttribute('tabindex')).toBe('0'); + expect(grainsItem.getAttribute('tabindex')).toBe('-1'); + expect(dairyItem.getAttribute('tabindex')).toBe('-1'); + }); + + it('should not have aria-activedescendant', () => { + expect(treeElement.hasAttribute('aria-activedescendant')).toBe(false); + }); + }); + + describe('activedescendant focus mode (focusMode="activedescendant")', () => { + beforeEach(() => { + setupTestTree(); + updateTree({focusMode: 'activedescendant'}); + }); + + it('should set tabindex="0" for the tree', () => { + expect(treeElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should set aria-activedescendant to the ID of the first non-disabled item if no value is set', () => { + const fruitsItem = getTreeItemElementByValue('fruits')!; + expect(treeElement.getAttribute('aria-activedescendant')).toBe(fruitsItem.id); + }); + + it('should set aria-activedescendant to the ID of the first selected item', () => { + updateTree({value: ['vegetables', 'dairy'], focusMode: 'activedescendant'}); + + const vegetablesItem = getTreeItemElementByValue('vegetables')!; + expect(treeElement.getAttribute('aria-activedescendant')).toBe(vegetablesItem.id); + }); + + it('should set tabindex="-1" for all items', () => { + // Preserve collapsed children nodes for checking attributes. + updateTreeItemByValue('fruits', {preserveContent: true}); + updateTreeItemByValue('berries', {preserveContent: true}); + updateTreeItemByValue('vegetables', {preserveContent: true}); + + expect(getTreeItemElementByValue('fruits')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('apple')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('banana')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('berries')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('strawberry')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('blueberry')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('vegetables')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('carrot')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('broccoli')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('grains')!.getAttribute('tabindex')).toBe('-1'); + expect(getTreeItemElementByValue('dairy')!.getAttribute('tabindex')).toBe('-1'); + }); + }); + }); + + describe('value and selection', () => { + it('should select items based on the initial value input', () => { + setupTestTree(); + // Preserve collapsed children nodes for checking attributes. + updateTreeItemByValue('fruits', {preserveContent: true}); + updateTreeItemByValue('berries', {preserveContent: true}); + updateTreeItemByValue('vegetables', {preserveContent: true}); + updateTree({value: ['apple', 'strawberry', 'carrot']}); + + expect(getTreeItemElementByValue('apple')!.getAttribute('aria-selected')).toBe('true'); + expect(getTreeItemElementByValue('strawberry')!.getAttribute('aria-selected')).toBe('true'); + expect(getTreeItemElementByValue('carrot')!.getAttribute('aria-selected')).toBe('true'); + expect(getTreeItemElementByValue('banana')!.getAttribute('aria-selected')).toBe('false'); + }); + + describe('pointer interactions', () => { + describe('single select (multi=false, selectionMode="explicit")', () => { + beforeEach(() => { + setupTestTree(); + updateTree({multi: false, selectionMode: 'explicit'}); + }); + + it('should select an item on click and deselect others', () => { + right(); + const appleEl = getTreeItemElementByValue('apple')!; + const bananaEl = getTreeItemElementByValue('banana')!; + + click(appleEl); + expect(treeInstance.value()).toEqual(['apple']); + expect(appleEl.getAttribute('aria-selected')).toBe('true'); + expect(bananaEl.getAttribute('aria-selected')).toBe('false'); + + click(bananaEl); + expect(treeInstance.value()).toEqual(['banana']); + expect(appleEl.getAttribute('aria-selected')).toBe('false'); + expect(bananaEl.getAttribute('aria-selected')).toBe('true'); + }); + }); + + describe('multi select (multi=true)', () => { + beforeEach(() => { + setupTestTree(); + updateTree({multi: true}); + + // Expands vegetables and fruits + down(); + right(); + up(); + right(); + }); + + describe('selectionMode="explicit"', () => { + beforeEach(() => { + updateTree({selectionMode: 'explicit'}); + }); + + it('should select a range with shift+click', () => { + const appleEl = getTreeItemElementByValue('apple')!; + const carrotEl = getTreeItemElementByValue('carrot')!; + + click(appleEl); + expect(treeInstance.value()).toEqual(['apple']); + expect(appleEl.getAttribute('aria-selected')).toBe('true'); + + shiftClick(carrotEl); + expect(treeInstance.value()).toEqual([ + 'apple', + 'banana', + 'berries', + 'vegetables', + 'carrot', + ]); + }); + + it('should toggle selection of an item on simple click, leaving other selections intact', () => { + const appleEl = getTreeItemElementByValue('apple')!; + const bananaEl = getTreeItemElementByValue('banana')!; + + click(appleEl); + expect(treeInstance.value()).toEqual(['apple']); + + click(bananaEl); + expect(treeInstance.value()).toEqual(['apple', 'banana']); + + click(appleEl); + expect(treeInstance.value()).toEqual(['banana']); + }); + }); + + describe('selectionMode="follow"', () => { + beforeEach(() => { + updateTree({selectionMode: 'follow'}); + }); + + it('should select only the clicked item with a simple click (like single select), deselecting others', () => { + const appleEl = getTreeItemElementByValue('apple')!; + const bananaEl = getTreeItemElementByValue('banana')!; + const carrotEl = getTreeItemElementByValue('carrot')!; + + ctrlClick(appleEl); + ctrlClick(bananaEl); + expect(treeInstance.value()).toEqual(['apple', 'banana']); + + click(carrotEl); + expect(treeInstance.value()).toEqual(['carrot']); + + click(appleEl); + expect(treeInstance.value()).toEqual(['apple']); + }); + + it('should add to selection with ctrl+click and toggle individual items', () => { + const appleEl = getTreeItemElementByValue('apple')!; + const berriesEl = getTreeItemElementByValue('berries')!; + + click(appleEl); + expect(treeInstance.value()).toEqual(['apple']); + + ctrlClick(berriesEl); + expect(treeInstance.value()).toEqual(['apple', 'berries']); + + ctrlClick(appleEl); + expect(treeInstance.value()).toEqual(['berries']); + }); + + it('should select a range with shift+click, anchoring from last selected/focused', () => { + const appleEl = getTreeItemElementByValue('apple')!; + const berriesEl = getTreeItemElementByValue('berries')!; + const carrotEl = getTreeItemElementByValue('carrot')!; + const broccoliEl = getTreeItemElementByValue('broccoli')!; + + click(appleEl); + expect(treeInstance.value()).toEqual(['apple']); + + shiftClick(carrotEl); + expect(treeInstance.value()).toEqual([ + 'apple', + 'banana', + 'berries', + 'vegetables', + 'carrot', + ]); + + click(berriesEl); + expect(treeInstance.value()).toEqual(['berries']); + + shiftClick(broccoliEl); + expect(treeInstance.value()).toEqual([ + 'berries', + 'strawberry', + 'blueberry', + 'vegetables', + 'carrot', + 'broccoli', + ]); + }); + }); + }); + }); + + describe('keyboard interactions', () => { + describe('single select (multi=false)', () => { + beforeEach(() => { + setupTestTree(); + updateTree({multi: false}); + }); + + describe('selectionMode="explicit"', () => { + beforeEach(() => { + updateTree({selectionMode: 'explicit'}); + }); + + it('should select the focused item with Enter and deselect others', () => { + enter(); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + enter(); + expect(treeInstance.value()).toEqual(['vegetables']); + }); + + it('should select the focused item with Space and deselect others', () => { + space(); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + space(); + expect(treeInstance.value()).toEqual(['vegetables']); + }); + + it('should move focus with arrows without changing selection until Enter/Space', () => { + enter(); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + expect(treeInstance.value()).toEqual(['fruits']); + + enter(); + expect(treeInstance.value()).toEqual(['grains']); + }); + }); + + describe('selectionMode="follow"', () => { + beforeEach(() => { + updateTree({selectionMode: 'follow'}); + }); + + it('should select an item when it becomes focused with ArrowDown and deselect others', () => { + updateTree({value: ['fruits']}); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + expect(treeInstance.value()).toEqual(['vegetables']); + + down(); + expect(treeInstance.value()).toEqual(['grains']); + }); + + it('should select an item when it becomes focused with ArrowUp and deselect others', () => { + updateTree({value: ['grains']}); + + up(); + expect(treeInstance.value()).toEqual(['vegetables']); + }); + + it('should select the first item with Home and deselect others', () => { + updateTree({value: ['grains']}); + expect(treeInstance.value()).toEqual(['grains']); + + home(); + expect(treeInstance.value()).toEqual(['fruits']); + }); + + it('should select the last visible item with End and deselect others', () => { + updateTree({value: ['fruits']}); + expect(treeInstance.value()).toEqual(['fruits']); + + end(); + expect(treeInstance.value()).toEqual(['dairy']); + }); + + it('should select an item via typeahead and deselect others', () => { + updateTree({value: ['fruits']}); + expect(treeInstance.value()).toEqual(['fruits']); + + type('V'); + expect(treeInstance.value()).toEqual(['vegetables']); + }); + }); + }); + + describe('multi select (multi=true)', () => { + beforeEach(() => { + setupTestTree(); + updateTree({multi: true}); + }); + + describe('selectionMode="explicit"', () => { + beforeEach(() => { + updateTree({selectionMode: 'explicit'}); + }); + + it('should toggle selection of the focused item with Space, leaving other selections intact', () => { + space(); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + space(); + expect(treeInstance.value().sort()).toEqual(['fruits', 'vegetables']); + }); + + it('should move focus with arrows without changing selection', () => { + space(); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + expect(treeInstance.value()).toEqual(['fruits']); + }); + + it('should extend selection downwards with Shift+ArrowDown', () => { + shift(); + down({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['fruits', 'vegetables']); + + down({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['fruits', 'grains', 'vegetables']); + }); + + it('should extend selection upwards with Shift+ArrowUp', () => { + end(); + shift(); + up({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['dairy', 'grains']); + }); + + it('Ctrl+A should select all enabled visible items, then deselect all', () => { + // Expands vegetables and fruits + down(); + right(); + up(); + right(); + + updateTreeItemByValue('carrot', {disabled: true}); + updateTreeItemByValue('broccoli', {disabled: true}); + + keydown('A', {ctrlKey: true}); + expect(treeInstance.value().sort()).toEqual([ + 'apple', + 'banana', + 'berries', + 'dairy', + 'fruits', + 'grains', + 'vegetables', + ]); + + keydown('A', {ctrlKey: true}); + expect(treeInstance.value()).toEqual([]); + }); + + it('Ctrl+ArrowKey should move focus without changing selection', () => { + space(); + expect(treeInstance.value()).toEqual(['fruits']); + + down({ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + + up({ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + }); + }); + + describe('selectionMode="follow"', () => { + beforeEach(() => { + updateTree({selectionMode: 'follow'}); + }); + + it('should select the focused item and deselect others on ArrowDown', () => { + updateTree({value: ['fruits']}); + expect(treeInstance.value()).toEqual(['fruits']); + + down(); + expect(treeInstance.value()).toEqual(['vegetables']); + }); + + it('should select the focused item and deselect others on ArrowUp', () => { + updateTree({value: ['vegetables']}); + expect(treeInstance.value()).toEqual(['vegetables']); + + up(); + expect(treeInstance.value()).toEqual(['fruits']); + }); + + it('should move focus without changing selection on Ctrl+ArrowDown', () => { + updateTree({value: ['fruits']}); + expect(getFocusedTreeItemValue()).toBe('fruits'); + + down({ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + expect(getFocusedTreeItemValue()).toBe('vegetables'); + }); + + it('should move focus without changing selection on Ctrl+ArrowUp', () => { + updateTree({value: ['fruits']}); + + down({ctrlKey: true}); + expect(getFocusedTreeItemValue()).toBe('vegetables'); + + up({ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + expect(getFocusedTreeItemValue()).toBe('fruits'); + }); + + it('should toggle selection of the focused item on Ctrl+Space, adding to existing selection', () => { + updateTree({value: ['fruits']}); + down({ctrlKey: true}); + expect(getFocusedTreeItemValue()).toBe('vegetables'); + + space({ctrlKey: true}); + expect(treeInstance.value().sort()).toEqual(['fruits', 'vegetables']); + + space({ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + }); + + it('should toggle selection of the focused item on Ctrl+Enter, adding to existing selection', () => { + updateTree({value: ['fruits']}); + down({ctrlKey: true}); + expect(getFocusedTreeItemValue()).toBe('vegetables'); + + enter({ctrlKey: true}); + expect(treeInstance.value().sort()).toEqual(['fruits', 'vegetables']); + + enter({ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + }); + + it('should extend selection downwards with Shift+ArrowDown', () => { + right(); // Expands fruits + updateTree({value: ['fruits']}); + + shift(); + down({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['apple', 'fruits']); + + down({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['apple', 'banana', 'fruits']); + }); + + it('should extend selection upwards with Shift+ArrowUp', () => { + updateTree({value: ['grains']}); + + shift(); + up({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['grains', 'vegetables']); + + up({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['fruits', 'grains', 'vegetables']); + }); + + it('should select a range with Shift+Space, anchoring from last selected/focused item', () => { + right(); // Expands fruits + updateTree({value: ['fruits']}); + + down({ctrlKey: true}); + down({ctrlKey: true}); + expect(getFocusedTreeItemValue()).toBe('banana'); + + space({shiftKey: true}); + expect(treeInstance.value().sort()).toEqual(['apple', 'banana', 'fruits']); + }); + + it('Ctrl+A: select all enabled visible items; second Ctrl+A deselects all except focused', () => { + right(); // Expands fruits + updateTreeItemByValue('vegetables', {disabled: true}); + + keydown('A', {ctrlKey: true}); + expect(treeInstance.value().sort()).toEqual([ + 'apple', + 'banana', + 'berries', + 'dairy', + 'fruits', + 'grains', + ]); + + keydown('A', {ctrlKey: true}); + expect(treeInstance.value()).toEqual(['fruits']); + }); + + it('typeahead should select the focused item and deselect others', () => { + updateTree({value: ['fruits']}); + type('V'); + expect(treeInstance.value()).toEqual(['vegetables']); + expect(getFocusedTreeItemValue()).toBe('vegetables'); + }); + + it('should not select disabled items during Shift+ArrowKey navigation even if skipDisabled is false', () => { + right(); // Expands fruits + updateTreeItemByValue('banana', {disabled: true}); + updateTree({value: ['apple'], skipDisabled: false}); + expect(getFocusedTreeItemValue()).toBe('apple'); + + keydown('Shift'); + down({shiftKey: true}); + expect(getFocusedTreeItemValue()).toBe('banana'); + expect(treeInstance.value().sort()).toEqual(['apple']); + + down({shiftKey: true}); // Focus 'berries' + expect(getFocusedTreeItemValue()).toBe('berries'); + expect(treeInstance.value().sort()).toEqual(['apple', 'berries']); + }); + + it('should not change selection if tree is disabled', () => { + updateTree({value: ['fruits'], disabled: true}); + down(); + expect(treeInstance.value()).toEqual(['fruits']); + }); + }); + }); + }); + }); + + describe('expansion and collapse', () => { + describe('LTR', () => { + beforeEach(() => { + setupTestTree(); + }); + + describe('orientation="vertical"', () => { + beforeEach(() => { + updateTree({orientation: 'vertical'}); + }); + + it('should expand a collapsed item with ArrowRight', () => { + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + + right(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should move focus to first child if ArrowRight on an expanded item', () => { + right(); // Expands fruits + expect(getFocusedTreeItemValue()).toBe('fruits'); + + right(); + expect(getFocusedTreeItemValue()).toBe('apple'); + }); + + it('should collapse an expanded item with ArrowLeft', () => { + right(); // Expands fruits + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + left(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should move focus to parent if ArrowLeft on a collapsed non-root item', () => { + right(); // Expands fruits + right(); // Focus apple (child of fruits) + expect(getFocusedTreeItemValue()).toBe('apple'); + + left(); + expect(getFocusedTreeItemValue()).toBe('fruits'); + }); + }); + + describe('orientation="horizontal"', () => { + beforeEach(() => { + updateTree({orientation: 'horizontal'}); + }); + + it('should expand a collapsed item with ArrowDown', () => { + updateTree({orientation: 'horizontal'}); + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + down(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should move focus to first child if ArrowDown on an expanded item', () => { + updateTree({orientation: 'horizontal'}); + expect(getFocusedTreeItemValue()).toBe('fruits'); + down(); + down(); + expect(getFocusedTreeItemValue()).toBe('apple'); + }); + + it('should collapse an expanded item with ArrowUp', () => { + updateTree({orientation: 'horizontal'}); + down(); // Expands fruits + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + up(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should move focus to parent if ArrowUp on a collapsed non-root item', () => { + updateTree({orientation: 'horizontal'}); + down(); // Expands fruits + down(); + expect(getFocusedTreeItemValue()).toBe('apple'); + up(); + expect(getFocusedTreeItemValue()).toBe('fruits'); + }); + }); + + it('should expand all sibling items with Shift + *', () => { + const fruitsEl = getTreeItemElementByValue('fruits')!; + const vegetablesEl = getTreeItemElementByValue('vegetables')!; + const grainsEl = getTreeItemElementByValue('grains')!; + const dairyEl = getTreeItemElementByValue('dairy')!; + + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + expect(vegetablesEl.getAttribute('aria-expanded')).toBe('false'); + + keydown('*', {shiftKey: true}); + + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + expect(vegetablesEl.getAttribute('aria-expanded')).toBe('true'); + expect(grainsEl.hasAttribute('aria-expanded')).toBe(false); + expect(dairyEl.hasAttribute('aria-expanded')).toBe(false); + }); + + it('should toggle expansion on pointerdown (click) for an expandable item', () => { + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + + click(fruitsEl); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + expect(getFocusedTreeItemValue()).toBe('fruits'); + + click(fruitsEl); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should not expand a non-expandable item on click', () => { + const grainsEl = getTreeItemElementByValue('grains')!; + expect(grainsEl.hasAttribute('aria-expanded')).toBe(false); + + click(grainsEl); + expect(grainsEl.hasAttribute('aria-expanded')).toBe(false); + expect(getFocusedTreeItemValue()).toBe('grains'); + }); + + it('should not expand a non-expandable item with expand key', () => { + const grainsEl = getTreeItemElementByValue('grains')!; + down(); + down(); + expect(getFocusedTreeItemValue()).toBe('grains'); + + right(); + expect(grainsEl.hasAttribute('aria-expanded')).toBe(false); + expect(getFocusedTreeItemValue()).toBe('grains'); + }); + + it('should not expand/collapse if item is disabled', () => { + updateTreeItemByValue('fruits', {disabled: true}); + const fruitsEl = getTreeItemElementByValue('fruits')!; + + click(fruitsEl); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + + right(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should not expand/collapse if tree is disabled', () => { + updateTree({disabled: true}); + const fruitsEl = getTreeItemElementByValue('fruits')!; + + click(fruitsEl); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + + right(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should do nothing on collapseKey if item is collapsed and is a root item', () => { + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + expect(getFocusedTreeItemValue()).toBe('fruits'); + + left(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + expect(getFocusedTreeItemValue()).toBe('fruits'); + }); + }); + + describe('RTL', () => { + beforeEach(() => { + setupTestTree('rtl'); + }); + + describe('orientation="vertical"', () => { + it('should expand a collapsed item with ArrowLeft', () => { + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + left(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should collapse an expanded item with ArrowRight', () => { + left(); + const fruitsEl = getTreeItemElementByValue('fruits')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + + right(); + expect(fruitsEl.getAttribute('aria-expanded')).toBe('false'); + }); + }); + }); + }); + + describe('keyboard navigation', () => { + for (const focusMode of ['roving', 'activedescendant'] as const) { + const isFocused = (value: string) => getFocusedTreeItemValue() === value; + + describe(`focusMode="${focusMode}"`, () => { + describe('LTR', () => { + beforeEach(() => { + setupTestTree('ltr'); + updateTree({focusMode}); + }); + + describe('vertical orientation', () => { + beforeEach(() => { + updateTree({orientation: 'vertical'}); + }); + + it('should move focus to the next visible item on ArrowDown', () => { + expect(isFocused('fruits')).toBe(true); + right(); // Expands fruits + down(); + expect(isFocused('apple')).toBe(true); + down(); + expect(isFocused('banana')).toBe(true); + }); + + it('should move focus to the previous visible item on ArrowUp', () => { + expect(isFocused('fruits')).toBe(true); + right(); // Expands fruits + down(); + down(); + expect(isFocused('banana')).toBe(true); + up(); + expect(isFocused('apple')).toBe(true); + up(); + expect(isFocused('fruits')).toBe(true); + }); + + it('should skip disabled items with ArrowDown if skipDisabled=true', () => { + right(); // Expands fruits + updateTreeItemByValue('apple', {disabled: true}); + updateTree({skipDisabled: true}); + + expect(isFocused('fruits')).toBe(true); + down(); + expect(isFocused('banana')).toBe(true); + }); + + it('should not skip disabled items with ArrowDown if skipDisabled=false', () => { + right(); // Expands fruits + updateTreeItemByValue('apple', {disabled: true}); + updateTree({skipDisabled: false}); + + expect(isFocused('fruits')).toBe(true); + down(); + expect(isFocused('apple')).toBe(true); + }); + + it('should wrap focus from last to first with ArrowDown when wrap is true', () => { + updateTree({wrap: true}); + end(); + expect(isFocused('dairy')).toBe(true); + down(); + expect(isFocused('fruits')).toBe(true); + }); + + it('should not wrap focus from last to first with ArrowDown when wrap is false', () => { + updateTree({wrap: false}); + end(); + expect(isFocused('dairy')).toBe(true); + down(); + expect(isFocused('dairy')).toBe(true); + }); + }); + + describe('horizontal orientation', () => { + beforeEach(() => { + updateTree({orientation: 'horizontal'}); + }); + + it('should move focus to the next visible item on ArrowRight', () => { + expect(isFocused('fruits')).toBe(true); + right(); + expect(isFocused('vegetables')).toBe(true); + }); + + it('should move focus to the previous visible item on ArrowLeft', () => { + right(); + expect(isFocused('vegetables')).toBe(true); + left(); + expect(isFocused('fruits')).toBe(true); + }); + }); + + it('should move focus to the last enabled visible item on End', () => { + right(); // Expands fruits + updateTreeItemByValue('dairy', {disabled: true}); + updateTreeItemByValue('grains', {disabled: true}); + updateTreeItemByValue('vegetables', {disabled: true}); + end(); + expect(isFocused('berries')).toBe(true); + }); + + it('should move focus to the first enabled visible item on Home', () => { + end(); + updateTreeItemByValue('fruits', {disabled: true}); + home(); + expect(isFocused('vegetables')).toBe(true); + }); + }); + + describe('RTL', () => { + beforeEach(() => { + setupTestTree('rtl'); + updateTree({focusMode}); + }); + + describe('vertical orientation', () => { + beforeEach(() => { + updateTree({orientation: 'vertical'}); + }); + + it('should move focus to the next visible item on ArrowDown', () => { + expect(isFocused('fruits')).toBe(true); + down(); + expect(isFocused('vegetables')).toBe(true); + }); + + it('should move focus to the previous visible item on ArrowUp', () => { + down(); + expect(isFocused('vegetables')).toBe(true); + up(); + expect(isFocused('fruits')).toBe(true); + }); + }); + + describe('horizontal orientation', () => { + beforeEach(() => { + updateTree({orientation: 'horizontal'}); + }); + + it('should move focus to the next visible item on ArrowLeft', () => { + expect(isFocused('fruits')).toBe(true); + left(); + expect(isFocused('vegetables')).toBe(true); + }); + + it('should move focus to the previous visible item on ArrowRight', () => { + left(); + expect(isFocused('vegetables')).toBe(true); + right(); + expect(isFocused('fruits')).toBe(true); + }); + }); + }); + + describe('pointer navigation', () => { + beforeEach(() => setupTestTree()); + + it('should move focus to the clicked item', () => { + const vegetablesEl = getTreeItemElementByValue('vegetables')!; + click(vegetablesEl); + expect(isFocused('vegetables')).toBe(true); + }); + + it('should move focus to the clicked disabled item if skipDisabled=false', () => { + updateTreeItemByValue('vegetables', {disabled: true}); + updateTree({skipDisabled: false}); + const vegetablesEl = getTreeItemElementByValue('vegetables')!; + click(vegetablesEl); + expect(isFocused('vegetables')).toBe(true); + }); + }); + + describe('typeahead functionality', () => { + beforeEach(() => setupTestTree()); // LTR by default + + it('should focus the first matching visible item when typing characters', () => { + right(); // Expands fruits + type('Ba'); + expect(isFocused('banana')).toBe(true); + }); + + it('should select the focused item if selectionMode is "follow"', () => { + updateTree({selectionMode: 'follow'}); + type('Gr'); + expect(isFocused('grains')).toBe(true); + expect(treeInstance.value()).toEqual(['grains']); + }); + + it('should not select the focused item if selectionMode is "explicit"', () => { + updateTree({selectionMode: 'explicit'}); + type('Gr'); + expect(isFocused('grains')).toBe(true); + expect(treeInstance.value()).toEqual([]); + }); + + it('should skip disabled items with typeahead if skipDisabled=true', () => { + right(); // Expands fruits + updateTreeItemByValue('banana', {disabled: true}); + updateTree({skipDisabled: true}); + type('B'); + expect(isFocused('berries')).toBe(true); + }); + + it('should focus disabled items with typeahead if skipDisabled=false', () => { + updateTreeItemByValue('vegetables', {disabled: true}); + updateTree({skipDisabled: false}); + type('V'); + expect(isFocused('vegetables')).toBe(true); + }); + }); + }); + } + }); +}); + +interface TestTreeNode { + value: V; + label: string; + disabled?: boolean; + children?: TestTreeNode[]; + preserveContent?: boolean; +} + +@Component({ + template: ` +
    + @for (node of nodes(); track node.value) { +
  • + {{ node.label }} + @if (node.children !== undefined && node.children!.length > 0) { +
      + + @for (node of node.children; track node.value) { +
    • + {{ node.label }} + @if (node.children !== undefined && node.children!.length > 0) { +
        + + @for (node of node.children; track node.value) { +
      • + {{ node.label }} +
      • + } +
        +
      + } +
    • + } +
      +
    + } +
  • + } +
+ `, + imports: [CdkTree, CdkTreeItem, CdkTreeItemGroup, CdkTreeItemGroupContent], +}) +class TestTreeComponent { + nodes = signal([ + { + value: 'fruits', + label: 'Fruits', + children: [ + {value: 'apple', label: 'Apple'}, + {value: 'banana', label: 'Banana'}, + { + value: 'berries', + label: 'Berries', + children: [ + {value: 'strawberry', label: 'Strawberry'}, + {value: 'blueberry', label: 'Blueberry'}, + ], + }, + ], + }, + { + value: 'vegetables', + label: 'Vegetables', + children: [ + {value: 'carrot', label: 'Carrot'}, + {value: 'broccoli', label: 'Broccoli'}, + ], + }, + {value: 'grains', label: 'Grains'}, + {value: 'dairy', label: 'Dairy'}, + ]); + value = signal([]); + disabled = signal(false); + orientation = signal<'vertical' | 'horizontal'>('vertical'); + multi = signal(false); + wrap = signal(true); + skipDisabled = signal(true); + focusMode = signal<'roving' | 'activedescendant'>('roving'); + selectionMode = signal<'explicit' | 'follow'>('explicit'); +} diff --git a/src/cdk-experimental/tree/tree.ts b/src/cdk-experimental/tree/tree.ts new file mode 100644 index 000000000000..e317a0da1322 --- /dev/null +++ b/src/cdk-experimental/tree/tree.ts @@ -0,0 +1,349 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + Directive, + ElementRef, + afterRenderEffect, + booleanAttribute, + computed, + inject, + input, + model, + signal, + Signal, + OnInit, + OnDestroy, +} from '@angular/core'; +import {_IdGenerator} from '@angular/cdk/a11y'; +import {Directionality} from '@angular/cdk/bidi'; +import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content'; +import {TreeItemPattern, TreePattern} from '../ui-patterns/tree/tree'; + +interface HasElement { + element: Signal; +} + +/** + * Sort directives by their document order. + */ +function sortDirectives(a: HasElement, b: HasElement) { + return (a.element().compareDocumentPosition(b.element()) & Node.DOCUMENT_POSITION_PRECEDING) > 0 + ? 1 + : -1; +} + +/** + * A Tree container. + * + * Transforms nested lists into an accessible, ARIA-compliant tree structure. + * + * ```html + *
    + *
  • Leaf Item 1
  • + *
  • + * Parent Item 1 + *
      + * + *
    • Child Item 1.1
    • + *
    • Child Item 1.2
    • + *
      + *
    + *
  • + *
  • Disabled Leaf Item 2
  • + *
+ * ``` + */ +@Directive({ + selector: '[cdkTree]', + exportAs: 'cdkTree', + host: { + 'class': 'cdk-tree', + 'role': 'tree', + '[attr.aria-orientation]': 'pattern.orientation()', + '[attr.aria-multiselectable]': 'pattern.multi()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.aria-activedescendant]': 'pattern.activedescendant()', + '[tabindex]': 'pattern.tabindex()', + '(keydown)': 'pattern.onKeydown($event)', + '(pointerdown)': 'pattern.onPointerdown($event)', + '(focusin)': 'onFocus()', + }, +}) +export class CdkTree { + /** All CdkTreeItem instances within this tree. */ + private readonly _unorderedItems = signal(new Set>()); + + /** All CdkGroup instances within this tree. */ + readonly unorderedGroups = signal(new Set>()); + + /** Orientation of the tree. */ + readonly orientation = input<'vertical' | 'horizontal'>('vertical'); + + /** Whether multi-selection is allowed. */ + readonly multi = input(false, {transform: booleanAttribute}); + + /** Whether the tree is disabled. */ + readonly disabled = input(false, {transform: booleanAttribute}); + + /** The selection strategy used by the tree. */ + readonly selectionMode = input<'explicit' | 'follow'>('explicit'); + + /** The focus strategy used by the tree. */ + readonly focusMode = input<'roving' | 'activedescendant'>('roving'); + + /** Whether navigation wraps. */ + readonly wrap = input(true, {transform: booleanAttribute}); + + /** Whether to skip disabled items during navigation. */ + readonly skipDisabled = input(true, {transform: booleanAttribute}); + + /** Typeahead delay. */ + readonly typeaheadDelay = input(0.5); + + /** Selected item values. */ + readonly value = model([]); + + /** Text direction. */ + readonly textDirection = inject(Directionality).valueSignal; + + /** The UI pattern for the tree. */ + readonly pattern: TreePattern = new TreePattern({ + ...this, + allItems: computed(() => + [...this._unorderedItems()].sort(sortDirectives).map(item => item.pattern), + ), + activeIndex: signal(0), + }); + + /** Whether the tree has received focus yet. */ + private _hasFocused = signal(false); + + constructor() { + afterRenderEffect(() => { + if (!this._hasFocused()) { + this.pattern.setDefaultState(); + } + }); + } + + onFocus() { + this._hasFocused.set(true); + } + + register(child: CdkTreeItemGroup | CdkTreeItem) { + if (child instanceof CdkTreeItemGroup) { + this.unorderedGroups().add(child); + this.unorderedGroups.set(new Set(this.unorderedGroups())); + } + + if (child instanceof CdkTreeItem) { + this._unorderedItems().add(child); + this._unorderedItems.set(new Set(this._unorderedItems())); + } + } + + deregister(child: CdkTreeItemGroup | CdkTreeItem) { + if (child instanceof CdkTreeItemGroup) { + this.unorderedGroups().delete(child); + this.unorderedGroups.set(new Set(this.unorderedGroups())); + } + + if (child instanceof CdkTreeItem) { + this._unorderedItems().delete(child); + this._unorderedItems.set(new Set(this._unorderedItems())); + } + } +} + +/** + * A selectable and expandable Tree Item in a Tree. + */ +@Directive({ + selector: '[cdkTreeItem]', + exportAs: 'cdkTreeItem', + host: { + 'class': 'cdk-treeitem', + '[class.cdk-active]': 'pattern.active()', + 'role': 'treeitem', + '[id]': 'pattern.id()', + '[attr.aria-expanded]': 'pattern.expandable() ? pattern.expanded() : null', + '[attr.aria-selected]': 'pattern.selected()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.aria-level]': 'pattern.level()', + '[attr.aria-owns]': 'group()?.id', + '[attr.aria-setsize]': 'pattern.setsize()', + '[attr.aria-posinset]': 'pattern.posinset()', + '[attr.tabindex]': 'pattern.tabindex()', + '[attr.inert]': 'pattern.visible() ? null : true', + }, +}) +export class CdkTreeItem implements OnInit, OnDestroy, HasElement { + /** A reference to the tree item element. */ + private readonly _elementRef = inject(ElementRef); + + /** A unique identifier for the tree item. */ + private readonly _id = inject(_IdGenerator).getId('cdk-tree-item-'); + + /** The top level CdkTree. */ + private readonly _tree = inject(CdkTree); + + /** The parent CdkTreeItem. */ + private readonly _treeItem = inject(CdkTreeItem, {optional: true, skipSelf: true}); + + /** The parent CdkGroup, if any. */ + private readonly _parentGroup = inject(CdkTreeItemGroup, {optional: true}); + + /** The top level TreePattern. */ + private readonly _treePattern = computed(() => this._tree.pattern); + + /** The parent TreeItemPattern. */ + private readonly _parentPattern: Signal | TreePattern> = computed( + () => this._treeItem?.pattern ?? this._treePattern(), + ); + + /** The host native element. */ + readonly element = computed(() => this._elementRef.nativeElement); + + /** The value of the tree item. */ + readonly value = input.required(); + + /** Whether the tree item is disabled. */ + readonly disabled = input(false, {transform: booleanAttribute}); + + /** Optional label for typeahead. Defaults to the element's textContent. */ + readonly label = input(); + + /** Search term for typeahead. */ + readonly searchTerm = computed(() => this.label() ?? this.element().textContent); + + /** Manual group assignment. */ + readonly group = signal | undefined>(undefined); + + /** The UI pattern for this item. */ + readonly pattern: TreeItemPattern = new TreeItemPattern({ + ...this, + id: () => this._id, + tree: this._treePattern, + parent: this._parentPattern, + children: computed( + () => + this.group() + ?.children() + .map(item => (item as CdkTreeItem).pattern) ?? [], + ), + hasChildren: computed(() => !!this.group()), + }); + + constructor() { + afterRenderEffect(() => { + const group = [...this._tree.unorderedGroups()].find(group => group.value() === this.value()); + if (group) { + this.group.set(group); + } + }); + + // Updates the visibility of the owned group. + afterRenderEffect(() => { + this.group()?.visible.set(this.pattern.expanded()); + }); + } + + ngOnInit() { + this._tree.register(this); + this._parentGroup?.register(this); + } + + ngOnDestroy() { + this._tree.deregister(this); + this._parentGroup?.deregister(this); + } +} + +/** + * Container that designates content as a group. + */ +@Directive({ + selector: '[cdkTreeItemGroup]', + exportAs: 'cdkTreeItemGroup', + hostDirectives: [ + { + directive: DeferredContentAware, + inputs: ['preserveContent'], + }, + ], + host: { + 'class': 'cdk-treeitem-group', + 'role': 'group', + '[id]': 'id', + '[attr.inert]': 'visible() ? null : true', + }, +}) +export class CdkTreeItemGroup implements OnInit, OnDestroy, HasElement { + /** A reference to the group element. */ + private readonly _elementRef = inject(ElementRef); + + /** The DeferredContentAware host directive. */ + private readonly _deferredContentAware = inject(DeferredContentAware); + + /** The top level CdkTree. */ + private readonly _tree = inject(CdkTree); + + /** All groupable items that are descendants of the group. */ + private readonly _unorderedItems = signal(new Set>()); + + /** The host native element. */ + readonly element = computed(() => this._elementRef.nativeElement); + + /** Unique ID for the group. */ + readonly id = inject(_IdGenerator).getId('cdk-tree-group-'); + + /** Whether the group is visible. */ + readonly visible = signal(true); + + /** Child items within this group. */ + readonly children = computed(() => [...this._unorderedItems()].sort(sortDirectives)); + + /** Identifier for matching the group owner. */ + readonly value = input.required(); + + constructor() { + // Connect the group's hidden state to the DeferredContentAware's visibility. + afterRenderEffect(() => { + this._deferredContentAware.contentVisible.set(this.visible()); + }); + } + + ngOnInit() { + this._tree.register(this); + } + + ngOnDestroy() { + this._tree.deregister(this); + } + + register(child: CdkTreeItem) { + this._unorderedItems().add(child); + this._unorderedItems.set(new Set(this._unorderedItems())); + } + + deregister(child: CdkTreeItem) { + this._unorderedItems().delete(child); + this._unorderedItems.set(new Set(this._unorderedItems())); + } +} + +/** + * A structural directive that marks the `ng-template` to be used as the content + * for a `CdkTreeItemGroup`. This content can be lazily loaded. + */ +@Directive({ + selector: 'ng-template[cdkTreeItemGroupContent]', + hostDirectives: [DeferredContent], +}) +export class CdkTreeItemGroupContent {} diff --git a/src/cdk-experimental/ui-patterns/BUILD.bazel b/src/cdk-experimental/ui-patterns/BUILD.bazel index fee524d85d42..0d4d60260c23 100644 --- a/src/cdk-experimental/ui-patterns/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/BUILD.bazel @@ -10,8 +10,11 @@ ts_project( ), deps = [ "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/accordion", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", "//src/cdk-experimental/ui-patterns/listbox", + "//src/cdk-experimental/ui-patterns/radio-group", "//src/cdk-experimental/ui-patterns/tabs", + "//src/cdk-experimental/ui-patterns/tree", ], ) diff --git a/src/cdk-experimental/ui-patterns/accordion/BUILD.bazel b/src/cdk-experimental/ui-patterns/accordion/BUILD.bazel new file mode 100644 index 000000000000..2bc65f76efbf --- /dev/null +++ b/src/cdk-experimental/ui-patterns/accordion/BUILD.bazel @@ -0,0 +1,38 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "accordion", + srcs = [ + "accordion.ts", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/event-manager", + "//src/cdk-experimental/ui-patterns/behaviors/expansion", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "//src/cdk-experimental/ui-patterns/behaviors/list-selection", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "accordion.spec.ts", + ], + deps = [ + ":accordion", + "//:node_modules/@angular/core", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/accordion/accordion.spec.ts b/src/cdk-experimental/ui-patterns/accordion/accordion.spec.ts new file mode 100644 index 000000000000..bc076833e78d --- /dev/null +++ b/src/cdk-experimental/ui-patterns/accordion/accordion.spec.ts @@ -0,0 +1,369 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {signal} from '@angular/core'; +import { + AccordionGroupInputs, + AccordionGroupPattern, + AccordionPanelInputs, + AccordionPanelPattern, + AccordionTriggerInputs, + AccordionTriggerPattern, +} from './accordion'; +import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; +import {ModifierKeys} from '@angular/cdk/testing'; + +// Converts the SignalLike type to WritableSignalLike type for controlling test inputs. +type WritableSignalOverrides = { + [K in keyof O as O[K] extends SignalLike ? K : never]: O[K] extends SignalLike + ? WritableSignalLike + : never; +}; + +type TestAccordionGroupInputs = AccordionGroupInputs & + WritableSignalOverrides; +type TestAccordionTriggerInputs = AccordionTriggerInputs & + WritableSignalOverrides; +type TestAccordionPanelInputs = AccordionPanelInputs & + WritableSignalOverrides; + +const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods); +const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods); +const left = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 37, 'ArrowLeft', mods); +const right = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 39, 'ArrowRight', mods); +const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', mods); +const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods); +const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods); +const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods); + +function createAccordionTriggerElement(): HTMLElement { + const element = document.createElement('button'); + element.setAttribute('role', 'button'); + return element; +} + +describe('Accordion Pattern', () => { + let groupInputs: TestAccordionGroupInputs; + let groupPattern: AccordionGroupPattern; + let triggerInputs: TestAccordionTriggerInputs[]; + let triggerPatterns: AccordionTriggerPattern[]; + let panelInputs: TestAccordionPanelInputs[]; + let panelPatterns: AccordionPanelPattern[]; + + beforeEach(() => { + // Initiate AccordionGroupPattern. + groupInputs = { + orientation: signal('vertical'), + textDirection: signal('ltr'), + activeIndex: signal(0), + disabled: signal(false), + multiExpandable: signal(true), + items: signal([]), + expandedIds: signal([]), + skipDisabled: signal(true), + wrap: signal(true), + }; + groupPattern = new AccordionGroupPattern(groupInputs); + + // Initiate a list of AccordionTriggerPattern. + triggerInputs = [ + { + accordionGroup: signal(groupPattern), + accordionPanel: signal(undefined), + id: signal('trigger-1-id'), + element: signal(createAccordionTriggerElement()), + disabled: signal(false), + value: signal('panel-1'), // Value should match the panel it controls + }, + { + accordionGroup: signal(groupPattern), + accordionPanel: signal(undefined), + id: signal('trigger-2-id'), + element: signal(createAccordionTriggerElement()), + disabled: signal(false), + value: signal('panel-2'), + }, + { + accordionGroup: signal(groupPattern), + accordionPanel: signal(undefined), + id: signal('trigger-3-id'), + element: signal(createAccordionTriggerElement()), + disabled: signal(false), + value: signal('panel-3'), + }, + ]; + triggerPatterns = [ + new AccordionTriggerPattern(triggerInputs[0]), + new AccordionTriggerPattern(triggerInputs[1]), + new AccordionTriggerPattern(triggerInputs[2]), + ]; + + // Initiate a list of AccordionPanelPattern. + panelInputs = [ + { + id: signal('panel-1-id'), + value: signal('panel-1'), + accordionTrigger: signal(undefined), + }, + { + id: signal('panel-2-id'), + value: signal('panel-2'), + accordionTrigger: signal(undefined), + }, + { + id: signal('panel-3-id'), + value: signal('panel-3'), + accordionTrigger: signal(undefined), + }, + ]; + panelPatterns = [ + new AccordionPanelPattern(panelInputs[0]), + new AccordionPanelPattern(panelInputs[1]), + new AccordionPanelPattern(panelInputs[2]), + ]; + + // Binding between triggers and panels. + triggerInputs[0].accordionPanel.set(panelPatterns[0]); + triggerInputs[1].accordionPanel.set(panelPatterns[1]); + triggerInputs[2].accordionPanel.set(panelPatterns[2]); + panelInputs[0].accordionTrigger.set(triggerPatterns[0]); + panelInputs[1].accordionTrigger.set(triggerPatterns[1]); + panelInputs[2].accordionTrigger.set(triggerPatterns[2]); + groupInputs.items.set(triggerPatterns); + }); + + it('expands a panel by setting `value`.', () => { + expect(triggerPatterns[0].expanded()).toBeFalse(); + groupInputs.expandedIds.set(['panel-1']); + expect(triggerPatterns[0].expanded()).toBeTrue(); + }); + + it('gets a controlled panel id from a trigger.', () => { + expect(panelPatterns[0].id()).toBe('panel-1-id'); + expect(triggerPatterns[0].controls()).toBe('panel-1-id'); + expect(panelPatterns[1].id()).toBe('panel-2-id'); + expect(triggerPatterns[1].controls()).toBe('panel-2-id'); + expect(panelPatterns[2].id()).toBe('panel-3-id'); + expect(triggerPatterns[2].controls()).toBe('panel-3-id'); + }); + + describe('Keyboard Navigation', () => { + it('does not handle keyboard event if an accordion group is disabled.', () => { + groupInputs.disabled.set(true); + triggerPatterns[0].onKeydown(space()); + expect(panelPatterns[0].hidden()).toBeTrue(); + }); + + it('does not handle keyboard event if an accordion trigger is disabled.', () => { + triggerInputs[0].disabled.set(true); + triggerPatterns[0].onKeydown(space()); + expect(panelPatterns[0].hidden()).toBeTrue(); + }); + + it('navigates to first accordion trigger with home key.', () => { + groupInputs.activeIndex.set(2); + expect(triggerPatterns[2].active()).toBeTrue(); + triggerPatterns[2].onKeydown(home()); + expect(triggerPatterns[2].active()).toBeFalse(); + expect(triggerPatterns[0].active()).toBeTrue(); + }); + + it('navigates to last accordion trigger with end key.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + triggerPatterns[0].onKeydown(end()); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[2].active()).toBeTrue(); + }); + + describe('Vertical Orientation (orientation=vertical)', () => { + it('navigates to the next trigger with down key.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + expect(triggerPatterns[1].active()).toBeFalse(); + triggerPatterns[0].onKeydown(down()); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[1].active()).toBeTrue(); + }); + + it('navigates to the previous trigger with up key.', () => { + groupInputs.activeIndex.set(1); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[1].active()).toBeTrue(); + triggerPatterns[1].onKeydown(up()); + expect(triggerPatterns[1].active()).toBeFalse(); + expect(triggerPatterns[0].active()).toBeTrue(); + }); + + describe('wrap=true', () => { + beforeEach(() => { + groupInputs.wrap.set(true); + }); + + it('navigates to the last trigger with up key from first trigger.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + expect(triggerPatterns[2].active()).toBeFalse(); + triggerPatterns[0].onKeydown(up()); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[2].active()).toBeTrue(); + }); + + it('navigates to the first trigger with down key from last trigger.', () => { + groupInputs.activeIndex.set(2); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[2].active()).toBeTrue(); + triggerPatterns[2].onKeydown(down()); + expect(triggerPatterns[0].active()).toBeTrue(); + expect(triggerPatterns[2].active()).toBeFalse(); + }); + }); + + describe('wrap=false', () => { + beforeEach(() => { + groupInputs.wrap.set(false); + }); + + it('stays on the first trigger with up key from first trigger.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + triggerPatterns[0].onKeydown(up()); + expect(triggerPatterns[0].active()).toBeTrue(); + }); + + it('stays on the last trigger with down key from last trigger.', () => { + groupInputs.activeIndex.set(2); + expect(triggerPatterns[2].active()).toBeTrue(); + triggerPatterns[2].onKeydown(down()); + expect(triggerPatterns[2].active()).toBeTrue(); + }); + }); + }); + + describe('Horizontal Orientation (orientation=horizontal)', () => { + beforeEach(() => { + groupInputs.orientation.set('horizontal'); + }); + + it('navigates to the next trigger with right key.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + expect(triggerPatterns[1].active()).toBeFalse(); + triggerPatterns[0].onKeydown(right()); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[1].active()).toBeTrue(); + }); + + it('navigates to the previous trigger with left key.', () => { + groupInputs.activeIndex.set(1); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[1].active()).toBeTrue(); + triggerPatterns[1].onKeydown(left()); + expect(triggerPatterns[1].active()).toBeFalse(); + expect(triggerPatterns[0].active()).toBeTrue(); + }); + + describe('wrap=true', () => { + beforeEach(() => { + groupInputs.wrap.set(true); + }); + + it('navigates to the last trigger with left key from first trigger.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + expect(triggerPatterns[2].active()).toBeFalse(); + triggerPatterns[0].onKeydown(left()); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[2].active()).toBeTrue(); + }); + + it('navigates to the first trigger with right key from last trigger.', () => { + groupInputs.activeIndex.set(2); + expect(triggerPatterns[2].active()).toBeTrue(); + expect(triggerPatterns[0].active()).toBeFalse(); + triggerPatterns[2].onKeydown(right()); + expect(triggerPatterns[2].active()).toBeFalse(); + expect(triggerPatterns[0].active()).toBeTrue(); + }); + }); + + describe('wrap=false', () => { + beforeEach(() => { + groupInputs.wrap.set(false); + }); + + it('stays on the first trigger with left key from first trigger.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + triggerPatterns[0].onKeydown(left()); + expect(triggerPatterns[0].active()).toBeTrue(); + }); + + it('stays on the last trigger with right key from last trigger.', () => { + groupInputs.activeIndex.set(2); + expect(triggerPatterns[2].active()).toBeTrue(); + triggerPatterns[2].onKeydown(right()); + expect(triggerPatterns[2].active()).toBeTrue(); + }); + }); + }); + + describe('Single Expansion (multi=false)', () => { + beforeEach(() => { + groupInputs.multiExpandable.set(false); + }); + + it('expands a panel and collapses others with space key.', () => { + groupInputs.expandedIds.set(['panel-2']); + expect(panelPatterns[0].hidden()).toBeTrue(); + expect(panelPatterns[1].hidden()).toBeFalse(); + + triggerPatterns[0].onKeydown(space()); + expect(panelPatterns[0].hidden()).toBeFalse(); + expect(panelPatterns[1].hidden()).toBeTrue(); + }); + + it('expands a panel and collapses others with enter key.', () => { + groupInputs.expandedIds.set(['panel-2']); + expect(panelPatterns[0].hidden()).toBeTrue(); + expect(panelPatterns[1].hidden()).toBeFalse(); + + triggerPatterns[0].onKeydown(space()); + expect(panelPatterns[0].hidden()).toBeFalse(); + expect(panelPatterns[1].hidden()).toBeTrue(); + }); + }); + + describe('Multiple Expansion (multi=true)', () => { + beforeEach(() => { + groupInputs.multiExpandable.set(true); + }); + + it('expands a panel without affecting other panels.', () => { + groupInputs.expandedIds.set(['panel-2']); + expect(panelPatterns[0].hidden()).toBeTrue(); + expect(panelPatterns[1].hidden()).toBeFalse(); + + triggerPatterns[0].onKeydown(space()); + expect(panelPatterns[0].hidden()).toBeFalse(); + expect(panelPatterns[1].hidden()).toBeFalse(); + }); + + it('collapses a panel without affecting other panels.', () => { + groupInputs.expandedIds.set(['panel-1', 'panel-2']); + expect(panelPatterns[0].hidden()).toBeFalse(); + expect(panelPatterns[1].hidden()).toBeFalse(); + + triggerPatterns[0].onKeydown(enter()); + expect(panelPatterns[0].hidden()).toBeTrue(); + expect(panelPatterns[1].hidden()).toBeFalse(); + }); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/accordion/accordion.ts b/src/cdk-experimental/ui-patterns/accordion/accordion.ts new file mode 100644 index 000000000000..2d3a8ebe711c --- /dev/null +++ b/src/cdk-experimental/ui-patterns/accordion/accordion.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed} from '@angular/core'; +import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; +import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; +import { + ExpansionItem, + ExpansionControl, + ListExpansion, + ListExpansionInputs, +} from '../behaviors/expansion/expansion'; +import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; +import { + ListNavigation, + ListNavigationInputs, + ListNavigationItem, +} from '../behaviors/list-navigation/list-navigation'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; + +/** Inputs of the AccordionGroupPattern. */ +export type AccordionGroupInputs = Omit< + ListNavigationInputs & + ListFocusInputs & + Omit, + 'focusMode' +>; + +const focusMode = () => 'roving' as const; + +export interface AccordionGroupPattern extends AccordionGroupInputs {} +/** A pattern controls the nested Accordions. */ +export class AccordionGroupPattern { + /** Controls navigation for the group. */ + navigation: ListNavigation; + + /** Controls focus for the group. */ + focusManager: ListFocus; + + /** Controls expansion for the group. */ + expansionManager: ListExpansion; + + constructor(readonly inputs: AccordionGroupInputs) { + this.wrap = inputs.wrap; + this.orientation = inputs.orientation; + this.textDirection = inputs.textDirection; + this.activeIndex = inputs.activeIndex; + this.disabled = inputs.disabled; + this.multiExpandable = inputs.multiExpandable; + this.items = inputs.items; + this.expandedIds = inputs.expandedIds; + this.skipDisabled = inputs.skipDisabled; + this.focusManager = new ListFocus({ + ...inputs, + focusMode, + }); + this.navigation = new ListNavigation({ + ...inputs, + focusMode, + focusManager: this.focusManager, + }); + this.expansionManager = new ListExpansion({ + ...inputs, + }); + } +} + +/** Inputs for the AccordionTriggerPattern. */ +export type AccordionTriggerInputs = ListNavigationItem & + ListFocusItem & + Omit & { + /** A local unique identifier for the trigger. */ + value: SignalLike; + + /** The parent accordion group that controls this trigger. */ + accordionGroup: SignalLike; + + /** The accordion panel controlled by this trigger. */ + accordionPanel: SignalLike; + }; + +export interface AccordionTriggerPattern extends AccordionTriggerInputs {} +/** A pattern controls the expansion state of an accordion. */ +export class AccordionTriggerPattern { + /** Whether this tab has expandable content. */ + expandable: SignalLike; + + /** The unique identifier used by the expansion behavior. */ + expansionId: SignalLike; + + /** Whether an accordion is expanded. */ + expanded: SignalLike; + + /** Controls the expansion state for the trigger. */ + expansionControl: ExpansionControl; + + /** Whether the trigger is active. */ + active = computed(() => this.inputs.accordionGroup().focusManager.activeItem() === this); + + /** Id of the accordion panel controlled by the trigger. */ + controls = computed(() => this.inputs.accordionPanel()?.id()); + + /** The tabindex of the trigger. */ + tabindex = computed(() => (this.inputs.accordionGroup().focusManager.isFocusable(this) ? 0 : -1)); + + /** Whether the trigger is disabled. Disabling an accordion group disables all the triggers. */ + disabled = computed(() => this.inputs.disabled() || this.inputs.accordionGroup().disabled()); + + constructor(readonly inputs: AccordionTriggerInputs) { + this.id = inputs.id; + this.element = inputs.element; + this.value = inputs.value; + this.accordionGroup = inputs.accordionGroup; + this.accordionPanel = inputs.accordionPanel; + this.expansionControl = new ExpansionControl({ + ...inputs, + expansionId: inputs.value, + expandable: () => true, + expansionManager: inputs.accordionGroup().expansionManager, + }); + this.expandable = this.expansionControl.isExpandable; + this.expansionId = this.expansionControl.expansionId; + this.expanded = this.expansionControl.isExpanded; + } + + /** The key used to navigate to the previous accordion trigger. */ + prevKey = computed(() => { + if (this.inputs.accordionGroup().orientation() === 'vertical') { + return 'ArrowUp'; + } + return this.inputs.accordionGroup().textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key used to navigate to the next accordion trigger. */ + nextKey = computed(() => { + if (this.inputs.accordionGroup().orientation() === 'vertical') { + return 'ArrowDown'; + } + return this.inputs.accordionGroup().textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The keydown event manager for the accordion trigger. */ + keydown = computed(() => { + return new KeyboardEventManager() + .on(this.prevKey, () => this.accordionGroup().navigation.prev()) + .on(this.nextKey, () => this.accordionGroup().navigation.next()) + .on('Home', () => this.accordionGroup().navigation.first()) + .on('End', () => this.accordionGroup().navigation.last()) + .on(' ', () => this.expansionControl.toggle()) + .on('Enter', () => this.expansionControl.toggle()); + }); + + /** The pointerdown event manager for the accordion trigger. */ + pointerdown = computed(() => { + return new PointerEventManager().on(e => { + const item = this._getItem(e); + + if (item) { + this.accordionGroup().navigation.goto(item); + this.expansionControl.toggle(); + } + }); + }); + + /** Handles keydown events on the trigger, delegating to the group if not disabled. */ + onKeydown(event: KeyboardEvent): void { + this.keydown().handle(event); + } + + /** Handles pointerdown events on the trigger, delegating to the group if not disabled. */ + onPointerdown(event: PointerEvent): void { + this.pointerdown().handle(event); + } + + /** Handles focus events on the trigger. This ensures the tabbing changes the active index. */ + onFocus(event: FocusEvent): void { + const item = this._getItem(event); + + if (item && this.inputs.accordionGroup().focusManager.isFocusable(item)) { + this.accordionGroup().focusManager.focus(item); + } + } + + private _getItem(e: Event) { + if (!(e.target instanceof HTMLElement)) { + return; + } + + const element = e.target.closest('[role="button"]'); + return this.accordionGroup() + .items() + .find(i => i.element() === element); + } +} + +/** Represents the required inputs for the AccordionPanelPattern. */ +export interface AccordionPanelInputs { + /** A global unique identifier for the panel. */ + id: SignalLike; + + /** A local unique identifier for the panel, matching its trigger's value. */ + value: SignalLike; + + /** The parent accordion trigger that controls this panel. */ + accordionTrigger: SignalLike; +} + +export interface AccordionPanelPattern extends AccordionPanelInputs {} +/** Represents an accordion panel. */ +export class AccordionPanelPattern { + /** Whether the accordion panel is hidden. True if the associated trigger is not expanded. */ + hidden: SignalLike; + + constructor(readonly inputs: AccordionPanelInputs) { + this.id = inputs.id; + this.value = inputs.value; + this.accordionTrigger = inputs.accordionTrigger; + this.hidden = computed(() => inputs.accordionTrigger()?.expanded() === false); + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/expansion/BUILD.bazel new file mode 100644 index 000000000000..ce0364627c10 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/BUILD.bazel @@ -0,0 +1,33 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "expansion", + srcs = [ + "expansion.ts", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "expansion.spec.ts", + ], + deps = [ + ":expansion", + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus:unit_test_sources", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts new file mode 100644 index 000000000000..f1029267fcbe --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts @@ -0,0 +1,248 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {WritableSignal, signal} from '@angular/core'; +import {ListExpansion, ListExpansionInputs, ExpansionItem} from './expansion'; + +type TestItem = ExpansionItem & { + id: WritableSignal; + disabled: WritableSignal; + expandable: WritableSignal; + expansionId: WritableSignal; +}; + +type TestInputs = Partial> & { + numItems?: number; + initialExpandedIds?: string[]; + expansionDisabled?: boolean; +}; + +function createItems(length: number): WritableSignal { + return signal( + Array.from({length}).map((_, i) => { + const itemId = `item-${i}`; + return { + id: signal(itemId), + disabled: signal(false), + expandable: signal(true), + expansionId: signal(itemId), + }; + }), + ); +} + +function getExpansion(inputs: TestInputs = {}): { + expansion: ListExpansion; + items: TestItem[]; +} { + const numItems = inputs.numItems ?? 3; + const items = createItems(numItems); + + const expansion = new ListExpansion({ + items: items, + disabled: signal(inputs.expansionDisabled ?? false), + multiExpandable: signal(inputs.multiExpandable?.() ?? false), + expandedIds: signal([]), + }); + + if (inputs.initialExpandedIds) { + expansion.expandedIds.set(inputs.initialExpandedIds); + } + + return {expansion, items: items()}; +} + +describe('Expansion', () => { + describe('#open', () => { + it('should open only one item at a time when multiExpandable is false', () => { + const {expansion, items} = getExpansion({ + multiExpandable: signal(false), + }); + + expansion.open(items[0]); + expect(expansion.expandedIds()).toEqual(['item-0']); + + expansion.open(items[1]); + expect(expansion.expandedIds()).toEqual(['item-1']); + }); + + it('should open multiple items when multiExpandable is true', () => { + const {expansion, items} = getExpansion({ + multiExpandable: signal(true), + }); + + expansion.open(items[0]); + expect(expansion.expandedIds()).toEqual(['item-0']); + + expansion.open(items[1]); + expect(expansion.expandedIds()).toEqual(['item-0', 'item-1']); + }); + + it('should not open an item if it is not expandable (expandable is false)', () => { + const {expansion, items} = getExpansion(); + items[1].expandable.set(false); + expansion.open(items[1]); + expect(expansion.expandedIds()).toEqual([]); + }); + + it('should not open an item if it is disabled', () => { + const {expansion, items} = getExpansion(); + items[1].disabled.set(true); + expansion.open(items[1]); + expect(expansion.expandedIds()).toEqual([]); + }); + }); + + describe('#close', () => { + it('should close the specified item', () => { + const {expansion, items} = getExpansion({initialExpandedIds: ['item-0', 'item-1']}); + expansion.close(items[0]); + expect(expansion.expandedIds()).toEqual(['item-1']); + }); + + it('should not close an item if it is not expandable', () => { + const {expansion, items} = getExpansion({initialExpandedIds: ['item-0']}); + items[0].expandable.set(false); + expansion.close(items[0]); + expect(expansion.expandedIds()).toEqual(['item-0']); + }); + + it('should not close an item if it is disabled', () => { + const {expansion, items} = getExpansion({initialExpandedIds: ['item-0']}); + items[0].disabled.set(true); + expansion.close(items[0]); + expect(expansion.expandedIds()).toEqual(['item-0']); + }); + }); + + describe('#toggle', () => { + it('should open a closed item', () => { + const {expansion, items} = getExpansion(); + expansion.toggle(items[0]); + expect(expansion.expandedIds()).toEqual(['item-0']); + }); + + it('should close an opened item', () => { + const {expansion, items} = getExpansion({ + initialExpandedIds: ['item-0'], + }); + expansion.toggle(items[0]); + expect(expansion.expandedIds()).toEqual([]); + }); + }); + + describe('#openAll', () => { + it('should open all focusable and expandable items when multiExpandable is true', () => { + const {expansion} = getExpansion({ + numItems: 3, + multiExpandable: signal(true), + }); + expansion.openAll(); + expect(expansion.expandedIds()).toEqual(['item-0', 'item-1', 'item-2']); + }); + + it('should not expand items that are not expandable', () => { + const {expansion, items} = getExpansion({ + numItems: 3, + multiExpandable: signal(true), + }); + items[1].expandable.set(false); + expansion.openAll(); + expect(expansion.expandedIds()).toEqual(['item-0', 'item-2']); + }); + + it('should not expand items that are disabled', () => { + const {expansion, items} = getExpansion({ + numItems: 3, + multiExpandable: signal(true), + }); + items[1].disabled.set(true); + expansion.openAll(); + expect(expansion.expandedIds()).toEqual(['item-0', 'item-2']); + }); + + it('should do nothing when multiExpandable is false', () => { + const {expansion} = getExpansion({ + numItems: 3, + multiExpandable: signal(false), + }); + expansion.openAll(); + expect(expansion.expandedIds()).toEqual([]); + }); + }); + + describe('#closeAll', () => { + it('should close all expanded items', () => { + const {expansion, items} = getExpansion({ + multiExpandable: signal(true), + initialExpandedIds: ['item-0', 'item-2'], + }); + items[1].expandable.set(false); + expansion.closeAll(); + expect(expansion.expandedIds()).toEqual([]); + }); + + it('should not close items that are not expandable', () => { + const {expansion, items} = getExpansion({ + multiExpandable: signal(true), + initialExpandedIds: ['item-0', 'item-1', 'item-2'], + }); + items[1].expandable.set(false); + expansion.closeAll(); + expect(expansion.expandedIds()).toEqual(['item-1']); + }); + + it('should not close items that are disabled', () => { + const {expansion, items} = getExpansion({ + multiExpandable: signal(true), + initialExpandedIds: ['item-0', 'item-1', 'item-2'], + }); + items[1].disabled.set(true); + expansion.closeAll(); + expect(expansion.expandedIds()).toEqual(['item-1']); + }); + }); + + describe('#isExpandable', () => { + it('should return true if an item is not disabled and expandable is true', () => { + const {expansion, items} = getExpansion(); + items[0].expandable.set(true); + items[0].disabled.set(false); + expect(expansion.isExpandable(items[0])).toBeTrue(); + }); + + it('should return false if an item is disabled', () => { + const {expansion, items} = getExpansion(); + items[0].disabled.set(true); + expect(expansion.isExpandable(items[0])).toBeFalse(); + }); + + it('should return false if the expansion behavior is disabled', () => { + const {expansion, items} = getExpansion({expansionDisabled: true}); + expect(expansion.isExpandable(items[0])).toBeFalse(); + }); + + it('should return false if expandable is false', () => { + const {expansion, items} = getExpansion(); + items[0].expandable.set(false); + expect(expansion.isExpandable(items[0])).toBeFalse(); + }); + }); + + describe('#isExpanded', () => { + it('should return true if item ID is in expandedIds', () => { + const {expansion, items} = getExpansion({initialExpandedIds: ['item-0']}); + expect(expansion.isExpanded(items[0])).toBeTrue(); + }); + + it('should return false if item ID is not in expandedIds', () => { + const {expansion, items} = getExpansion(); + expect(expansion.isExpanded(items[0])).toBeFalse(); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts new file mode 100644 index 000000000000..a105744f9d70 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ +import {computed} from '@angular/core'; +import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; + +/** Represents an item that can be expanded or collapsed. */ +export interface ExpansionItem { + /** Whether the item is expandable. */ + expandable: SignalLike; + + /** Used to uniquely identify an expansion item. */ + expansionId: SignalLike; + + /** Whether the expansion is disabled. */ + disabled: SignalLike; +} + +export interface ExpansionControl extends ExpansionItem {} +/** + * Controls a single item's expansion state and interactions, + * delegating actual state changes to an Expansion manager. + */ +export class ExpansionControl { + /** Whether this specific item is currently expanded. Derived from the Expansion manager. */ + readonly isExpanded = computed(() => this.inputs.expansionManager.isExpanded(this)); + + /** Whether this item can be expanded. */ + readonly isExpandable = computed(() => this.inputs.expansionManager.isExpandable(this)); + + constructor(readonly inputs: ExpansionItem & {expansionManager: ListExpansion}) { + this.expansionId = inputs.expansionId; + this.expandable = inputs.expandable; + this.disabled = inputs.disabled; + } + + /** Requests the Expansopn manager to open this item. */ + open() { + this.inputs.expansionManager.open(this); + } + + /** Requests the Expansion manager to close this item. */ + close() { + this.inputs.expansionManager.close(this); + } + + /** Requests the Expansion manager to toggle this item. */ + toggle() { + this.inputs.expansionManager.toggle(this); + } +} + +/** Represents the required inputs for an expansion behavior. */ +export interface ListExpansionInputs { + /** Whether multiple items can be expanded at once. */ + multiExpandable: SignalLike; + + /** An array of ids of the currently expanded items. */ + expandedIds: WritableSignalLike; + + /** An array of expansion items. */ + items: SignalLike; + + /** Whether all expansions are disabled. */ + disabled: SignalLike; +} + +/** Manages the expansion state of a list of items. */ +export class ListExpansion { + /** A signal holding an array of ids of the currently expanded items. */ + expandedIds: WritableSignalLike; + + constructor(readonly inputs: ListExpansionInputs) { + this.expandedIds = inputs.expandedIds; + } + + /** Opens the specified item. */ + open(item: ExpansionItem) { + if (!this.isExpandable(item)) return; + if (this.isExpanded(item)) return; + if (!this.inputs.multiExpandable()) { + this.closeAll(); + } + this.expandedIds.update(ids => ids.concat(item.expansionId())); + } + + /** Closes the specified item. */ + close(item: ExpansionItem) { + if (this.isExpandable(item)) { + this.expandedIds.update(ids => ids.filter(id => id !== item.expansionId())); + } + } + + /** Toggles the expansion state of the specified item. */ + toggle(item: ExpansionItem) { + this.expandedIds().includes(item.expansionId()) ? this.close(item) : this.open(item); + } + + /** Opens all focusable items in the list. */ + openAll() { + if (this.inputs.multiExpandable()) { + for (const item of this.inputs.items()) { + this.open(item); + } + } + } + + /** Closes all focusable items in the list. */ + closeAll() { + for (const item of this.inputs.items()) { + this.close(item); + } + } + + /** Checks whether the specified item is expandable / collapsible. */ + isExpandable(item: ExpansionItem) { + return !this.inputs.disabled() && !item.disabled() && item.expandable(); + } + + /** Checks whether the specified item is currently expanded. */ + isExpanded(item: ExpansionItem): boolean { + return this.expandedIds().includes(item.expansionId()); + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/grid-focus/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/BUILD.bazel new file mode 100644 index 000000000000..763e2d09c806 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/BUILD.bazel @@ -0,0 +1,27 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "grid-focus", + srcs = ["grid-focus.ts"], + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = ["grid-focus.spec.ts"], + deps = [ + ":grid-focus", + "//:node_modules/@angular/core", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.spec.ts new file mode 100644 index 000000000000..70f661556a21 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.spec.ts @@ -0,0 +1,443 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed, Signal, signal, WritableSignal} from '@angular/core'; +import {GridFocus, GridFocusInputs, GridFocusCell, RowCol} from './grid-focus'; + +// Helper type for test cells, extending GridFocusCell +interface TestGridCell extends GridFocusCell { + id: WritableSignal; + element: WritableSignal; + disabled: WritableSignal; +} + +// Helper type for configuring GridFocus inputs in tests +type TestSetupInputs = Partial> & { + numRows?: number; + numCols?: number; + gridFocus?: WritableSignal | undefined>; +}; + +export function createTestCell( + gridFocus: Signal | undefined>, + opts: {id: string; rowspan?: number; colspan?: number}, +): TestGridCell { + const el = document.createElement('div'); + spyOn(el, 'focus').and.callThrough(); + let coordinates: Signal = signal({row: -1, col: -1}); + const cell: TestGridCell = { + id: signal(opts.id), + element: signal(el as HTMLElement), + disabled: signal(false), + rowspan: signal(opts.rowspan ?? 1), + colspan: signal(opts.rowspan ?? 1), + rowindex: signal(-1), + colindex: signal(-1), + }; + coordinates = computed(() => gridFocus()?.getCoordinates(cell) ?? {row: -1, col: -1}); + cell.rowindex = computed(() => coordinates().row); + cell.colindex = computed(() => coordinates().col); + return cell; +} + +export function createTestCells( + gridFocus: Signal | undefined>, + numRows: number, + numCols: number, +): WritableSignal { + return signal( + Array.from({length: numRows}).map((_, r) => + Array.from({length: numCols}).map((_, c) => { + return createTestCell(gridFocus, {id: `cell-${r}-${c}`}); + }), + ), + ); +} + +// Main helper function to instantiate GridFocus and its dependencies for testing +export function setupGridFocus(inputs: TestSetupInputs = {}): { + cells: TestGridCell[][]; + gridFocus: GridFocus; +} { + const numRows = inputs.numRows ?? 3; + const numCols = inputs.numCols ?? 3; + + const gridFocus = inputs.gridFocus ?? signal | undefined>(undefined); + const cells = inputs.cells ?? createTestCells(gridFocus, numRows, numCols); + + const activeCoords = inputs.activeCoords ?? signal({row: 0, col: 0}); + const focusMode = signal<'roving' | 'activedescendant'>( + inputs.focusMode ? inputs.focusMode() : 'roving', + ); + const disabled = signal(inputs.disabled ? inputs.disabled() : false); + const skipDisabled = signal(inputs.skipDisabled ? inputs.skipDisabled() : true); + + gridFocus.set( + new GridFocus({ + cells: cells, + activeCoords: activeCoords, + focusMode: focusMode, + disabled: disabled, + skipDisabled: skipDisabled, + }), + ); + + return { + cells: cells(), + gridFocus: gridFocus()!, + }; +} + +describe('GridFocus', () => { + describe('Initialization', () => { + it('should initialize with activeCell at {row: 0, col: 0} by default', () => { + const {gridFocus} = setupGridFocus(); + expect(gridFocus.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + + it('should compute activeCell based on activeCell', () => { + const {gridFocus, cells} = setupGridFocus({ + activeCoords: signal({row: 1, col: 1}), + }); + expect(gridFocus.activeCell()).toBe(cells[1][1]); + }); + + it('should compute activeCell correctly when rowspan and colspan are set', () => { + const activeCoords = signal({row: 0, col: 0}); + const gridFocusSignal = signal | undefined>(undefined); + + // Visualization of this irregular grid. + // + // +---+---+---+ + // | |0,2| + // + 0,0 +---+ + // | |1,2| + // +---+---+---+ + // + const cell_0_0 = createTestCell(gridFocusSignal, {id: `cell-0-0`, rowspan: 2, colspan: 2}); + const cell_0_2 = createTestCell(gridFocusSignal, {id: `cell-0-2`}); + const cell_1_2 = createTestCell(gridFocusSignal, {id: `cell-1-2`}); + const cells = signal([[cell_0_0, cell_0_2], [cell_1_2]]); + + const {gridFocus} = setupGridFocus({ + cells, + activeCoords, + gridFocus: gridFocusSignal, + }); + + activeCoords.set({row: 0, col: 0}); + expect(gridFocus.activeCell()).toBe(cell_0_0); + activeCoords.set({row: 0, col: 1}); + expect(gridFocus.activeCell()).toBe(cell_0_0); + activeCoords.set({row: 1, col: 0}); + expect(gridFocus.activeCell()).toBe(cell_0_0); + activeCoords.set({row: 1, col: 1}); + expect(gridFocus.activeCell()).toBe(cell_0_0); + + activeCoords.set({row: 0, col: 2}); + expect(gridFocus.activeCell()).toBe(cell_0_2); + + activeCoords.set({row: 1, col: 2}); + expect(gridFocus.activeCell()).toBe(cell_1_2); + }); + + it('should compute rowCount and colCount correctly', () => { + const {gridFocus} = setupGridFocus({ + numRows: 2, + numCols: 3, + }); + expect(gridFocus.rowCount()).toBe(2); + expect(gridFocus.colCount()).toBe(3); + }); + + it('should compute rowCount and colCount correctly when rowspan and colspan are set', () => { + const gridFocusSignal = signal | undefined>(undefined); + + // Visualization of this irregular grid. + // + // +---+---+---+ + // | |0,2| + // + 0,0 +---+ + // | |1,2| + // +---+---+---+ + // + const cell_0_0 = createTestCell(gridFocusSignal, {id: `cell-0-0`, rowspan: 2, colspan: 2}); + const cell_0_2 = createTestCell(gridFocusSignal, {id: `cell-0-2`}); + const cell_1_2 = createTestCell(gridFocusSignal, {id: `cell-1-2`}); + const cells = signal([[cell_0_0, cell_0_2], [cell_1_2]]); + + const {gridFocus} = setupGridFocus({ + cells, + gridFocus: gridFocusSignal, + }); + + expect(gridFocus.rowCount()).toBe(2); + expect(gridFocus.colCount()).toBe(3); + }); + }); + + describe('isGridDisabled', () => { + it('should return true if inputs.disabled is true', () => { + const {gridFocus} = setupGridFocus({disabled: signal(true)}); + expect(gridFocus.isGridDisabled()).toBeTrue(); + }); + + it('should return true if all cells are disabled', () => { + const {gridFocus, cells} = setupGridFocus({numRows: 2, numCols: 1}); + cells.forEach(row => row.forEach(cell => cell.disabled.set(true))); + expect(gridFocus.isGridDisabled()).toBeTrue(); + }); + + it('should return true if inputs.cells is empty', () => { + const {gridFocus} = setupGridFocus({numRows: 0, numCols: 0}); + expect(gridFocus.isGridDisabled()).toBeTrue(); + }); + + it('should return true if the grid contains only empty rows', () => { + const cells = signal([[], []]); + const {gridFocus} = setupGridFocus({cells: cells}); + expect(gridFocus.isGridDisabled()).toBeTrue(); + }); + }); + + describe('getActiveDescendant', () => { + it('should return undefined if focusMode is "roving"', () => { + const {gridFocus} = setupGridFocus({focusMode: signal('roving')}); + expect(gridFocus.getActiveDescendant()).toBeUndefined(); + }); + + it('should return undefined if the grid is disabled', () => { + const {gridFocus} = setupGridFocus({ + disabled: signal(true), + focusMode: signal('activedescendant'), + }); + expect(gridFocus.getActiveDescendant()).toBeUndefined(); + }); + + it('should return the activeCell id if focusMode is "activedescendant"', () => { + const {gridFocus, cells} = setupGridFocus({ + focusMode: signal('activedescendant'), + activeCoords: signal({row: 2, col: 2}), + }); + expect(gridFocus.getActiveDescendant()).toBe(cells[2][2].id()); + }); + }); + + describe('getGridTabindex', () => { + it('should return 0 if grid is disabled', () => { + const {gridFocus} = setupGridFocus({disabled: signal(true)}); + expect(gridFocus.getGridTabindex()).toBe(0); + }); + + it('should return -1 if focusMode is "roving" and grid is not disabled', () => { + const {gridFocus} = setupGridFocus({focusMode: signal('roving')}); + expect(gridFocus.getGridTabindex()).toBe(-1); + }); + + it('should return 0 if focusMode is "activedescendant" and grid is not disabled', () => { + const {gridFocus} = setupGridFocus({focusMode: signal('activedescendant')}); + expect(gridFocus.getGridTabindex()).toBe(0); + }); + }); + + describe('getCellTabindex', () => { + it('should return -1 if grid is disabled', () => { + const {gridFocus, cells} = setupGridFocus({ + numRows: 1, + numCols: 3, + disabled: signal(true), + }); + expect(gridFocus.getCellTabindex(cells[0][0])).toBe(-1); + expect(gridFocus.getCellTabindex(cells[0][1])).toBe(-1); + expect(gridFocus.getCellTabindex(cells[0][2])).toBe(-1); + }); + + it('should return -1 if focusMode is "activedescendant"', () => { + const {gridFocus, cells} = setupGridFocus({ + numRows: 1, + numCols: 3, + focusMode: signal('activedescendant'), + }); + expect(gridFocus.getCellTabindex(cells[0][0])).toBe(-1); + expect(gridFocus.getCellTabindex(cells[0][1])).toBe(-1); + expect(gridFocus.getCellTabindex(cells[0][2])).toBe(-1); + }); + + it('should return 0 if focusMode is "roving" and cell is the activeCell', () => { + const {gridFocus, cells} = setupGridFocus({ + numRows: 1, + numCols: 3, + focusMode: signal('roving'), + }); + + expect(gridFocus.getCellTabindex(cells[0][0])).toBe(0); + expect(gridFocus.getCellTabindex(cells[0][1])).toBe(-1); + expect(gridFocus.getCellTabindex(cells[0][2])).toBe(-1); + }); + }); + + describe('isFocusable', () => { + it('should return true if cell is not disabled', () => { + const {gridFocus, cells} = setupGridFocus({ + numRows: 1, + numCols: 3, + }); + expect(gridFocus.isFocusable(cells[0][0])).toBeTrue(); + expect(gridFocus.isFocusable(cells[0][1])).toBeTrue(); + expect(gridFocus.isFocusable(cells[0][2])).toBeTrue(); + }); + + it('should return false if cell is disabled and skipDisabled is true', () => { + const {gridFocus, cells} = setupGridFocus({ + numRows: 1, + numCols: 3, + skipDisabled: signal(true), + }); + cells[0][1].disabled.set(true); + expect(gridFocus.isFocusable(cells[0][0])).toBeTrue(); + expect(gridFocus.isFocusable(cells[0][1])).toBeFalse(); + expect(gridFocus.isFocusable(cells[0][2])).toBeTrue(); + }); + + it('should return true if cell is disabled but skipDisabled is false', () => { + const {gridFocus, cells} = setupGridFocus({ + numRows: 1, + numCols: 3, + skipDisabled: signal(false), + }); + cells[0][1].disabled.set(true); + expect(gridFocus.isFocusable(cells[0][0])).toBeTrue(); + expect(gridFocus.isFocusable(cells[0][1])).toBeTrue(); + expect(gridFocus.isFocusable(cells[0][2])).toBeTrue(); + }); + }); + + describe('focusCoordinates', () => { + it('should return false and not change state if grid is disabled', () => { + const activeCoords = signal({row: 0, col: 0}); + const {gridFocus, cells} = setupGridFocus({ + activeCoords, + disabled: signal(true), + }); + + const success = gridFocus.focusCoordinates({row: 1, col: 0}); + + expect(success).toBeFalse(); + expect(activeCoords()).toEqual({row: 0, col: 0}); + expect(cells[1][0].element().focus).not.toHaveBeenCalled(); + }); + + it('should return false and not change state if cell is not focusable', () => { + const activeCoords = signal({row: 0, col: 0}); + const {gridFocus, cells} = setupGridFocus({activeCoords}); + cells[1][0].disabled.set(true); + + const success = gridFocus.focusCoordinates({row: 1, col: 0}); + + expect(success).toBeFalse(); + expect(activeCoords()).toEqual({row: 0, col: 0}); + expect(cells[1][0].element().focus).not.toHaveBeenCalled(); + }); + + it('should focus cell, update activeCell and prevActiveCell in "roving" mode', () => { + const activeCoords = signal({row: 0, col: 0}); + const {gridFocus, cells} = setupGridFocus({ + activeCoords, + focusMode: signal('roving'), + }); + + const success = gridFocus.focusCoordinates({row: 1, col: 0}); + + expect(success).toBeTrue(); + expect(activeCoords()).toEqual({row: 1, col: 0}); + expect(cells[1][0].element().focus).toHaveBeenCalled(); + + expect(gridFocus.activeCell()).toBe(cells[1][0]); + expect(gridFocus.prevActiveCoords()).toEqual({row: 0, col: 0}); + }); + + it('should update activeCell and prevActiveCell but not call element.focus in "activedescendant" mode', () => { + const activeCoords = signal({row: 0, col: 0}); + const {gridFocus, cells} = setupGridFocus({ + activeCoords, + focusMode: signal('activedescendant'), + }); + + const success = gridFocus.focusCoordinates({row: 1, col: 0}); + + expect(success).toBeTrue(); + expect(activeCoords()).toEqual({row: 1, col: 0}); + expect(cells[1][0].element().focus).not.toHaveBeenCalled(); + + expect(gridFocus.activeCell()).toBe(cells[1][0]); + expect(gridFocus.prevActiveCoords()).toEqual({row: 0, col: 0}); + }); + }); + + describe('focusCell', () => { + it('should return false and not change state if grid is disabled', () => { + const activeCoords = signal({row: 0, col: 0}); + const {gridFocus, cells} = setupGridFocus({ + activeCoords, + disabled: signal(true), + }); + + const success = gridFocus.focusCell(cells[1][0]); + + expect(success).toBeFalse(); + expect(activeCoords()).toEqual({row: 0, col: 0}); + expect(cells[1][0].element().focus).not.toHaveBeenCalled(); + }); + + it('should return false and not change state if cell is not focusable', () => { + const activeCoords = signal({row: 0, col: 0}); + const {gridFocus, cells} = setupGridFocus({activeCoords}); + cells[1][0].disabled.set(true); + + const success = gridFocus.focusCell(cells[1][0]); + + expect(success).toBeFalse(); + expect(activeCoords()).toEqual({row: 0, col: 0}); + expect(cells[1][0].element().focus).not.toHaveBeenCalled(); + }); + + it('should focus cell, update activeCell and prevActiveCell in "roving" mode', () => { + const activeCoords = signal({row: 0, col: 0}); + const {gridFocus, cells} = setupGridFocus({ + activeCoords, + focusMode: signal('roving'), + }); + + const success = gridFocus.focusCell(cells[1][0]); + + expect(success).toBeTrue(); + expect(activeCoords()).toEqual({row: 1, col: 0}); + expect(cells[1][0].element().focus).toHaveBeenCalled(); + + expect(gridFocus.activeCell()).toBe(cells[1][0]); + expect(gridFocus.prevActiveCoords()).toEqual({row: 0, col: 0}); + }); + + it('should update activeCell and prevActiveCell but not call element.focus in "activedescendant" mode', () => { + const activeCoords = signal({row: 0, col: 0}); + const {gridFocus, cells} = setupGridFocus({ + activeCoords, + focusMode: signal('activedescendant'), + }); + + const success = gridFocus.focusCell(cells[1][0]); + + expect(success).toBeTrue(); + expect(activeCoords()).toEqual({row: 1, col: 0}); + expect(cells[1][0].element().focus).not.toHaveBeenCalled(); + + expect(gridFocus.activeCell()).toBe(cells[1][0]); + expect(gridFocus.prevActiveCoords()).toEqual({row: 0, col: 0}); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.ts b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.ts new file mode 100644 index 000000000000..bacb2b5d9daa --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-focus/grid-focus.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed, signal} from '@angular/core'; +import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; + +/** Represents an cell in a grid, such as a grid cell, that may receive focus. */ +export interface GridFocusCell { + /** A unique identifier for the cell. */ + id: SignalLike; + + /** The html element that should receive focus. */ + element: SignalLike; + + /** Whether an cell is disabled. */ + disabled: SignalLike; + + /** The number of rows the cell should span. Defaults to 1. */ + rowspan: SignalLike; + + /** The number of columns the cell should span. Defaults to 1. */ + colspan: SignalLike; + + /** The row index of the cell within the grid. */ + rowindex: SignalLike; + + /** The column index of the cell within the grid. */ + colindex: SignalLike; +} + +/** Represents the required inputs for a grid that contains focusable cells. */ +export interface GridFocusInputs { + /** The focus strategy used by the grid. */ + focusMode: SignalLike<'roving' | 'activedescendant'>; + + /** Whether the grid is disabled. */ + disabled: SignalLike; + + /** The cells in the grid, represented as a 2D array (rows and columns). */ + cells: SignalLike; + + /** The coordinates (row and column) of the current active cell. */ + activeCoords: WritableSignalLike; + + /** Whether disabled cells in the grid should be skipped when navigating. */ + skipDisabled: SignalLike; +} + +/** Represents coordinates in a grid. */ +export interface RowCol { + /** The row index. */ + row: number; + + /** The column index. */ + col: number; +} + +/** Controls focus for a 2D grid of cells. */ +export class GridFocus { + /** The last active cell coordinates. */ + prevActiveCoords = signal({row: 0, col: 0}); + + /** The current active cell based on `activeCoords` coordinates. */ + activeCell = computed(() => this.getCell(this.inputs.activeCoords())); + + /** The number of rows in the grid. */ + rowCount = computed(() => this.inputs.cells().length); + + /** The number of columns in the grid. */ + colCount = computed(() => { + return this.inputs.cells()[0].reduce((count, curr) => count + curr.colspan(), 0); + }); + + constructor(readonly inputs: GridFocusInputs) {} + + /** The id of the current active cell, for ARIA activedescendant. */ + getActiveDescendant(): string | undefined { + if (this.isGridDisabled() || this.inputs.focusMode() === 'roving') { + return undefined; + } + const currentActiveCell = this.activeCell(); + return currentActiveCell ? currentActiveCell.id() : undefined; + } + + /** Whether the grid is in a disabled state. */ + isGridDisabled(): boolean { + if (this.inputs.disabled()) { + return true; + } + const gridCells = this.inputs.cells(); + return gridCells.length === 0 || gridCells.every(row => row.every(cell => cell.disabled())); + } + + /** The tabindex for the grid container. */ + getGridTabindex(): -1 | 0 { + if (this.isGridDisabled()) { + return 0; + } + return this.inputs.focusMode() === 'activedescendant' ? 0 : -1; + } + + /** Returns the tabindex for the given grid cell cell. */ + getCellTabindex(cell: T): -1 | 0 { + if (this.isGridDisabled()) { + return -1; + } + if (this.inputs.focusMode() === 'activedescendant') { + return -1; + } + return this.activeCell() === cell ? 0 : -1; + } + + /** Focuses the given cell. */ + focusCell(cell: T): boolean { + if (this.isGridDisabled()) { + return false; + } + + if (!this.isFocusable(cell)) { + return false; + } + + this.prevActiveCoords.set(this.inputs.activeCoords()); + this.inputs.activeCoords.set({row: cell.rowindex(), col: cell.colindex()}); + this._focus(cell); + + return true; + } + + /** Moves focus to the cell at the given coordinates if it's part of a focusable cell. */ + focusCoordinates(coordinates: RowCol): boolean { + if (this.isGridDisabled()) { + return false; + } + + const cell = this.getCell(coordinates); + + if (!cell || !this.isFocusable(cell)) { + return false; + } + + this.prevActiveCoords.set(this.inputs.activeCoords()); + this.inputs.activeCoords.set(coordinates); + this._focus(cell); + + return true; + } + + /** Handles conditionally calling `focus` on the HTML element of the cell. */ + private _focus(cell: T) { + if (this.inputs.focusMode() === 'roving') { + const element = cell.element(); + if (element && typeof element.focus === 'function') { + element.focus(); + } + } + } + + /** Returns true if the given cell can be navigated to. */ + isFocusable(cell: T): boolean { + return !cell.disabled() || !this.inputs.skipDisabled(); + } + + /** Finds the top-left anchor coordinates of a given cell instance in the grid. */ + getCoordinates(cellToFind: T): RowCol | void { + const grid = this.inputs.cells(); + const occupiedCells = new Set(); + + for (let rowindex = 0; rowindex < grid.length; rowindex++) { + let colindex = 0; + const gridRow = grid[rowindex]; + + for (const gridCell of gridRow) { + // Skip past cells that are already taken. + while (occupiedCells.has(`${rowindex},${colindex}`)) { + colindex++; + } + + // Check if this is the cell we're looking for. + if (gridCell === cellToFind) { + return {row: rowindex, col: colindex}; + } + + const rowspan = gridCell.rowspan(); + const colspan = gridCell.colspan(); + + // If this cell spans multiple rows, mark those cells as occupied. + if (rowspan > 1) { + for (let rOffset = 1; rOffset < rowspan; rOffset++) { + const spannedRow = rowindex + rOffset; + for (let cOffset = 0; cOffset < colspan; cOffset++) { + const spannedCol = colindex + cOffset; + occupiedCells.add(`${spannedRow},${spannedCol}`); + } + } + } + + colindex += colspan; + } + } + } + + /** Gets the cell that covers the given coordinates, considering rowspan and colspan. */ + getCell(coords: RowCol): T | void { + for (const row of this.inputs.cells()) { + for (const cell of row) { + if ( + coords.row >= cell.rowindex() && + coords.row <= cell.rowindex() + cell.rowspan() - 1 && + coords.col >= cell.colindex() && + coords.col <= cell.colindex() + cell.colspan() - 1 + ) { + return cell; + } + } + } + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/BUILD.bazel new file mode 100644 index 000000000000..a9b146b2e295 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/BUILD.bazel @@ -0,0 +1,28 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "grid-navigation", + srcs = ["grid-navigation.ts"], + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/grid-focus", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = ["grid-navigation.spec.ts"], + deps = [ + ":grid-navigation", + "//:node_modules/@angular/core", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/grid-navigation.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/grid-navigation.spec.ts new file mode 100644 index 000000000000..6d48f217d17f --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/grid-navigation.spec.ts @@ -0,0 +1,1253 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed, signal, WritableSignal} from '@angular/core'; +import {GridFocus} from '../grid-focus/grid-focus'; +import {GridNavigation, GridNavigationCell, GridNavigationInputs} from './grid-navigation'; + +type TestGridNav = GridNavigation; + +interface TestCell extends GridNavigationCell { + disabled: WritableSignal; +} + +interface TestCellInputs { + rowspan?: number; + colspan?: number; +} + +function createCell(config?: TestCellInputs): TestCell { + const element = document.createElement('div'); + spyOn(element, 'focus').and.callThrough(); + + return { + id: signal(''), + element: signal(element), + disabled: signal(false), + rowindex: signal(0), + colindex: signal(0), + rowspan: signal(config?.rowspan ?? 1), + colspan: signal(config?.colspan ?? 1), + }; +} + +type TestGridNavInputs = Partial> & + Pick, 'cells'>; + +function createGridNav(config: TestGridNavInputs): {gridNav: TestGridNav; cells: TestCell[][]} { + const wrap = signal(true); + const disabled = signal(false); + const skipDisabled = signal(false); + const focusMode = signal('roving' as const); + const activeCoords = signal({row: 0, col: 0}); + const wrapBehavior = signal('continuous' as const); + + const gridFocus = new GridFocus({ + disabled, + focusMode, + activeCoords, + skipDisabled, + ...config, + }); + + const gridNav = new GridNavigation({ + wrap, + disabled, + focusMode, + activeCoords, + skipDisabled, + wrapBehavior, + gridFocus, + ...config, + }); + + for (const row of config.cells()) { + for (const cell of row) { + const coordinates = computed(() => gridFocus.getCoordinates(cell) ?? {row: -1, col: -1}); + cell.rowindex = computed(() => coordinates().row); + cell.colindex = computed(() => coordinates().col); + } + } + + return {gridNav, cells: config.cells()}; +} + +describe('GridNavigation', () => { + /** + * GRID A: + * ┌─────┬─────┬─────┐ + * │ 0,0 │ 0,1 │ 0,2 │ + * ├─────┼─────┼─────┤ + * │ 1,0 │ 1,1 │ 1,2 │ + * ├─────┼─────┼─────┤ + * │ 2,0 │ 2,1 │ 2,2 │ + * └─────┴─────┴─────┘ + */ + let gridA = signal([]); + + /** + * GRID B: + * ┌─────┬─────┬─────┐ + * │ 0,0 │ 0,1 │ 0,2 │ + * ├─────┼─────┤ │ + * │ 1,0 │ 1,1 │ │ + * ├─────┤ ├─────┤ + * │ 2,0 │ │ 2,2 │ + * │ ├─────┼─────┤ + * │ │ 3,1 │ 3,2 │ + * └─────┴─────┴─────┘ + */ + let gridB = signal([]); + + /** + * GRID C: + * ┌───────────┬─────┬─────┐ + * │ 0,0 │ 0,2 │ 0,3 │ + * ├─────┬─────┴─────┼─────┤ + * │ 1,0 │ 1,1 │ 1,3 │ + * ├─────┼─────┬─────┴─────┤ + * │ 2,0 │ 2,1 │ 2,2 │ + * └─────┴─────┴───────────┘ + */ + let gridC = signal([]); + + /** + * GRID D: + * ┌─────┬───────────┬─────┐ + * │ 0,0 │ 0,1 │ 0,3 │ + * │ ├───────────┼─────┤ + * │ │ 1,1 │ 1,3 │ + * ├─────┤ │ │ + * │ 2,0 │ │ │ + * ├─────┼─────┬─────┴─────┤ + * │ 3,0 │ 3,1 │ 3,2 │ + * └─────┴─────┴───────────┘ + */ + let gridD = signal([]); + + beforeEach(() => { + gridA.set([ + [createCell(), createCell(), createCell()], + [createCell(), createCell(), createCell()], + [createCell(), createCell(), createCell()], + ]); + + gridB.set([ + [createCell(), createCell(), createCell({rowspan: 2})], + [createCell(), createCell({rowspan: 2})], + [createCell({rowspan: 2}), createCell()], + [createCell(), createCell()], + ]); + + gridC.set([ + [createCell({colspan: 2}), createCell(), createCell()], + [createCell(), createCell({colspan: 2}), createCell()], + [createCell(), createCell(), createCell({colspan: 2})], + ]); + + gridD.set([ + [createCell({rowspan: 2}), createCell({colspan: 2}), createCell()], + [createCell({rowspan: 2, colspan: 2}), createCell({rowspan: 2})], + [createCell()], + [createCell(), createCell(), createCell({colspan: 2})], + ]); + }); + + describe('up()', () => { + it('should navigate up', () => { + const {gridNav} = createGridNav({ + cells: gridA, + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.up(); + expect(result).toBeTrue(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('(wrap: false) should not wrap', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(false), + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('(skip disabled: false) should be able to navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(false), + activeCoords: signal({row: 1, col: 1}), + }); + cells[0][1].disabled.set(true); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(true), + activeCoords: signal({row: 2, col: 1}), + }); + cells[1][1].disabled.set(true); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('(wrap: false) (skip disabled: true) should not navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(false), + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + cells[0][1].disabled.set(true); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('(disabled: true) should not navigate', () => { + const {gridNav} = createGridNav({ + cells: gridA, + disabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.up(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + describe('(wrap: true)', () => { + describe('(wrap behavior: loop)', () => { + it('should loop to the last cell of the current column', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 0, col: 1}), + }); + cells[2][1].disabled.set(true); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 0, col: 1}), + }); + cells[1][1].disabled.set(true); + cells[2][1].disabled.set(true); + const result = gridNav.up(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + }); + + describe('(wrap behavior: continuous)', () => { + it('should wrap to the last cell of the previous column', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + }); + + it('should wrap to the last cell of the last column', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 0, col: 0}), + }); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 0, col: 1}), + }); + + cells[0][0].disabled.set(true); + cells[1][0].disabled.set(true); + cells[2][0].disabled.set(true); + + cells[1][1].disabled.set(true); + cells[2][1].disabled.set(true); + + cells[0][2].disabled.set(true); + cells[2][2].disabled.set(true); + + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 0, col: 1}), + }); + + cells[0][0].disabled.set(true); + cells[1][0].disabled.set(true); + cells[2][0].disabled.set(true); + + cells[1][1].disabled.set(true); + cells[2][1].disabled.set(true); + + cells[0][2].disabled.set(true); + cells[0][1].disabled.set(true); + cells[2][2].disabled.set(true); + + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 1}), + }); + cells[0][0].disabled.set(true); + cells[1][0].disabled.set(true); + cells[2][0].disabled.set(true); + cells[0][1].disabled.set(true); + cells[2][1].disabled.set(true); + cells[0][2].disabled.set(true); + cells[1][2].disabled.set(true); + cells[2][2].disabled.set(true); + + const result = gridNav.up(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + }); + }); + + describe('with rowspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridB, + activeCoords: signal({row: 3, col: 2}), + }); + + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 2}); + }); + + it('should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridB, + activeCoords: signal({row: 3, col: 0}), + }); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridB, + skipDisabled: signal(true), + activeCoords: signal({row: 2, col: 2}), + }); + cells[0][2].disabled.set(true); + cells[2][0].disabled.set(true); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('(wrap: false) should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridB, + wrap: signal(false), + activeCoords: signal({row: 1, col: 2}), + }); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + }); + }); + + describe('with colspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridC, + activeCoords: signal({row: 2, col: 3}), + }); + + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 3}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 3}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 3}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridC, + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 2}), + }); + cells[0][0].disabled.set(true); + cells[0][1].disabled.set(true); + cells[2][1].disabled.set(true); + + const result = gridNav.up(); + expect(result).toBeTrue(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 3}); + }); + }); + + describe('with rowspan and colspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridD, + activeCoords: signal({row: 3, col: 3}), + }); + + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 3}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 3}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 3}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridD, + skipDisabled: signal(true), + activeCoords: signal({row: 3, col: 3}), + }); + + cells[1][0].disabled.set(true); + cells[1][1].disabled.set(true); + + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 3}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 3, col: 1}); + gridNav.up(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + }); + }); + + describe('down()', () => { + it('should navigate down', () => { + const {gridNav} = createGridNav({ + cells: gridA, + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.down(); + expect(result).toBeTrue(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + }); + + it('(wrap: false) should not wrap', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(false), + activeCoords: signal({row: 2, col: 1}), + }); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + }); + + it('(skip disabled: false) should be able to navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(false), + activeCoords: signal({row: 1, col: 1}), + }); + cells[2][1].disabled.set(true); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(true), + activeCoords: signal({row: 0, col: 1}), + }); + cells[1][1].disabled.set(true); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + }); + + it('(wrap: false) (skip disabled: true) should not navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(false), + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + cells[2][1].disabled.set(true); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('(disabled: true) should not navigate', () => { + const {gridNav} = createGridNav({ + cells: gridA, + disabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.down(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + describe('(wrap: true)', () => { + describe('(wrap behavior: loop)', () => { + it('should loop to the first cell of the current column', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 2, col: 1}), + }); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 2, col: 1}), + }); + cells[0][1].disabled.set(true); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 2, col: 1}), + }); + cells[0][1].disabled.set(true); + cells[1][1].disabled.set(true); + const result = gridNav.down(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 1}); + }); + }); + + describe('(wrap behavior: continuous)', () => { + it('should wrap to the first cell of the next column', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 2, col: 1}), + }); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 2, col: 1}), + }); + + cells[0][2].disabled.set(true); + cells[1][2].disabled.set(true); + + gridNav.down(); + // Should land on (2,2) + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 1}), + }); + cells[0][0].disabled.set(true); + cells[1][0].disabled.set(true); + cells[2][0].disabled.set(true); + cells[0][1].disabled.set(true); + cells[2][1].disabled.set(true); + cells[0][2].disabled.set(true); + cells[1][2].disabled.set(true); + cells[2][2].disabled.set(true); + + const result = gridNav.down(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should wrap to the first cell of the first column', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 2, col: 2}), + }); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + }); + }); + + describe('with rowspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridB, + activeCoords: signal({row: 0, col: 0}), + }); + + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridB, + activeCoords: signal({row: 1, col: 2}), + }); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridB, + skipDisabled: signal(true), + activeCoords: signal({row: 0, col: 0}), + }); + cells[1][0].disabled.set(true); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + + it('(wrap: false) should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridB, + wrap: signal(false), + activeCoords: signal({row: 0, col: 2}), + }); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + }); + + describe('with colspan set', () => { + // For `down()`, colspan doesn't affect vertical navigation as much as rowspan. + // Basic navigation should still work. + it('should navigate correctly', () => { + const {gridNav} = createGridNav({cells: gridC, activeCoords: signal({row: 0, col: 0})}); + gridNav.down(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + }); + }); + + describe('left()', () => { + it('should navigate left', () => { + const {gridNav} = createGridNav({ + cells: gridA, + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.left(); + expect(result).toBeTrue(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('(wrap: false) should not wrap', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(false), + activeCoords: signal({row: 1, col: 0}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('(skip disabled: false) should be able to navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(false), + activeCoords: signal({row: 1, col: 1}), + }); + cells[1][0].disabled.set(true); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 2}), + }); + cells[1][1].disabled.set(true); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('(wrap: false) (skip disabled: true) should not navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(false), + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + cells[1][0].disabled.set(true); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('(disabled: true) should not navigate', () => { + const {gridNav} = createGridNav({ + cells: gridA, + disabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.left(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + describe('(wrap: true)', () => { + describe('(wrap behavior: loop)', () => { + it('should loop to the last cell of the current row', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 1, col: 0}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 1, col: 0}), + }); + cells[1][2].disabled.set(true); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 1, col: 0}), + }); + cells[1][2].disabled.set(true); + cells[1][1].disabled.set(true); + const result = gridNav.left(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + }); + + describe('(wrap behavior: continuous)', () => { + it('should wrap to the last cell of the previous row', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 0}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 0}), + }); + + cells[0][2].disabled.set(true); + cells[0][1].disabled.set(true); + + gridNav.left(); + // Should land on (0,0) + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 1}), + }); + cells[0][0].disabled.set(true); + cells[1][0].disabled.set(true); + cells[2][0].disabled.set(true); + cells[0][1].disabled.set(true); + cells[2][1].disabled.set(true); + cells[0][2].disabled.set(true); + cells[1][2].disabled.set(true); + cells[2][2].disabled.set(true); + const result = gridNav.left(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should wrap to the last cell of the last row', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 0, col: 0}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + }); + }); + + describe('with rowspan set', () => { + // For `left()`, rowspan doesn't affect horizontal navigation as much as colspan. + // Basic navigation should still work. + it('should navigate correctly', () => { + const {gridNav} = createGridNav({cells: gridB, activeCoords: signal({row: 0, col: 1})}); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + }); + + describe('with colspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridC, + activeCoords: signal({row: 0, col: 3}), + }); + + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + + it('should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridC, + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridC, + skipDisabled: signal(true), + activeCoords: signal({row: 0, col: 3}), + }); + + cells[0][1].disabled.set(true); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + + it('(wrap: false) should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridC, + wrap: signal(false), + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + }); + + describe('with rowspan and colspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridD, + activeCoords: signal({row: 0, col: 3}), + }); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + gridNav.left(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + }); + }); + + describe('right()', () => { + it('should navigate right', () => { + const {gridNav} = createGridNav({ + cells: gridA, + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.right(); + expect(result).toBeTrue(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('(wrap: false) should not wrap', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(false), + activeCoords: signal({row: 1, col: 2}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('(skip disabled: false) should be able to navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(false), + activeCoords: signal({row: 1, col: 1}), + }); + cells[1][2].disabled.set(true); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 0}), + }); + cells[1][1].disabled.set(true); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + + it('(wrap: false) (skip disabled: true) should not navigate through disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(false), + skipDisabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + cells[1][2].disabled.set(true); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('(disabled: true) should not navigate', () => { + const {gridNav} = createGridNav({ + cells: gridA, + disabled: signal(true), + activeCoords: signal({row: 1, col: 1}), + }); + const result = gridNav.right(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + describe('(wrap: true)', () => { + describe('(wrap behavior: loop)', () => { + it('should loop to the first cell of the current row', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 1, col: 2}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 1, col: 2}), + }); + cells[1][0].disabled.set(true); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('loop'), + activeCoords: signal({row: 1, col: 2}), + }); + cells[1][0].disabled.set(true); + cells[1][1].disabled.set(true); + const result = gridNav.right(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 2}); + }); + }); + + describe('(wrap behavior: continuous)', () => { + it('should wrap to the first cell of the next row', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 2}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 0}); + }); + + it('should wrap until it finds a cell that is focusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 2}), + }); + + cells[2][0].disabled.set(true); + cells[2][1].disabled.set(true); + + gridNav.right(); + // Should land on (2,2) + expect(gridNav.inputs.activeCoords()).toEqual({row: 2, col: 2}); + }); + + it('should not navigate if all cells that would be navigated to are unfocusable', () => { + const {gridNav, cells} = createGridNav({ + cells: gridA, + wrap: signal(true), + skipDisabled: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 1, col: 1}), + }); + cells[0][0].disabled.set(true); + cells[1][0].disabled.set(true); + cells[2][0].disabled.set(true); + cells[0][1].disabled.set(true); + cells[2][1].disabled.set(true); + cells[0][2].disabled.set(true); + cells[1][2].disabled.set(true); + cells[2][2].disabled.set(true); + const result = gridNav.right(); + expect(result).toBeFalse(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 1}); + }); + + it('should wrap to the first cell of the first row', () => { + const {gridNav} = createGridNav({ + cells: gridA, + wrap: signal(true), + wrapBehavior: signal('continuous'), + activeCoords: signal({row: 2, col: 2}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 0}); + }); + }); + }); + + describe('with rowspan set', () => { + // For `right()`, rowspan doesn't affect horizontal navigation as much as colspan. + // Basic navigation should still work. + it('should navigate correctly', () => { + const {gridNav} = createGridNav({cells: gridB, activeCoords: signal({row: 0, col: 0})}); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + }); + }); + + describe('with colspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridC, + activeCoords: signal({row: 0, col: 0}), + }); + + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 3}); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 1, col: 0}); + }); + + it('should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridC, + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + }); + + it('(skip disabled: true) should skip disabled cells', () => { + const {gridNav, cells} = createGridNav({ + cells: gridC, + skipDisabled: signal(true), + activeCoords: signal({row: 0, col: 0}), + }); + cells[0][1].disabled.set(true); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 3}); + }); + + it('(wrap: false) should navigate correctly when in a subcoordinate of a cell', () => { + const {gridNav} = createGridNav({ + cells: gridC, + wrap: signal(false), + activeCoords: signal({row: 0, col: 1}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 2}); + }); + }); + + describe('with rowspan and colspan set', () => { + it('should navigate correctly', () => { + const {gridNav} = createGridNav({ + cells: gridD, + activeCoords: signal({row: 0, col: 0}), + }); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 1}); + gridNav.right(); + expect(gridNav.inputs.activeCoords()).toEqual({row: 0, col: 3}); + }); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/grid-navigation.ts b/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/grid-navigation.ts new file mode 100644 index 000000000000..62cce7975bcf --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/grid-navigation/grid-navigation.ts @@ -0,0 +1,158 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {SignalLike} from '../signal-like/signal-like'; +import {GridFocus, GridFocusCell, GridFocusInputs, RowCol} from '../grid-focus/grid-focus'; +import {computed} from '@angular/core'; + +/** Represents an item in a collection, such as a listbox option, than can be navigated to. */ +export interface GridNavigationCell extends GridFocusCell {} + +/** Represents the required inputs for a collection that has navigable items. */ +export interface GridNavigationInputs extends GridFocusInputs { + gridFocus: GridFocus; + wrap: SignalLike; + wrapBehavior: SignalLike<'continuous' | 'loop'>; +} + +/** Controls navigation for a grid of items. */ +export class GridNavigation { + rowcount = computed(() => this.inputs.gridFocus.rowCount()); + colcount = computed(() => this.inputs.gridFocus.colCount()); + + constructor(readonly inputs: GridNavigationInputs) {} + + /** Navigates to the given item. */ + gotoCell(cell?: T): boolean { + return cell ? this.inputs.gridFocus.focusCell(cell) : false; + } + + /** Navigates to the given coordinates. */ + gotoCoords(coords: RowCol): boolean { + return this.inputs.gridFocus.focusCoordinates(coords); + } + + /** Navigates to the item above the current item. */ + up(): boolean { + return this._advance((cell: T, {col}: RowCol) => { + const rowindex = cell.rowindex(); + const isRowWrapping = this.inputs.wrap() && rowindex - 1 < 0; + const isColumnWrapping = isRowWrapping && this.inputs.wrapBehavior() === 'continuous'; + + const nextCoords = { + row: isRowWrapping + ? (rowindex - 1 + this.rowcount()) % this.rowcount() + : Math.max(rowindex - 1, 0), + col: isColumnWrapping ? (col - 1 + this.colcount()) % this.colcount() : col, + }; + + const nextCell = this.inputs.gridFocus.getCell(nextCoords)!; + + return { + row: nextCell.rowindex(), + col: nextCoords.col, + }; + }); + } + + /** Navigates to the item below the current item. */ + down(): boolean { + return this._advance((cell: T, {col}: RowCol) => { + const rowspan = cell.rowspan(); + const rowindex = cell.rowindex(); + const isRowWrapping = this.inputs.wrap() && rowindex + rowspan >= this.rowcount(); + const isColumnWrapping = isRowWrapping && this.inputs.wrapBehavior() === 'continuous'; + + return { + row: isRowWrapping + ? (rowindex + rowspan) % this.rowcount() + : Math.min(rowindex + rowspan, this.rowcount() - 1), + col: isColumnWrapping ? (col + 1 + this.colcount()) % this.colcount() : col, + }; + }); + } + + /** Navigates to the item to the left of the current item. */ + left(): boolean { + return this._advance((cell: T, {row, col}: RowCol) => { + const colindex = cell.colindex(); + const isColumnWrapping = this.inputs.wrap() && colindex - 1 < 0; + const isRowWrapping = isColumnWrapping && this.inputs.wrapBehavior() === 'continuous'; + + const nextCoords = { + row: isRowWrapping ? (row - 1 + this.rowcount()) % this.rowcount() : row, + col: isColumnWrapping + ? (colindex - 1 + this.colcount()) % this.colcount() + : Math.max(colindex - 1, 0), + }; + + const nextCell = this.inputs.gridFocus.getCell(nextCoords)!; + + return { + row: nextCoords.row, + col: nextCell.colindex(), + }; + }); + } + + /** Navigates to the item to the right of the current item. */ + right(): boolean { + return this._advance((cell: T, {row}: RowCol) => { + const colspan = cell.colspan(); + const colindex = cell.colindex(); + const isColumnWrapping = this.inputs.wrap() && colindex + colspan >= this.colcount(); + const isRowWrapping = isColumnWrapping && this.inputs.wrapBehavior() === 'continuous'; + + return { + row: isRowWrapping ? (row + 1 + this.rowcount()) % this.rowcount() : row, + col: isColumnWrapping + ? (colindex + colspan + this.colcount()) % this.colcount() + : Math.min(colindex + colspan, this.colcount() - 1), + }; + }); + } + + /** + * Continuously calls the given stepFn starting at the given coordinates + * until either a new focusable cell is reached or the grid fully loops. + */ + private _advance(stepFn: (cell: T, coords: RowCol) => RowCol) { + const startCoords = this.inputs.activeCoords(); + let prevCoords = {row: startCoords.row, col: startCoords.col}; + let nextCoords = {row: startCoords.row, col: startCoords.col}; + let nextCell = this.inputs.gridFocus.activeCell()!; + + while (true) { + prevCoords = {row: nextCoords.row, col: nextCoords.col}; + nextCoords = stepFn(nextCell, nextCoords); + + // The step did not result in any change in coordinates. + // + // This will happen if the user is at a boundary (start/end row or col) + // and tries to advance past it while `wrap` is false. + if (nextCoords.row === prevCoords.row && nextCoords.col === prevCoords.col) { + return false; + } + + // The step has resulted in arriving back to the original coordinates. + // + // This will happen if the other cells in the grid are unfocusable and `wrap` + // is true. The `stepFn` will eventually loop all the way back to the original cells. + if (nextCoords.row === startCoords.row && nextCoords.col === startCoords.col) { + return false; + } + + nextCell = this.inputs.gridFocus.getCell(nextCoords)!; + + // The `stepFn` has successfully reached a cell that is focusable. + if (this.inputs.gridFocus.isFocusable(nextCell)) { + return this.gotoCoords(nextCoords); + } + } + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts index aceb03718cd2..aab64dbf3970 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts @@ -219,6 +219,34 @@ describe('List Selection', () => { selection.selectOne(); // [0] expect(selection.inputs.value()).toEqual([0]); }); + + it('should do nothing if the current active item is disabled', () => { + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; + + selection.inputs.focusManager.focus(items[1]); + selection.select(); + expect(selection.inputs.value()).toEqual([1]); + + selection.inputs.focusManager.focus(items[0]); + items[0].disabled.set(true); + selection.selectOne(); + expect(selection.inputs.value()).toEqual([1]); + }); + + it('should not select an item if the list is not multiselectable and not all items are deselected', () => { + const selection = getSelection({multi: signal(false)}); + const items = selection.inputs.items() as TestItem[]; + + selection.inputs.focusManager.focus(items[1]); + selection.select(); + expect(selection.inputs.value()).toEqual([1]); + + items[1].disabled.set(true); + selection.inputs.focusManager.focus(items[2]); + selection.selectOne(); + expect(selection.inputs.value()).toEqual([1]); + }); }); describe('#selectRange', () => { diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts index 639c292c93d4..339759cd3f93 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {signal} from '@angular/core'; +import {computed, signal} from '@angular/core'; import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus'; @@ -36,6 +36,11 @@ export class ListSelection, V> { /** The end index to use for range selection. */ rangeEndIndex = signal(0); + /** The currently selected items. */ + selectedItems = computed(() => + this.inputs.items().filter(item => this.inputs.value().includes(item.value())), + ); + constructor(readonly inputs: ListSelectionInputs & {focusManager: ListFocus}) {} /** Selects the item at the current active index. */ @@ -115,7 +120,16 @@ export class ListSelection, V> { /** Sets the selection to only the current active item. */ selectOne() { + if (this.inputs.focusManager.activeItem().disabled()) { + return; + } + this.deselectAll(); + + if (this.inputs.value().length > 0 && !this.inputs.multi()) { + return; + } + this.select(); } diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts index 615de0149069..fb80cf822d3e 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts @@ -549,7 +549,7 @@ describe('Listbox Pattern', () => { listbox.onKeydown(down()); expect(listbox.inputs.value()).toEqual(['Apricot']); listbox.onKeydown(down()); - expect(listbox.inputs.value()).toEqual([]); + expect(listbox.inputs.value()).toEqual(['Apricot']); listbox.onKeydown(down()); expect(listbox.inputs.value()).toEqual(['Blackberry']); }); diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 4b85499ffc32..b15657af095b 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -237,6 +237,28 @@ export class ListboxPattern { }); } + /** Returns a set of violations */ + validate(): string[] { + const violations: string[] = []; + + if (!this.inputs.multi() && this.inputs.value().length > 1) { + violations.push( + `A single-select listbox should not have multiple selected options. Selected options: ${this.inputs.value().join(', ')}`, + ); + } + + if ( + this.inputs.items.length && + (this.inputs.activeIndex() < 0 || this.inputs.activeIndex() >= this.inputs.items().length) + ) { + violations.push( + `The active index is out of bounds. Number of options: ${this.inputs.items().length} Active index: ${this.inputs.activeIndex()}.`, + ); + } + + return violations; + } + /** Handles keydown events for the listbox. */ onKeydown(event: KeyboardEvent) { if (!this.disabled()) { diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index 9b11949ed77c..79f121e9f1e1 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -8,5 +8,8 @@ export * from './listbox/listbox'; export * from './listbox/option'; +export * from './radio-group/radio-group'; +export * from './radio-group/radio-button'; export * from './behaviors/signal-like/signal-like'; export * from './tabs/tabs'; +export * from './accordion/accordion'; diff --git a/src/cdk-experimental/ui-patterns/radio-group/BUILD.bazel b/src/cdk-experimental/ui-patterns/radio-group/BUILD.bazel new file mode 100644 index 000000000000..34bd1b4a6168 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio-group/BUILD.bazel @@ -0,0 +1,36 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "radio-group", + srcs = [ + "radio-button.ts", + "radio-group.ts", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/event-manager", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "//src/cdk-experimental/ui-patterns/behaviors/list-selection", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":radio-group", + "//:node_modules/@angular/core", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts new file mode 100644 index 000000000000..f9d7c2f9724f --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed} from '@angular/core'; +import {ListSelection, ListSelectionItem} from '../behaviors/list-selection/list-selection'; +import {ListNavigation, ListNavigationItem} from '../behaviors/list-navigation/list-navigation'; +import {ListFocus, ListFocusItem} from '../behaviors/list-focus/list-focus'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; + +/** + * Represents the properties exposed by a radio group that need to be accessed by a radio button. + * This exists to avoid circular dependency errors between the radio group and radio button. + */ +interface RadioGroupLike { + focusManager: ListFocus>; + selection: ListSelection, V>; + navigation: ListNavigation>; +} + +/** Represents the required inputs for a radio button in a radio group. */ +export interface RadioButtonInputs + extends ListNavigationItem, + ListSelectionItem, + ListFocusItem { + /** A reference to the parent radio group. */ + group: SignalLike | undefined>; +} + +/** Represents a radio button within a radio group. */ +export class RadioButtonPattern { + /** A unique identifier for the radio button. */ + id: SignalLike; + + /** The value associated with the radio button. */ + value: SignalLike; + + /** The position of the radio button within the group. */ + index = computed( + () => + this.group() + ?.navigation.inputs.items() + .findIndex(i => i.id() === this.id()) ?? -1, + ); + + /** Whether the radio button is currently the active one (focused). */ + active = computed(() => this.group()?.focusManager.activeItem() === this); + + /** Whether the radio button is selected. */ + selected: SignalLike = computed( + () => !!this.group()?.selection.inputs.value().includes(this.value()), + ); + + /** Whether the radio button is disabled. */ + disabled: SignalLike; + + /** A reference to the parent radio group. */ + group: SignalLike | undefined>; + + /** The tabindex of the radio button. */ + tabindex = computed(() => this.group()?.focusManager.getItemTabindex(this)); + + /** The HTML element associated with the radio button. */ + element: SignalLike; + + constructor(readonly inputs: RadioButtonInputs) { + this.id = inputs.id; + this.value = inputs.value; + this.group = inputs.group; + this.element = inputs.element; + this.disabled = inputs.disabled; + } +} diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts new file mode 100644 index 000000000000..2317769d3f07 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts @@ -0,0 +1,230 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed} from '@angular/core'; +import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; +import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; +import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus'; +import {ListNavigation, ListNavigationInputs} from '../behaviors/list-navigation/list-navigation'; +import {ListSelection, ListSelectionInputs} from '../behaviors/list-selection/list-selection'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {RadioButtonPattern} from './radio-button'; + +/** The selection operations that the radio group can perform. */ +interface SelectOptions { + selectOne?: boolean; +} + +/** Represents the required inputs for a radio group. */ +export type RadioGroupInputs = Omit>, 'wrap'> & + // Radio groups are always single-select. + Omit, V>, 'multi' | 'selectionMode'> & + ListFocusInputs> & { + /** Whether the radio group is disabled. */ + disabled: SignalLike; + /** Whether the radio group is readonly. */ + readonly: SignalLike; + }; + +/** Controls the state of a radio group. */ +export class RadioGroupPattern { + /** Controls navigation for the radio group. */ + navigation: ListNavigation>; + + /** Controls selection for the radio group. */ + selection: ListSelection, V>; + + /** Controls focus for the radio group. */ + focusManager: ListFocus>; + + /** Whether the radio group is vertically or horizontally oriented. */ + orientation: SignalLike<'vertical' | 'horizontal'>; + + /** Whether the radio group is disabled. */ + disabled = computed(() => this.inputs.disabled() || this.focusManager.isListDisabled()); + + /** The currently selected radio button. */ + selectedItem = computed(() => this.selection.selectedItems()[0]); + + /** Whether the radio group is readonly. */ + readonly = computed(() => this.selectedItem()?.disabled() || this.inputs.readonly()); + + /** The tabindex of the radio group (if using activedescendant). */ + tabindex = computed(() => this.focusManager.getListTabindex()); + + /** The id of the current active radio button (if using activedescendant). */ + activedescendant = computed(() => this.focusManager.getActiveDescendant()); + + /** The key used to navigate to the previous radio button. */ + prevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowUp'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key used to navigate to the next radio button. */ + nextKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowDown'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The keydown event manager for the radio group. */ + keydown = computed(() => { + const manager = new KeyboardEventManager(); + + // Readonly mode allows navigation but not selection changes. + if (this.readonly()) { + return manager + .on(this.prevKey, () => this.prev()) + .on(this.nextKey, () => this.next()) + .on('Home', () => this.first()) + .on('End', () => this.last()); + } + + // Default behavior: navigate and select on arrow keys, home, end. + // Space/Enter also select the focused item. + return manager + .on(this.prevKey, () => this.prev({selectOne: true})) + .on(this.nextKey, () => this.next({selectOne: true})) + .on('Home', () => this.first({selectOne: true})) + .on('End', () => this.last({selectOne: true})) + .on(' ', () => this.selection.selectOne()) + .on('Enter', () => this.selection.selectOne()); + }); + + /** The pointerdown event manager for the radio group. */ + pointerdown = computed(() => { + const manager = new PointerEventManager(); + + if (this.readonly()) { + // Navigate focus only in readonly mode. + return manager.on(e => this.goto(e)); + } + + // Default behavior: navigate and select on click. + return manager.on(e => this.goto(e, {selectOne: true})); + }); + + constructor(readonly inputs: RadioGroupInputs) { + this.orientation = inputs.orientation; + + this.focusManager = new ListFocus(inputs); + this.navigation = new ListNavigation({ + ...inputs, + wrap: () => false, + focusManager: this.focusManager, + }); + this.selection = new ListSelection({ + ...inputs, + multi: () => false, + selectionMode: () => 'follow', + focusManager: this.focusManager, + }); + } + + /** Handles keydown events for the radio group. */ + onKeydown(event: KeyboardEvent) { + if (!this.disabled()) { + this.keydown().handle(event); + } + } + + /** Handles pointerdown events for the radio group. */ + onPointerdown(event: PointerEvent) { + if (!this.disabled()) { + this.pointerdown().handle(event); + } + } + + /** Navigates to the first enabled radio button in the group. */ + first(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.first()); + } + + /** Navigates to the last enabled radio button in the group. */ + last(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.last()); + } + + /** Navigates to the next enabled radio button in the group. */ + next(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.next()); + } + + /** Navigates to the previous enabled radio button in the group. */ + prev(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.prev()); + } + + /** Navigates to the radio button associated with the given pointer event. */ + goto(event: PointerEvent, opts?: SelectOptions) { + const item = this._getItem(event); + this._navigate(opts, () => this.navigation.goto(item)); + } + + /** + * Sets the radio group to its default initial state. + * + * Sets the active index to the selected radio button if one exists and is focusable. + * Otherwise, sets the active index to the first focusable radio button. + */ + setDefaultState() { + let firstItem: RadioButtonPattern | null = null; + + for (const item of this.inputs.items()) { + if (this.focusManager.isFocusable(item)) { + if (!firstItem) { + firstItem = item; + } + if (item.selected()) { + this.inputs.activeIndex.set(item.index()); + return; + } + } + } + + if (firstItem) { + this.inputs.activeIndex.set(firstItem.index()); + } + } + + /** Validates the state of the radio group and returns a list of accessibility violations. */ + validate(): string[] { + const violations: string[] = []; + + if (this.selectedItem()?.disabled() && this.inputs.skipDisabled()) { + violations.push( + "Accessibility Violation: The selected radio button is disabled while 'skipDisabled' is true, making the selection unreachable via keyboard.", + ); + } + + return violations; + } + + /** Safely performs a navigation operation and updates selection if needed. */ + private _navigate(opts: SelectOptions = {}, operation: () => boolean) { + const moved = operation(); + if (moved && opts.selectOne) { + this.selection.selectOne(); + } + } + + /** Finds the RadioButtonPattern associated with a pointer event target. */ + private _getItem(e: PointerEvent): RadioButtonPattern | undefined { + if (!(e.target instanceof HTMLElement)) { + return undefined; + } + + // Assumes the target or its ancestor has role="radio" + const element = e.target.closest('[role="radio"]'); + return this.inputs.items().find(i => i.element() === element); + } +} diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts b/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts new file mode 100644 index 000000000000..beed688c0ded --- /dev/null +++ b/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts @@ -0,0 +1,313 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {signal, WritableSignal} from '@angular/core'; +import {RadioGroupInputs, RadioGroupPattern} from './radio-group'; +import {RadioButtonPattern} from './radio-button'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; +import {ModifierKeys} from '@angular/cdk/testing'; + +type TestInputs = RadioGroupInputs; +type TestRadio = RadioButtonPattern & { + disabled: WritableSignal; +}; +type TestRadioGroup = RadioGroupPattern; + +const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods); +const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods); +const left = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 37, 'ArrowLeft', mods); +const right = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 39, 'ArrowRight', mods); +const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', mods); +const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods); +const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods); +const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods); + +describe('RadioGroup Pattern', () => { + function getRadioGroup(inputs: Partial & Pick) { + return new RadioGroupPattern({ + items: inputs.items, + value: inputs.value ?? signal([]), + activeIndex: inputs.activeIndex ?? signal(0), + readonly: inputs.readonly ?? signal(false), + disabled: inputs.disabled ?? signal(false), + skipDisabled: inputs.skipDisabled ?? signal(true), + focusMode: inputs.focusMode ?? signal('roving'), + textDirection: inputs.textDirection ?? signal('ltr'), + orientation: inputs.orientation ?? signal('vertical'), + }); + } + + function getRadios(radioGroup: TestRadioGroup, values: string[]): TestRadio[] { + return values.map((value, index) => { + const element = document.createElement('div'); + element.role = 'radio'; + return new RadioButtonPattern({ + value: signal(value), + id: signal(`radio-${index}`), + disabled: signal(false), + group: signal(radioGroup), + element: signal(element), + }); + }) as TestRadio[]; + } + + function getPatterns(values: string[], inputs: Partial = {}) { + const radioButtons = signal([]); + const radioGroup = getRadioGroup({...inputs, items: radioButtons}); + radioButtons.set(getRadios(radioGroup, values)); + return {radioGroup, radioButtons: radioButtons()}; + } + + function getDefaultPatterns(inputs: Partial = {}) { + return getPatterns(['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'], inputs); + } + + describe('Keyboard Navigation', () => { + it('should navigate next on ArrowDown', () => { + const {radioGroup} = getDefaultPatterns(); + expect(radioGroup.inputs.activeIndex()).toBe(0); + radioGroup.onKeydown(down()); + expect(radioGroup.inputs.activeIndex()).toBe(1); + }); + + it('should navigate prev on ArrowUp', () => { + const {radioGroup} = getDefaultPatterns({activeIndex: signal(1)}); + expect(radioGroup.inputs.activeIndex()).toBe(1); + radioGroup.onKeydown(up()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should navigate next on ArrowRight (horizontal)', () => { + const {radioGroup} = getDefaultPatterns({orientation: signal('horizontal')}); + expect(radioGroup.inputs.activeIndex()).toBe(0); + radioGroup.onKeydown(right()); + expect(radioGroup.inputs.activeIndex()).toBe(1); + }); + + it('should navigate prev on ArrowLeft (horizontal)', () => { + const {radioGroup} = getDefaultPatterns({ + activeIndex: signal(1), + orientation: signal('horizontal'), + }); + expect(radioGroup.inputs.activeIndex()).toBe(1); + radioGroup.onKeydown(left()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should navigate next on ArrowLeft (horizontal & rtl)', () => { + const {radioGroup} = getDefaultPatterns({ + textDirection: signal('rtl'), + orientation: signal('horizontal'), + }); + expect(radioGroup.inputs.activeIndex()).toBe(0); + radioGroup.onKeydown(left()); + expect(radioGroup.inputs.activeIndex()).toBe(1); + }); + + it('should navigate prev on ArrowRight (horizontal & rtl)', () => { + const {radioGroup} = getDefaultPatterns({ + activeIndex: signal(1), + textDirection: signal('rtl'), + orientation: signal('horizontal'), + }); + expect(radioGroup.inputs.activeIndex()).toBe(1); + radioGroup.onKeydown(right()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should navigate to the first radio on Home', () => { + const {radioGroup} = getDefaultPatterns({ + activeIndex: signal(4), + }); + expect(radioGroup.inputs.activeIndex()).toBe(4); + radioGroup.onKeydown(home()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should navigate to the last radio on End', () => { + const {radioGroup} = getDefaultPatterns(); + expect(radioGroup.inputs.activeIndex()).toBe(0); + radioGroup.onKeydown(end()); + expect(radioGroup.inputs.activeIndex()).toBe(4); + }); + + it('should skip disabled radios when skipDisabled is true', () => { + const {radioGroup, radioButtons} = getDefaultPatterns({skipDisabled: signal(true)}); + radioButtons[1].disabled.set(true); + radioGroup.onKeydown(down()); + expect(radioGroup.inputs.activeIndex()).toBe(2); + radioGroup.onKeydown(up()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should not skip disabled radios when skipDisabled is false', () => { + const {radioGroup, radioButtons} = getDefaultPatterns({skipDisabled: signal(false)}); + radioButtons[1].disabled.set(true); + radioGroup.onKeydown(down()); + expect(radioGroup.inputs.activeIndex()).toBe(1); + radioGroup.onKeydown(up()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should be able to navigate in readonly mode', () => { + const {radioGroup} = getDefaultPatterns({readonly: signal(true)}); + radioGroup.onKeydown(down()); + expect(radioGroup.inputs.activeIndex()).toBe(1); + radioGroup.onKeydown(up()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + radioGroup.onKeydown(end()); + expect(radioGroup.inputs.activeIndex()).toBe(4); + radioGroup.onKeydown(home()); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + }); + + describe('Keyboard Selection', () => { + let radioGroup: TestRadioGroup; + + beforeEach(() => { + radioGroup = getDefaultPatterns({value: signal([])}).radioGroup; + }); + + it('should select a radio on Space', () => { + radioGroup.onKeydown(space()); + expect(radioGroup.inputs.value()).toEqual(['Apple']); + }); + + it('should select a radio on Enter', () => { + radioGroup.onKeydown(enter()); + expect(radioGroup.inputs.value()).toEqual(['Apple']); + }); + + it('should select the focused radio on navigation (implicit selection)', () => { + radioGroup.onKeydown(down()); + expect(radioGroup.inputs.value()).toEqual(['Banana']); + radioGroup.onKeydown(up()); + expect(radioGroup.inputs.value()).toEqual(['Apple']); + radioGroup.onKeydown(end()); + expect(radioGroup.inputs.value()).toEqual(['Elderberry']); + radioGroup.onKeydown(home()); + expect(radioGroup.inputs.value()).toEqual(['Apple']); + }); + + it('should not be able to change selection when in readonly mode', () => { + const readonly = radioGroup.inputs.readonly as WritableSignal; + readonly.set(true); + radioGroup.onKeydown(space()); + expect(radioGroup.inputs.value()).toEqual([]); + + radioGroup.onKeydown(down()); // Navigation still works + expect(radioGroup.inputs.activeIndex()).toBe(1); + expect(radioGroup.inputs.value()).toEqual([]); // Selection doesn't change + + radioGroup.onKeydown(enter()); + expect(radioGroup.inputs.value()).toEqual([]); + }); + + it('should not select a disabled radio via keyboard', () => { + const {radioGroup, radioButtons} = getPatterns(['A', 'B', 'C'], { + skipDisabled: signal(false), + }); + radioButtons[1].disabled.set(true); + + radioGroup.onKeydown(down()); // Focus B (disabled) + expect(radioGroup.inputs.activeIndex()).toBe(1); + expect(radioGroup.inputs.value()).toEqual([]); // Should not select B + + radioGroup.onKeydown(space()); // Try selecting B with space + expect(radioGroup.inputs.value()).toEqual([]); + + radioGroup.onKeydown(enter()); // Try selecting B with enter + expect(radioGroup.inputs.value()).toEqual([]); + + radioGroup.onKeydown(down()); // Focus C + expect(radioGroup.inputs.activeIndex()).toBe(2); + expect(radioGroup.inputs.value()).toEqual(['C']); // Selects C on navigation + }); + }); + + describe('Pointer Events', () => { + function click(radios: TestRadio[], index: number) { + return { + target: radios[index].element(), + } as unknown as PointerEvent; + } + + it('should select a radio on click', () => { + const {radioGroup, radioButtons} = getDefaultPatterns(); + radioGroup.onPointerdown(click(radioButtons, 1)); + expect(radioGroup.inputs.value()).toEqual(['Banana']); + expect(radioGroup.inputs.activeIndex()).toBe(1); + }); + + it('should not select a disabled radio on click', () => { + const {radioGroup, radioButtons} = getDefaultPatterns(); + radioButtons[1].disabled.set(true); + radioGroup.onPointerdown(click(radioButtons, 1)); + expect(radioGroup.inputs.value()).toEqual([]); + expect(radioGroup.inputs.activeIndex()).toBe(0); // Active index shouldn't change + }); + + it('should only update active index when readonly', () => { + const {radioGroup, radioButtons} = getDefaultPatterns({readonly: signal(true)}); + radioGroup.onPointerdown(click(radioButtons, 1)); + expect(radioGroup.inputs.value()).toEqual([]); + expect(radioGroup.inputs.activeIndex()).toBe(1); // Active index should update + }); + }); + + describe('#setDefaultState', () => { + it('should set the active index to the first radio', () => { + const {radioGroup} = getDefaultPatterns({activeIndex: signal(-1)}); + radioGroup.setDefaultState(); + expect(radioGroup.inputs.activeIndex()).toBe(0); + }); + + it('should set the active index to the first focusable radio', () => { + const {radioGroup, radioButtons} = getDefaultPatterns({ + skipDisabled: signal(true), + activeIndex: signal(-1), + }); + radioButtons[0].disabled.set(true); + radioGroup.setDefaultState(); + expect(radioGroup.inputs.activeIndex()).toBe(1); + }); + + it('should set the active index to the selected radio', () => { + const {radioGroup} = getDefaultPatterns({ + value: signal(['Cherry']), + activeIndex: signal(-1), + }); + radioGroup.setDefaultState(); + expect(radioGroup.inputs.activeIndex()).toBe(2); + }); + + it('should set the active index to the first focusable radio if selected is disabled', () => { + const {radioGroup, radioButtons} = getDefaultPatterns({ + value: signal(['Cherry']), + skipDisabled: signal(true), + activeIndex: signal(-1), + }); + radioButtons[2].disabled.set(true); // Disable Cherry + radioGroup.setDefaultState(); + expect(radioGroup.inputs.activeIndex()).toBe(0); // Defaults to first focusable + }); + }); + + describe('validate', () => { + it('should report a violation if the selected item is disabled and skipDisabled is true', () => { + const {radioGroup, radioButtons} = getDefaultPatterns({ + value: signal(['Banana']), + skipDisabled: signal(true), + }); + radioButtons[1].disabled.set(true); // Disable the selected item. + const violations = radioGroup.validate(); + expect(violations.length).toBe(1); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel b/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel index 299e39ee2545..c404b9c384c7 100644 --- a/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ts_project") +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -10,9 +10,29 @@ ts_project( deps = [ "//:node_modules/@angular/core", "//src/cdk-experimental/ui-patterns/behaviors/event-manager", + "//src/cdk-experimental/ui-patterns/behaviors/expansion", "//src/cdk-experimental/ui-patterns/behaviors/list-focus", "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", "//src/cdk-experimental/ui-patterns/behaviors/list-selection", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", ], ) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "tabs.spec.ts", + ], + deps = [ + ":tabs", + "//:node_modules/@angular/core", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts new file mode 100644 index 000000000000..0c72fbd2b38f --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts @@ -0,0 +1,353 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {signal} from '@angular/core'; +import { + TabInputs, + TabPattern, + TabListInputs, + TabListPattern, + TabPanelInputs, + TabPanelPattern, +} from './tabs'; +import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; +import {ModifierKeys} from '@angular/cdk/testing'; + +// Converts the SignalLike type to WritableSignalLike type for controlling test inputs. +type WritableSignalOverrides = { + [K in keyof O as O[K] extends SignalLike ? K : never]: O[K] extends SignalLike + ? WritableSignalLike + : never; +}; + +type TestTabListInputs = TabListInputs & WritableSignalOverrides; +type TestTabInputs = TabInputs & WritableSignalOverrides; +type TestTabPanelInputs = TabPanelInputs & WritableSignalOverrides; + +const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods); +const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods); +const left = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 37, 'ArrowLeft', mods); +const right = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 39, 'ArrowRight', mods); +const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', mods); +const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods); +const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods); +const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods); + +function createTabElement(): HTMLElement { + const element = document.createElement('div'); + element.role = 'tab'; + return element; +} + +describe('Tabs Pattern', () => { + let tabListInputs: TestTabListInputs; + let tabListPattern: TabListPattern; + let tabInputs: TestTabInputs[]; + let tabPatterns: TabPattern[]; + let tabPanelInputs: TestTabPanelInputs[]; + let tabPanelPatterns: TabPanelPattern[]; + + beforeEach(() => { + // Initiate TabListPattern. + tabListInputs = { + orientation: signal('horizontal'), + wrap: signal(true), + textDirection: signal('ltr'), + selectionMode: signal('follow'), + focusMode: signal('roving'), + disabled: signal(false), + activeIndex: signal(0), + skipDisabled: signal(true), + items: signal([]), + value: signal(['tab-1']), + }; + tabListPattern = new TabListPattern(tabListInputs); + + // Initiate a list of TabPatterns. + tabInputs = [ + { + tablist: signal(tabListPattern), + tabpanel: signal(undefined), + id: signal('tab-1-id'), + element: signal(createTabElement()), + disabled: signal(false), + value: signal('tab-1'), + }, + { + tablist: signal(tabListPattern), + tabpanel: signal(undefined), + id: signal('tab-2-id'), + element: signal(createTabElement()), + disabled: signal(false), + value: signal('tab-2'), + }, + { + tablist: signal(tabListPattern), + tabpanel: signal(undefined), + id: signal('tab-3-id'), + element: signal(createTabElement()), + disabled: signal(false), + value: signal('tab-3'), + }, + ]; + tabPatterns = [ + new TabPattern(tabInputs[0]), + new TabPattern(tabInputs[1]), + new TabPattern(tabInputs[2]), + ]; + + // Initiate a list of TabPanelPatterns. + tabPanelInputs = [ + { + id: signal('tabpanel-1-id'), + tab: signal(undefined), + value: signal('tab-1'), + }, + { + id: signal('tabpanel-2-id'), + tab: signal(undefined), + value: signal('tab-2'), + }, + { + id: signal('tabpanel-3-id'), + tab: signal(undefined), + value: signal('tab-3'), + }, + ]; + tabPanelPatterns = [ + new TabPanelPattern(tabPanelInputs[0]), + new TabPanelPattern(tabPanelInputs[1]), + new TabPanelPattern(tabPanelInputs[2]), + ]; + + // Binding between tabs and tabpanels. + tabInputs[0].tabpanel.set(tabPanelPatterns[0]); + tabInputs[1].tabpanel.set(tabPanelPatterns[1]); + tabInputs[2].tabpanel.set(tabPanelPatterns[2]); + tabPanelInputs[0].tab.set(tabPatterns[0]); + tabPanelInputs[1].tab.set(tabPatterns[1]); + tabPanelInputs[2].tab.set(tabPatterns[2]); + tabListInputs.items.set(tabPatterns); + }); + + it('sets the selected tab by setting `value`.', () => { + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListInputs.value.set(['tab-2']); + expect(tabPatterns[0].selected()).toBeFalse(); + expect(tabPatterns[1].selected()).toBeTrue(); + }); + + it('sets a tabpanel to be not hidden if a tab is selected.', () => { + tabListInputs.value.set(['tab-1']); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPanelPatterns[0].hidden()).toBeFalse(); + }); + + it('sets a tabpanel to be hidden if a tab is not selected.', () => { + tabListInputs.value.set(['tab-1']); + expect(tabPatterns[1].selected()).toBeFalse(); + expect(tabPanelPatterns[1].hidden()).toBeTrue(); + }); + + it('should set a tabpanel tabindex to 0 if the tab is selected.', () => { + tabListInputs.value.set(['tab-1']); + expect(tabPatterns[0].tabindex()).toBe(0); + }); + + it('should set a tabpanel tabindex to -1 if the tab is not selected.', () => { + tabListInputs.value.set(['tab-1']); + expect(tabPatterns[1].tabindex()).toBe(-1); + expect(tabPatterns[2].tabindex()).toBe(-1); + }); + + it('gets a controlled tabpanel id from a tab.', () => { + expect(tabPanelPatterns[0].id()).toBe('tabpanel-1-id'); + expect(tabPatterns[0].controls()).toBe('tabpanel-1-id'); + expect(tabPanelPatterns[1].id()).toBe('tabpanel-2-id'); + expect(tabPatterns[1].controls()).toBe('tabpanel-2-id'); + expect(tabPanelPatterns[2].id()).toBe('tabpanel-3-id'); + expect(tabPatterns[2].controls()).toBe('tabpanel-3-id'); + }); + + describe('#setDefaultState', () => { + it('should not set activeIndex if there are no tabs', () => { + tabListInputs.items.set([]); + tabListInputs.activeIndex.set(10); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeIndex()).toBe(10); + }); + + it('should not set activeIndex if no tabs are focusable', () => { + tabInputs.forEach(input => input.disabled.set(true)); + tabListInputs.activeIndex.set(10); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeIndex()).toBe(10); + }); + + it('should set activeIndex to the first focusable tab if no tabs are selected', () => { + tabListInputs.activeIndex.set(2); + tabListInputs.value.set([]); + tabInputs[0].disabled.set(true); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeIndex()).toBe(1); + }); + + it('should set activeIndex to the first focusable and selected tab', () => { + tabListInputs.activeIndex.set(0); + tabListInputs.value.set([tabPatterns[2].value()]); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeIndex()).toBe(2); + }); + + it('should set activeIndex to the first focusable tab when the selected tab is not focusable', () => { + tabListInputs.value.set([tabPatterns[1].value()]); + tabInputs[1].disabled.set(true); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeIndex()).toBe(0); + }); + }); + + describe('Keyboard Navigation', () => { + it('does not handle keyboard event if a tablist is disabled.', () => { + expect(tabPatterns[1].active()).toBeFalse(); + tabListInputs.disabled.set(true); + tabListPattern.onKeydown(right()); + expect(tabPatterns[1].active()).toBeFalse(); + }); + + it('skips the disabled tab when `skipDisabled` is set to true.', () => { + tabInputs[1].disabled.set(true); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].active()).toBeFalse(); + expect(tabPatterns[1].active()).toBeFalse(); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('does not skip the disabled tab when `skipDisabled` is set to false.', () => { + tabListInputs.skipDisabled.set(false); + tabInputs[1].disabled.set(true); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].active()).toBeFalse(); + expect(tabPatterns[1].active()).toBeTrue(); + expect(tabPatterns[2].active()).toBeFalse(); + }); + + it('selects a tab by focus if `selectionMode` is "follow".', () => { + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].selected()).toBeFalse(); + expect(tabPatterns[1].selected()).toBeTrue(); + }); + + it('selects a tab by enter key if `selectionMode` is "explicit".', () => { + tabListInputs.selectionMode.set('explicit'); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(enter()); + expect(tabPatterns[0].selected()).toBeFalse(); + expect(tabPatterns[1].selected()).toBeTrue(); + }); + + it('selects a tab by space key if `selectionMode` is "explicit".', () => { + tabListInputs.selectionMode.set('explicit'); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(space()); + expect(tabPatterns[0].selected()).toBeFalse(); + expect(tabPatterns[1].selected()).toBeTrue(); + }); + + it('uses left key to navigate to the previous tab when `orientation` is set to "horizontal".', () => { + tabListInputs.activeIndex.set(1); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(left()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('uses right key to navigate to the next tab when `orientation` is set to "horizontal".', () => { + tabListInputs.activeIndex.set(1); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('uses up key to navigate to the previous tab when `orientation` is set to "vertical".', () => { + tabListInputs.orientation.set('vertical'); + tabListInputs.activeIndex.set(1); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(up()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('uses down key to navigate to the next tab when `orientation` is set to "vertical".', () => { + tabListInputs.orientation.set('vertical'); + tabListInputs.activeIndex.set(1); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(down()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('uses home key to navigate to the first tab.', () => { + tabListInputs.activeIndex.set(1); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(home()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('uses end key to navigate to the last tab.', () => { + tabListInputs.activeIndex.set(1); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(end()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('moves to the last tab from first tab when navigating to the previous tab if `wrap` is set to true', () => { + expect(tabPatterns[0].active()).toBeTrue(); + tabListPattern.onKeydown(left()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('moves to the first tab from last tab when navigating to the next tab if `wrap` is set to true', () => { + tabListPattern.onKeydown(end()); + expect(tabPatterns[2].active()).toBeTrue(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('stays on the first tab when navigating to the previous tab if `wrap` is set to false', () => { + tabListInputs.wrap.set(false); + expect(tabPatterns[0].active()).toBeTrue(); + tabListPattern.onKeydown(left()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('stays on the last tab when navigating to the next tab if `wrap` is set to false', () => { + tabListInputs.wrap.set(false); + tabListPattern.onKeydown(end()); + expect(tabPatterns[2].active()).toBeTrue(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('changes the navigation direction with `rtl` mode.', () => { + tabListInputs.textDirection.set('rtl'); + tabListInputs.activeIndex.set(1); + tabListPattern.onKeydown(left()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.ts index 820c145ab468..fd9b98d4a20d 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts @@ -6,8 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed, signal} from '@angular/core'; - +import {computed} from '@angular/core'; import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; @@ -21,71 +20,81 @@ import { ListSelectionInputs, ListSelectionItem, } from '../behaviors/list-selection/list-selection'; +import { + ExpansionItem, + ExpansionControl, + ListExpansionInputs, + ListExpansion, +} from '../behaviors/expansion/expansion'; import {SignalLike} from '../behaviors/signal-like/signal-like'; /** The required inputs to tabs. */ -export interface TabInputs extends ListNavigationItem, ListSelectionItem, ListFocusItem { +export interface TabInputs + extends ListNavigationItem, + ListSelectionItem, + ListFocusItem, + Omit { + /** The parent tablist that controls the tab. */ tablist: SignalLike; + + /** The remote tabpanel controlled by the tab. */ tabpanel: SignalLike; } /** A tab in a tablist. */ export class TabPattern { + /** Controls expansion for this tab. */ + readonly expansion: ExpansionControl; + /** A global unique identifier for the tab. */ - id: SignalLike; + readonly id: SignalLike; /** A local unique identifier for the tab. */ - value: SignalLike; + readonly value: SignalLike; - /** Whether the tab is active. */ - active = computed(() => this.tablist()?.focusManager.activeItem() === this); + /** Whether the tab is disabled. */ + readonly disabled: SignalLike; - /** Whether the tab is selected. */ - selected = computed(() => this.tablist().selection.inputs.value().includes(this.value())); + /** The html element that should receive focus. */ + readonly element: SignalLike; - /** A Tabpanel Id controlled by the tab. */ - controls = computed(() => this.tabpanel()?.id()); + /** Whether this tab has expandable content. */ + readonly expandable = computed(() => this.expansion.expandable()); - /** Whether the tab is disabled. */ - disabled: SignalLike; + /** The unique identifier used by the expansion behavior. */ + readonly expansionId = computed(() => this.expansion.expansionId()); - /** A reference to the parent tablist. */ - tablist: SignalLike; + /** Whether the tab is expanded. */ + readonly expanded = computed(() => this.expansion.isExpanded()); - /** A reference to the corresponding tabpanel. */ - tabpanel: SignalLike; + /** Whether the tab is active. */ + readonly active = computed(() => this.inputs.tablist().focusManager.activeItem() === this); + + /** Whether the tab is selected. */ + readonly selected = computed( + () => !!this.inputs.tablist().selection.inputs.value().includes(this.value()), + ); /** The tabindex of the tab. */ - tabindex = computed(() => this.tablist().focusManager.getItemTabindex(this)); + readonly tabindex = computed(() => this.inputs.tablist().focusManager.getItemTabindex(this)); - /** The html element that should receive focus. */ - element: SignalLike; + /** The id of the tabpanel associated with the tab. */ + readonly controls = computed(() => this.inputs.tabpanel()?.id()); - constructor(inputs: TabInputs) { + constructor(readonly inputs: TabInputs) { this.id = inputs.id; this.value = inputs.value; - this.tablist = inputs.tablist; - this.tabpanel = inputs.tabpanel; - this.element = inputs.element; this.disabled = inputs.disabled; + this.element = inputs.element; + this.expansion = new ExpansionControl({ + ...inputs, + expansionId: inputs.value, + expandable: () => true, + expansionManager: inputs.tablist().expansionManager, + }); } } -/** The selection operations that the tablist can perform. */ -interface SelectOptions { - select?: boolean; - toggle?: boolean; - toggleOne?: boolean; - selectOne?: boolean; -} - -/** The required inputs for the tablist. */ -export type TabListInputs = ListNavigationInputs & - Omit, 'multi'> & - ListFocusInputs & { - disabled: SignalLike; - }; - /** The required inputs for the tabpanel. */ export interface TabPanelInputs { id: SignalLike; @@ -96,52 +105,65 @@ export interface TabPanelInputs { /** A tabpanel associated with a tab. */ export class TabPanelPattern { /** A global unique identifier for the tabpanel. */ - id: SignalLike; + readonly id: SignalLike; /** A local unique identifier for the tabpanel. */ - value: SignalLike; - - /** A reference to the corresponding tab. */ - tab: SignalLike; + readonly value: SignalLike; /** Whether the tabpanel is hidden. */ - hidden = computed(() => !this.tab()?.selected()); + readonly hidden = computed(() => this.inputs.tab()?.expanded() === false); + + /** The tabindex of this tabpanel. */ + readonly tabindex = computed(() => (this.hidden() ? -1 : 0)); - constructor(inputs: TabPanelInputs) { + constructor(readonly inputs: TabPanelInputs) { this.id = inputs.id; this.value = inputs.value; - this.tab = inputs.tab; } } +/** The selection operations that the tablist can perform. */ +interface SelectOptions { + select?: boolean; +} + +/** The required inputs for the tablist. */ +export type TabListInputs = ListNavigationInputs & + Omit, 'multi'> & + ListFocusInputs & + Omit; + /** Controls the state of a tablist. */ export class TabListPattern { /** Controls navigation for the tablist. */ - navigation: ListNavigation; + readonly navigation: ListNavigation; /** Controls selection for the tablist. */ - selection: ListSelection; + readonly selection: ListSelection; /** Controls focus for the tablist. */ - focusManager: ListFocus; + readonly focusManager: ListFocus; + + /** Controls expansion for the tablist. */ + readonly expansionManager: ListExpansion; /** Whether the tablist is vertically or horizontally oriented. */ - orientation: SignalLike<'vertical' | 'horizontal'>; + readonly orientation: SignalLike<'vertical' | 'horizontal'>; /** Whether the tablist is disabled. */ - disabled: SignalLike; + readonly disabled: SignalLike; /** The tabindex of the tablist. */ - tabindex = computed(() => this.focusManager.getListTabindex()); + readonly tabindex = computed(() => this.focusManager.getListTabindex()); /** The id of the current active tab. */ - activedescendant = computed(() => this.focusManager.getActiveDescendant()); + readonly activedescendant = computed(() => this.focusManager.getActiveDescendant()); /** Whether selection should follow focus. */ - followFocus = computed(() => this.inputs.selectionMode() === 'follow'); + readonly followFocus = computed(() => this.inputs.selectionMode() === 'follow'); /** The key used to navigate to the previous tab in the tablist. */ - prevKey = computed(() => { + readonly prevKey = computed(() => { if (this.inputs.orientation() === 'vertical') { return 'ArrowUp'; } @@ -149,7 +171,7 @@ export class TabListPattern { }); /** The key used to navigate to the next item in the list. */ - nextKey = computed(() => { + readonly nextKey = computed(() => { if (this.inputs.orientation() === 'vertical') { return 'ArrowDown'; } @@ -157,21 +179,19 @@ export class TabListPattern { }); /** The keydown event manager for the tablist. */ - keydown = computed(() => { - const manager = new KeyboardEventManager() - .on(this.prevKey, () => this.prev({selectOne: this.followFocus()})) - .on(this.nextKey, () => this.next({selectOne: this.followFocus()})) - .on('Home', () => this.first({selectOne: this.followFocus()})) - .on('End', () => this.last({selectOne: this.followFocus()})) - .on(' ', () => this.selection.selectOne()) - .on('Enter', () => this.selection.selectOne()); - - return manager; + readonly keydown = computed(() => { + return new KeyboardEventManager() + .on(this.prevKey, () => this.prev({select: this.followFocus()})) + .on(this.nextKey, () => this.next({select: this.followFocus()})) + .on('Home', () => this.first({select: this.followFocus()})) + .on('End', () => this.last({select: this.followFocus()})) + .on(' ', () => this._select({select: true})) + .on('Enter', () => this._select({select: true})); }); /** The pointerdown event manager for the tablist. */ - pointerdown = computed(() => { - return new PointerEventManager().on(e => this.goto(e, {selectOne: true})); + readonly pointerdown = computed(() => { + return new PointerEventManager().on(e => this.goto(e, {select: true})); }); constructor(readonly inputs: TabListInputs) { @@ -180,11 +200,46 @@ export class TabListPattern { this.focusManager = new ListFocus(inputs); this.navigation = new ListNavigation({...inputs, focusManager: this.focusManager}); + this.selection = new ListSelection({ ...inputs, - multi: signal(false), + multi: () => false, focusManager: this.focusManager, }); + + this.expansionManager = new ListExpansion({ + ...inputs, + multiExpandable: () => false, + expandedIds: this.inputs.value, + }); + } + + /** + * Sets the tablist to its default initial state. + * + * Sets the active index of the tablist to the first focusable selected + * tab if one exists. Otherwise, sets focus to the first focusable tab. + * + * This method should be called once the tablist and its tabs are properly initialized. + */ + setDefaultState() { + let firstItemIndex: number | undefined; + + for (const [index, item] of this.inputs.items().entries()) { + if (!this.focusManager.isFocusable(item)) continue; + + if (firstItemIndex === undefined) { + firstItemIndex = index; + } + + if (item.selected()) { + this.inputs.activeIndex.set(index); + return; + } + } + if (firstItemIndex !== undefined) { + this.inputs.activeIndex.set(firstItemIndex); + } } /** Handles keydown events for the tablist. */ @@ -204,25 +259,25 @@ export class TabListPattern { /** Navigates to the first option in the tablist. */ first(opts?: SelectOptions) { this.navigation.first(); - this._updateSelection(opts); + this._select(opts); } /** Navigates to the last option in the tablist. */ last(opts?: SelectOptions) { this.navigation.last(); - this._updateSelection(opts); + this._select(opts); } /** Navigates to the next option in the tablist. */ next(opts?: SelectOptions) { this.navigation.next(); - this._updateSelection(opts); + this._select(opts); } /** Navigates to the previous option in the tablist. */ prev(opts?: SelectOptions) { this.navigation.prev(); - this._updateSelection(opts); + this._select(opts); } /** Navigates to the given item in the tablist. */ @@ -231,23 +286,15 @@ export class TabListPattern { if (item) { this.navigation.goto(item); - this._updateSelection(opts); + this._select(opts); } } /** Handles updating selection for the tablist. */ - private _updateSelection(opts?: SelectOptions) { + private _select(opts?: SelectOptions) { if (opts?.select) { - this.selection.select(); - } - if (opts?.toggle) { - this.selection.toggle(); - } - if (opts?.toggleOne) { - this.selection.toggleOne(); - } - if (opts?.selectOne) { this.selection.selectOne(); + this.expansionManager.open(this.focusManager.activeItem()); } } diff --git a/src/cdk-experimental/ui-patterns/tree/BUILD.bazel b/src/cdk-experimental/ui-patterns/tree/BUILD.bazel new file mode 100644 index 000000000000..1a53f80c42eb --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tree/BUILD.bazel @@ -0,0 +1,39 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "tree", + srcs = [ + "tree.ts", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/event-manager", + "//src/cdk-experimental/ui-patterns/behaviors/expansion", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "//src/cdk-experimental/ui-patterns/behaviors/list-selection", + "//src/cdk-experimental/ui-patterns/behaviors/list-typeahead", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "tree.spec.ts", + ], + deps = [ + ":tree", + "//:node_modules/@angular/core", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/tree/tree.spec.ts b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts new file mode 100644 index 000000000000..47d6370bf948 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts @@ -0,0 +1,1261 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {WritableSignal, signal} from '@angular/core'; +import {TreeInputs, TreeItemInputs, TreeItemPattern, TreePattern} from './tree'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; +import {ModifierKeys} from '@angular/cdk/testing'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; + +// Converts the SignalLike type to WritableSignal type for controlling test inputs. +type WritableSignalOverrides = { + [K in keyof O as O[K] extends SignalLike ? K : never]: O[K] extends SignalLike + ? WritableSignal + : never; +}; + +type TestTreeInputs = Omit & WritableSignalOverrides>, 'allItems'>; +type TestTreeItemInputs = TreeItemInputs & WritableSignalOverrides>; + +const a = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 65, 'A', mods); +const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods); +const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods); +const left = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 37, 'ArrowLeft', mods); +const right = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 39, 'ArrowRight', mods); +const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', mods); +const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods); +const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods); +const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods); +const asterisk = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 106, '*', mods); +const shift = () => createKeyboardEvent('keydown', 16, 'Shift', {shift: true}); + +function createClickEvent(element: HTMLElement, mods?: ModifierKeys): PointerEvent { + return { + target: element, + shiftKey: mods?.shift, + ctrlKey: mods?.control, + metaKey: mods?.meta, + button: 0, + preventDefault: () => {}, + stopPropagation: () => {}, + } as unknown as PointerEvent; +} + +function createTreeItemElement(id: string): HTMLElement { + const element = document.createElement('div'); + element.role = 'treeitem'; + element.id = id; + return element; +} + +interface TestTreeItem { + value: V; + children?: TestTreeItem[]; + disabled: boolean; +} + +describe('Tree Pattern', () => { + function createTree(treeData: TestTreeItem[], treeInputs: TestTreeInputs) { + const allItems = signal[]>([]); + const itemPatternInputsMap = new Map>(); + const tree = new TreePattern({ + ...treeInputs, + allItems, + }); + + let nextId = 0; + + function buildItems( + treeData: TestTreeItem[], + parent: TreeItemPattern | TreePattern, + ): TreeItemPattern[] { + const items: TreeItemPattern[] = []; + + for (const node of treeData) { + const itemId = `treeitem-${nextId++}`; + const element = createTreeItemElement(itemId); + const itemPatternInputs: TestTreeItemInputs = { + id: signal(itemId), + value: signal(node.value), + element: signal(element), + disabled: signal(node.disabled), + searchTerm: signal(String(node.value)), + parent: signal(parent), + hasChildren: signal((node.children ?? []).length > 0), + children: signal[]>([]), + tree: signal(tree), + }; + + const item = new TreeItemPattern(itemPatternInputs); + itemPatternInputsMap.set(itemId, itemPatternInputs); + + allItems.set([...allItems(), item]); + items.push(item); + + const childItems = buildItems(node.children ?? [], item); + itemPatternInputs.children.set(childItems); + } + return items; + } + + // Build tree items recursively. + buildItems(treeData, tree as TreePattern); + + return {tree, allItems, itemPatternInputsMap}; + } + + function getItemByValue(allItems: TreeItemPattern[], value: V) { + return allItems.find(i => i.value() === value)!; + } + + const treeExample: TestTreeItem[] = [ + { + value: 'Item 0', + children: [ + {value: 'Item 0-0', disabled: false}, + {value: 'Item 0-1', disabled: false}, + ], + disabled: false, + }, + {value: 'Item 1', disabled: false}, + { + value: 'Item 2', + children: [{value: 'Item 2-0', disabled: false}], + disabled: false, + }, + ]; + + describe('TreeItemPattern properties', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('follow'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should correctly compute level', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + + expect(item0.level()).toBe(1); + expect(item0_0.level()).toBe(2); + }); + + it('should correctly compute setsize', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + + expect(item0.setsize()).toBe(3); + expect(item0_0.setsize()).toBe(2); + }); + + it('should correctly compute posinset', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + const item0_1 = getItemByValue(allItems(), 'Item 0-1'); + + expect(item0.posinset()).toBe(1); + expect(item1.posinset()).toBe(2); + expect(item0_0.posinset()).toBe(1); + expect(item0_1.posinset()).toBe(2); + }); + }); + + describe('Keyboard Navigation', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should correctly compute active state', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + treeInputs.activeIndex.set(0); + expect(item0.active()).toBe(true); + expect(item1.active()).toBe(false); + + treeInputs.activeIndex.set(1); + expect(item0.active()).toBe(false); + expect(item1.active()).toBe(true); + }); + + it('should correctly compute tabindex state', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + expect(item0.tabindex()).toBe(tree.focusManager.getItemTabindex(item0)); + }); + + it('should navigate next on ArrowDown (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + tree.navigationManager.goto(item0); + + expect(tree.focusManager.activeItem()).toBe(item0); + tree.onKeydown(down()); + expect(tree.focusManager.activeItem()).toBe(item1); + }); + + it('should navigate prev on ArrowUp (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + tree.navigationManager.goto(item1); + + expect(tree.focusManager.activeItem()).toBe(item1); + tree.onKeydown(up()); + expect(tree.focusManager.activeItem()).toBe(item0); + }); + + it('should navigate next on ArrowRight (horizontal)', () => { + treeInputs.orientation.set('horizontal'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + tree.navigationManager.goto(item0); + + expect(tree.focusManager.activeItem()).toBe(item0); + tree.onKeydown(right()); + expect(tree.focusManager.activeItem()).toBe(item1); + }); + + it('should navigate prev on ArrowLeft (horizontal)', () => { + treeInputs.orientation.set('horizontal'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + tree.navigationManager.goto(item1); + + expect(tree.focusManager.activeItem()).toBe(item1); + tree.onKeydown(left()); + expect(tree.focusManager.activeItem()).toBe(item0); + }); + + it('should navigate next on ArrowLeft (horizontal & rtl)', () => { + treeInputs.orientation.set('horizontal'); + treeInputs.textDirection.set('rtl'); + treeInputs.activeIndex.set(0); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + expect(tree.focusManager.activeItem()).toBe(item0); + tree.onKeydown(left()); + expect(tree.focusManager.activeItem()).toBe(item1); + }); + + it('should navigate prev on ArrowRight (horizontal & rtl)', () => { + treeInputs.orientation.set('horizontal'); + treeInputs.textDirection.set('rtl'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + tree.navigationManager.goto(item1); + + expect(tree.focusManager.activeItem()).toBe(item1); + tree.onKeydown(right()); + expect(tree.focusManager.activeItem()).toBe(item0); + }); + + it('should navigate to the first visible item on Home', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item2 = getItemByValue(allItems(), 'Item 2'); + tree.navigationManager.goto(item2); + + expect(tree.focusManager.activeItem()).toBe(item2); + tree.onKeydown(home()); + expect(tree.focusManager.activeItem()).toBe(item0); + }); + + it('should navigate to the last visible item on End', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item2 = getItemByValue(allItems(), 'Item 2'); + tree.navigationManager.goto(item0); + + expect(tree.focusManager.activeItem()).toBe(item0); + tree.onKeydown(end()); + expect(tree.focusManager.activeItem()).toBe(item2); + }); + + it('should skip disabled items when skipDisabled is true', () => { + treeInputs.skipDisabled.set(true); + const localTreeExample: TestTreeItem[] = [ + {value: 'Item A', disabled: false}, + {value: 'Item B', disabled: true}, + {value: 'Item C', disabled: false}, + ]; + const {tree, allItems} = createTree(localTreeExample, treeInputs); + const itemA = getItemByValue(allItems(), 'Item A'); + const itemC = getItemByValue(allItems(), 'Item C'); + tree.navigationManager.goto(itemA); + + expect(tree.focusManager.activeItem()).toBe(itemA); + tree.onKeydown(down()); + expect(tree.focusManager.activeItem()).toBe(itemC); + }); + + it('should not skip disabled items when skipDisabled is false', () => { + treeInputs.skipDisabled.set(false); + const localTreeExample: TestTreeItem[] = [ + {value: 'Item A', disabled: false}, + {value: 'Item B', disabled: true}, + {value: 'Item C', disabled: false}, + ]; + const {tree, allItems} = createTree(localTreeExample, treeInputs); + const itemA = getItemByValue(allItems(), 'Item A'); + const itemB = getItemByValue(allItems(), 'Item B'); + tree.navigationManager.goto(itemA); + + expect(tree.focusManager.activeItem()).toBe(itemA); + tree.onKeydown(down()); + expect(tree.focusManager.activeItem()).toBe(itemB); + }); + + it('should not navigate when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + tree.navigationManager.goto(item0); + + expect(tree.focusManager.activeItem()).toBe(item0); + tree.onKeydown(down()); + expect(tree.focusManager.activeItem()).toBe(item0); + }); + }); + + describe('Keyboard Selection', () => { + describe('follows focus & single select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('follow'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should correctly compute selected state', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + treeInputs.value.set(['Item 0']); + expect(item0.selected()).toBe(true); + expect(item1.selected()).toBe(false); + + treeInputs.value.set(['Item 1']); + expect(item0.selected()).toBe(false); + expect(item1.selected()).toBe(true); + }); + + it('should select an item on navigation', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onKeydown(down()); + expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.inputs.value()).toEqual(['Item 1']); + + tree.onKeydown(up()); + expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.inputs.value()).toEqual(['Item 0']); + }); + + it('should not change selection when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(down()); + expect(tree.inputs.value()).toEqual([]); + }); + }); + + describe('explicit focus & single select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should select an item on Space', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(space()); + expect(tree.inputs.value()).toEqual(['Item 0']); + + tree.onKeydown(space()); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should select an item on Enter', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual(['Item 0']); + + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should only allow one selected item', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual(['Item 0']); + + tree.onKeydown(down()); + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual(['Item 1']); + }); + + it('should not change selection when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(space()); + expect(tree.inputs.value()).toEqual([]); + + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual([]); + }); + }); + + describe('explicit focus & multi select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(true), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should select an item on Space', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(space()); + expect(tree.inputs.value()).toEqual(['Item 0']); + }); + + it('should select an item on Enter', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual(['Item 0']); + }); + + it('should allow multiple selected items', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(enter()); + tree.onKeydown(down()); + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 1']); + }); + + it('should select a range of visible items on Shift + ArrowDown/ArrowUp', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + item0.expansion.open(); + + tree.onKeydown(shift()); + tree.onKeydown(down({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0']); + + tree.onKeydown(down({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1']); + + tree.onKeydown(up({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0']); + }); + + it('should not allow wrapping while Shift is held down', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + + tree.onKeydown(shift()); + tree.onKeydown(up({shift: true})); + expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should select a range of visible items on Shift + Space (or Enter)', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + item0.expansion.open(); + + tree.onKeydown(down()); + tree.onKeydown(space()); + expect(tree.inputs.value()).toEqual(['Item 0-0']); + + tree.onKeydown(down()); + tree.onKeydown(down()); + tree.onKeydown(shift()); + tree.onKeydown(space({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0-0', 'Item 0-1', 'Item 1']); + }); + + it('should select the focused item and all visible items up to the first on Ctrl + Shift + Home', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + item0.expansion.open(); + tree.navigationManager.goto(item1); + + tree.onKeydown(shift()); + tree.onKeydown(home({control: true, shift: true})); + expect(tree.inputs.value()).toEqual(['Item 1', 'Item 0-1', 'Item 0-0', 'Item 0']); + }); + + it('should select the focused item and all visible items down to the last on Ctrl + Shift + End', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + item0.expansion.open(); + tree.navigationManager.goto(item0_0); + + tree.onKeydown(shift()); + tree.onKeydown(end({control: true, shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0-0', 'Item 0-1', 'Item 1', 'Item 2']); + }); + + it('should not change selection when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(space()); + expect(tree.inputs.value()).toEqual([]); + + tree.onKeydown(a({control: true})); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should not select disabled items on Shift + ArrowUp / ArrowDown', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: true}, + {value: 'C', disabled: false}, + ]; + treeInputs.skipDisabled.set(false); + const {tree, allItems} = createTree(localTreeData, treeInputs); + const itemA = getItemByValue(allItems(), 'A'); + + tree.navigationManager.goto(itemA); + tree.onKeydown(shift()); + tree.onKeydown(down({shift: true})); + tree.onKeydown(down({shift: true})); + expect(tree.inputs.value()).toEqual(['A', 'C']); + }); + + it('should select all visible items on Ctrl + A', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + item0.expansion.open(); + + tree.onKeydown(a({control: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1', 'Item 1', 'Item 2']); + }); + + it('should deselect all visible items on Ctrl + A if all are selected', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + item0.expansion.open(); + + tree.onKeydown(a({control: true})); + tree.onKeydown(a({control: true})); + expect(tree.inputs.value()).toEqual([]); + }); + }); + + describe('follows focus & multi select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(true), + orientation: signal('vertical'), + selectionMode: signal('follow'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should select an item on navigation', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(down()); + expect(tree.inputs.value()).toEqual(['Item 1']); + }); + + it('should navigate without selecting if the Ctrl key is pressed', () => { + treeInputs.value.set(['Item 0']); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onKeydown(down({control: true})); + expect(tree.inputs.value()).toEqual(['Item 0']); + expect(tree.focusManager.activeItem()).toBe(item1); + }); + + it('should toggle an item selection state on Ctrl + Space', () => { + treeInputs.value.set(['Item 0']); + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(down({control: true})); + tree.onKeydown(space({control: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 1']); + + tree.onKeydown(space({control: true})); + expect(tree.inputs.value()).toEqual(['Item 0']); + }); + + it('should select a range of visible items on Shift + ArrowDown/ArrowUp', () => { + treeInputs.value.set(['Item 0']); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + item0.expansion.open(); + + tree.onKeydown(shift()); + tree.onKeydown(down({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0']); + tree.onKeydown(down({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1']); + }); + + it('should not allow wrapping while Shift is held down', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + tree.navigationManager.goto(item0); + + tree.onKeydown(shift()); + tree.onKeydown(up({shift: true})); + expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should select a range of visible items on Shift + Space (or Enter)', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + item0.expansion.open(); + tree.navigationManager.goto(item0); + + tree.onKeydown(down({control: true})); + tree.onKeydown(down({control: true})); + tree.onKeydown(shift()); + tree.onKeydown(space({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1']); + }); + + it('should select the focused item and all visible items up to the first on Ctrl + Shift + Home', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + item0.expansion.open(); + tree.navigationManager.goto(item1); + + tree.onKeydown(shift()); + tree.onKeydown(home({control: true, shift: true})); + expect(tree.inputs.value()).toEqual(['Item 1', 'Item 0-1', 'Item 0-0', 'Item 0']); + }); + + it('should select the focused item and all visible items down to the last on Ctrl + Shift + End', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + item0.expansion.open(); + tree.navigationManager.goto(item0_0); + + tree.onKeydown(shift()); + tree.onKeydown(end({control: true, shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0-0', 'Item 0-1', 'Item 1', 'Item 2']); + }); + + it('should not change selection when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(down()); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should not select disabled items on navigation', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: true}, + {value: 'C', disabled: false}, + ]; + treeInputs.skipDisabled.set(true); + const {tree, allItems} = createTree(localTreeData, treeInputs); + treeInputs.value.set(['A']); + tree.navigationManager.goto(getItemByValue(allItems(), 'A')); + + tree.onKeydown(down()); + expect(tree.inputs.value()).toEqual(['C']); + }); + + it('should deselect all except the focused item on Ctrl + A if all are selected', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + item0.expansion.open(); + tree.navigationManager.goto(item0_0); + + tree.onKeydown(a({control: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1', 'Item 1', 'Item 2']); + tree.onKeydown(a({control: true})); + expect(tree.inputs.value()).toEqual(['Item 0-0']); + }); + }); + }); + + describe('Pointer Events', () => { + describe('follows focus & single select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('follow'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should navigate and select a single item on click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.inputs.value()).toEqual(['Item 1']); + }); + + it('should not change selection when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.inputs.value()).toEqual([]); + }); + }); + + describe('explicit focus & single select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should navigate and toggle selection on click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.inputs.value()).toEqual(['Item 1']); + + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should not change selection when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.inputs.value()).toEqual([]); + }); + }); + + describe('explicit focus & multi select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(true), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should navigate and toggle selection on click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item0.element())); + expect(tree.inputs.value()).toEqual(['Item 0']); + + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 1']); + + tree.onPointerdown(createClickEvent(item0.element())); + expect(tree.inputs.value()).toEqual(['Item 1']); + }); + + it('should navigate and select range from anchor on shift + click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + item0.expansion.open(); + + tree.onKeydown(shift()); + tree.onPointerdown(createClickEvent(item1.element(), {shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1', 'Item 1']); + }); + }); + + describe('follows focus & multi select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(true), + orientation: signal('vertical'), + selectionMode: signal('follow'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should navigate and select a single item on click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item0.element())); + expect(tree.inputs.value()).toEqual(['Item 0']); + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.inputs.value()).toEqual(['Item 1']); + }); + + it('should navigate and toggle selection on ctrl + click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item0.element())); // Select and expand Item 0 + tree.onPointerdown(createClickEvent(item1.element(), {control: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 1']); + tree.onPointerdown(createClickEvent(item0.element(), {control: true})); + expect(tree.inputs.value()).toEqual(['Item 1']); + }); + + it('should navigate and select range from anchor on shift + click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item2 = getItemByValue(allItems(), 'Item 2'); + + tree.onPointerdown(createClickEvent(item0.element())); // Select and expand Item 0 + tree.onKeydown(shift()); + tree.onPointerdown(createClickEvent(item2.element(), {shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1', 'Item 1', 'Item 2']); + }); + + it('should select a new range on subsequent shift + clicks, deselecting previous range', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + item0.expansion.open(); + + tree.onKeydown(shift()); + tree.onPointerdown(createClickEvent(item1.element(), {shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1', 'Item 1']); + + tree.onPointerdown(createClickEvent(item0_0.element(), {shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0']); + }); + + it('should not select disabled items on click', () => { + const localTreeData: TestTreeItem[] = [{value: 'A', disabled: true}]; + const {tree, allItems} = createTree(localTreeData, treeInputs); + const itemA = getItemByValue(allItems(), 'A'); + + tree.onPointerdown(createClickEvent(itemA.element())); + expect(tree.inputs.value()).toEqual([]); + expect(tree.focusManager.activeItem()).toBe(itemA); + }); + }); + }); + + describe('Expansion/Collapse', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should correctly compute visible state', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + + expect(item0_0.visible()).toBe(false); + item0.expansion.open(); + expect(item0_0.visible()).toBe(true); + item0.expansion.close(); + expect(item0_0.visible()).toBe(false); + }); + + it('should correctly compute expanded state', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + + expect(item0.expanded()).toBe(false); + item0.expansion.open(); + expect(item0.expanded()).toBe(true); + }); + + it('should expand an item on expandKey if collapsed (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + tree.navigationManager.goto(item0); + + expect(item0.expanded()).toBe(false); + tree.onKeydown(right()); + expect(item0.expanded()).toBe(true); + }); + + it('should move focus to the first child on expandKey if expanded and has children (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + tree.navigationManager.goto(item0); + item0.expansion.open(); + + tree.onKeydown(right()); + expect(tree.focusManager.activeItem()).toBe(item0_0); + }); + + it('should do nothing on expandKey if expanded and has no children (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + tree.navigationManager.goto(item1); + + tree.onKeydown(right()); + expect(tree.focusManager.activeItem()).toBe(item1); + }); + + it('should collapse an item on collapseKey if expanded (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + tree.navigationManager.goto(item0); + item0.expansion.open(); + + expect(item0.expanded()).toBe(true); + tree.onKeydown(left()); + expect(item0.expanded()).toBe(false); + }); + + it('should move focus to the parent on collapseKey if collapsed (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + item0.expansion.open(); + tree.navigationManager.goto(item0_0); + + tree.onKeydown(left()); + expect(tree.focusManager.activeItem()).toBe(item0); + }); + + it('should do nothing on collapseKey if collapsed and is a root item (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + tree.navigationManager.goto(item0); + + tree.onKeydown(left()); + expect(tree.focusManager.activeItem()).toBe(item0); + expect(item0.expanded()).toBe(false); + }); + + it('should expand all sibling items on Shift + Asterisk (*)', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + const item2 = getItemByValue(allItems(), 'Item 2'); + tree.navigationManager.goto(item0); + + tree.onKeydown(asterisk({shift: true})); + expect(item0.expanded()).toBe(true); + expect(item1.expanded()).toBe(false); + expect(item2.expanded()).toBe(true); + }); + + it('should toggle expansion on pointerdown (click)', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + + expect(item0.expanded()).toBe(false); + tree.onPointerdown(createClickEvent(item0.element())); + expect(item0.expanded()).toBe(true); + tree.onPointerdown(createClickEvent(item0.element())); + expect(item0.expanded()).toBe(false); + }); + + it('should not toggle expansion on pointerdown if the item is not expandable', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + + expect(item1.expanded()).toBe(false); + tree.onPointerdown(createClickEvent(item1.element())); + expect(item1.expanded()).toBe(false); + }); + + it('should not toggle expansion on pointerdown if the item is disabled', () => { + const {tree, allItems, itemPatternInputsMap} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + itemPatternInputsMap.get(item0.id())!.disabled.set(true); + + tree.onPointerdown(createClickEvent(item0.element())); + expect(item0.expanded()).toBe(false); + }); + + it('should not toggle expansion on pointerdown if the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + + tree.onPointerdown(createClickEvent(item0.element())); + expect(item0.expanded()).toBe(false); + }); + }); + + describe('#setDefaultState', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should set activeIndex to the first visible focusable item if no selection', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: false}, + ]; + const {tree} = createTree(localTreeData, treeInputs); + + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(0); + }); + + it('should set activeIndex to the first visible focusable disabled item if skipDisabled is false and no selection', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: true}, + {value: 'B', disabled: false}, + ]; + treeInputs.skipDisabled.set(false); + const {tree} = createTree(localTreeData, treeInputs); + + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(0); + }); + + it('should set activeIndex to the first selected visible focusable item', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: false}, + {value: 'C', disabled: false}, + ]; + treeInputs.value.set(['B']); + const {tree} = createTree(localTreeData, treeInputs); + + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(1); + }); + + it('should prioritize the first selected item in visible order', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: false}, + {value: 'C', disabled: false}, + ]; + treeInputs.value.set(['C', 'A']); + const {tree} = createTree(localTreeData, treeInputs); + + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(0); + }); + + it('should skip a selected disabled item if skipDisabled is true', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: true}, + {value: 'C', disabled: false}, + ]; + treeInputs.value.set(['B']); + treeInputs.skipDisabled.set(true); + const {tree} = createTree(localTreeData, treeInputs); + + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(0); + }); + + it('should select a selected disabled item if skipDisabled is false', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: true}, + {value: 'C', disabled: false}, + ]; + treeInputs.value.set(['B']); + treeInputs.skipDisabled.set(false); + const {tree} = createTree(localTreeData, treeInputs); + + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(1); + }); + + it('should set activeIndex to first visible focusable item if selected item is not visible', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + treeInputs.value.set(['Item 0-0']); + + expect(item0.expanded()).toBe(false); + expect(getItemByValue(allItems(), 'Item 0-0').visible()).toBe(false); + tree.setDefaultState(); + expect(treeInputs.activeIndex()).toBe(0); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/tree/tree.ts b/src/cdk-experimental/ui-patterns/tree/tree.ts new file mode 100644 index 000000000000..d67b8930254c --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -0,0 +1,545 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed, signal} from '@angular/core'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; +import { + ListNavigation, + ListNavigationInputs, + ListNavigationItem, +} from '../behaviors/list-navigation/list-navigation'; +import { + ListSelection, + ListSelectionInputs, + ListSelectionItem, +} from '../behaviors/list-selection/list-selection'; +import { + ListTypeahead, + ListTypeaheadInputs, + ListTypeaheadItem, +} from '../behaviors/list-typeahead/list-typeahead'; +import {ExpansionItem, ExpansionControl, ListExpansion} from '../behaviors/expansion/expansion'; +import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; +import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; +import {ModifierKey as Modifier} from '../behaviors/event-manager/event-manager'; + +/** Represents the required inputs for a tree item. */ +export interface TreeItemInputs + extends ListFocusItem, + ListNavigationItem, + ListSelectionItem, + ListTypeaheadItem { + /** The parent item. */ + parent: SignalLike | TreePattern>; + + /** Whether this item has children. Children can be lazily loaded. */ + hasChildren: SignalLike; + + /** The children items. */ + children: SignalLike[]>; + + /** The tree pattern this item belongs to. */ + tree: SignalLike>; +} + +export interface TreeItemPattern extends TreeItemInputs {} +/** + * Represents an item in a Tree. + */ +export class TreeItemPattern implements ExpansionItem { + /** The unique identifier used by the expansion behavior. */ + readonly expansionId: SignalLike; + + /** Controls expansion for child items. */ + readonly expansionManager: ListExpansion; + + /** Controls expansion for this item. */ + readonly expansion: ExpansionControl; + + /** Whether the item is expandable. It's expandable if children item exist. */ + readonly expandable: SignalLike; + + /** The level of the current item in a tree. */ + readonly level: SignalLike = computed(() => this.parent().level() + 1); + + /** Whether this item is currently expanded. */ + readonly expanded = computed(() => this.expansion.isExpanded()); + + /** Whether this item is visible. */ + readonly visible = computed(() => this.parent().expanded()); + + /** The number of items under the same parent at the same level. */ + readonly setsize = computed(() => this.parent().children().length); + + /** The position of this item among its siblings (1-based). */ + readonly posinset = computed(() => this.parent().children().indexOf(this) + 1); + + /** Whether the item is active. */ + readonly active = computed(() => this.tree().focusManager.activeItem() === this); + + /** The tabindex of the item. */ + readonly tabindex = computed(() => this.tree().focusManager.getItemTabindex(this)); + + /** Whether the item is selected. */ + readonly selected = computed(() => this.tree().value().includes(this.value())); + + constructor(readonly inputs: TreeItemInputs) { + this.id = inputs.id; + this.value = inputs.value; + this.element = inputs.element; + this.disabled = inputs.disabled; + this.searchTerm = inputs.searchTerm; + this.expansionId = inputs.id; + this.tree = inputs.tree; + this.parent = inputs.parent; + this.children = inputs.children; + this.expandable = inputs.hasChildren; + this.expansion = new ExpansionControl({ + ...inputs, + expandable: this.expandable, + expansionId: this.expansionId, + expansionManager: this.parent().expansionManager, + }); + this.expansionManager = new ListExpansion({ + ...inputs, + multiExpandable: () => true, + // TODO(ok7sai): allow pre-expanded tree items. + expandedIds: signal([]), + items: this.children, + disabled: computed(() => this.tree()?.disabled() ?? false), + }); + } +} + +/** The selection operations that the tree can perform. */ +interface SelectOptions { + toggle?: boolean; + selectOne?: boolean; + selectRange?: boolean; + anchor?: boolean; +} + +/** Represents the required inputs for a tree. */ +export interface TreeInputs + extends Omit< + ListFocusInputs> & + ListNavigationInputs> & + ListSelectionInputs, V> & + ListTypeaheadInputs>, + 'items' + > { + /** All items in the tree, in document order (DFS-like, a flattened list). */ + allItems: SignalLike[]>; +} + +export interface TreePattern extends TreeInputs {} +/** Controls the state and interactions of a tree view. */ +export class TreePattern { + /** Controls focus for the all visible tree items. */ + readonly focusManager: ListFocus>; + + /** Controls navigation for all visible tree items. */ + readonly navigationManager: ListNavigation>; + + /** Controls selection for all visible tree items. */ + readonly selectionManager: ListSelection, V>; + + /** Controls typeahead for all visible tree items. */ + readonly typeaheadManager: ListTypeahead>; + + /** Controls expansion for direct children of the tree root (top-level items). */ + readonly expansionManager: ListExpansion; + + /** The root level is 0. */ + readonly level = () => 0; + + /** The root is always expanded. */ + readonly expanded = () => true; + + /** The tabindex of the tree. */ + readonly tabindex = computed(() => this.focusManager.getListTabindex()); + + /** The id of the current active item. */ + readonly activedescendant = computed(() => this.focusManager.getActiveDescendant()); + + /** Whether the tree is performing a range selection. */ + readonly inSelection = signal(false); + + /** The direct children of the root (top-level tree items). */ + readonly children = computed(() => + this.inputs.allItems().filter(item => item.level() === this.level() + 1), + ); + + /** All currently visible tree items. An item is visible if their parent is expanded. */ + readonly visibleItems = computed(() => this.inputs.allItems().filter(item => item.visible())); + + /** Whether the tree selection follows focus. */ + readonly followFocus = computed(() => this.inputs.selectionMode() === 'follow'); + + /** The key for navigating to the previous item. */ + readonly prevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowUp'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key for navigating to the next item. */ + readonly nextKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowDown'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The key for collapsing an item or moving to its parent. */ + readonly collapseKey = computed(() => { + if (this.inputs.orientation() === 'horizontal') { + return 'ArrowUp'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key for expanding an item or moving to its first child. */ + readonly expandKey = computed(() => { + if (this.inputs.orientation() === 'horizontal') { + return 'ArrowDown'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** Represents the space key. Does nothing when the user is actively using typeahead. */ + readonly dynamicSpaceKey = computed(() => (this.typeaheadManager.isTyping() ? '' : ' ')); + + /** Regular expression to match characters for typeahead. */ + readonly typeaheadRegexp = /^.$/; + + /** Uncommitted tree item for selecting a range of tree items. */ + readonly anchorItem = signal | undefined>(undefined); + + /** + * Uncommitted tree item index for selecting a range of tree items. + * + * The index is computed in case the tree item position is changed caused by tree expansions. + */ + readonly anchorIndex = computed(() => + this.anchorItem() ? this.visibleItems().indexOf(this.anchorItem()!) : -1, + ); + + /** The keydown event manager for the tree. */ + readonly keydown = computed(() => { + const manager = new KeyboardEventManager(); + + if (!this.followFocus()) { + manager + .on(this.prevKey, () => this.prev()) + .on(this.nextKey, () => this.next()) + .on('Home', () => this.first()) + .on('End', () => this.last()) + .on(this.typeaheadRegexp, e => this.search(e.key)); + } + + if (this.followFocus()) { + manager + .on(this.prevKey, () => this.prev({selectOne: true})) + .on(this.nextKey, () => this.next({selectOne: true})) + .on('Home', () => this.first({selectOne: true})) + .on('End', () => this.last({selectOne: true})) + .on(this.typeaheadRegexp, e => this.search(e.key, {selectOne: true})); + } + + if (this.inputs.multi()) { + manager + .on(Modifier.Any, 'Shift', () => this.anchorItem.set(this.focusManager.activeItem())) + .on(Modifier.Shift, this.prevKey, () => this.prev({selectRange: true})) + .on(Modifier.Shift, this.nextKey, () => this.next({selectRange: true})) + .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'Home', () => + this.first({selectRange: true, anchor: false}), + ) + .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'End', () => + this.last({selectRange: true, anchor: false}), + ) + .on(Modifier.Shift, 'Enter', () => + this._updateSelection({selectRange: true, anchor: false}), + ) + .on(Modifier.Shift, this.dynamicSpaceKey, () => + this._updateSelection({selectRange: true, anchor: false}), + ); + } + + if (!this.followFocus() && this.inputs.multi()) { + manager + .on(this.dynamicSpaceKey, () => this.selectionManager.toggle()) + .on('Enter', () => this.selectionManager.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'A', () => this.selectionManager.toggleAll()); + } + + if (!this.followFocus() && !this.inputs.multi()) { + manager.on(this.dynamicSpaceKey, () => this.selectionManager.toggleOne()); + manager.on('Enter', () => this.selectionManager.toggleOne()); + } + + if (this.inputs.multi() && this.followFocus()) { + manager + .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => this.prev()) + .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => this.next()) + .on([Modifier.Ctrl, Modifier.Meta], ' ', () => this.selectionManager.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => this.selectionManager.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => this.first()) + .on([Modifier.Ctrl, Modifier.Meta], 'End', () => this.last()) + .on([Modifier.Ctrl, Modifier.Meta], 'A', () => { + this.selectionManager.toggleAll(); + this.selectionManager.select(); // Ensure the currect item remains selected. + }); + } + + manager + .on(this.expandKey, () => this.expand()) + .on(this.collapseKey, () => this.collapse()) + .on(Modifier.Shift, '*', () => this.expandSiblings()); + + return manager; + }); + + /** The pointerdown event manager for the tree. */ + pointerdown = computed(() => { + const manager = new PointerEventManager(); + + if (this.multi()) { + manager.on(Modifier.Shift, e => this.goto(e, {selectRange: true})); + } + + if (!this.multi() && this.followFocus()) { + return manager.on(e => this.goto(e, {selectOne: true})); + } + + if (!this.multi() && !this.followFocus()) { + return manager.on(e => this.goto(e, {toggle: true})); + } + + if (this.multi() && this.followFocus()) { + return manager + .on(e => this.goto(e, {selectOne: true})) + .on(Modifier.Ctrl, e => this.goto(e, {toggle: true})); + } + + if (this.multi() && !this.followFocus()) { + return manager.on(e => this.goto(e, {toggle: true})); + } + + return manager; + }); + + constructor(readonly inputs: TreeInputs) { + this.allItems = inputs.allItems; + this.focusMode = inputs.focusMode; + this.disabled = inputs.disabled; + this.activeIndex = inputs.activeIndex; + this.skipDisabled = inputs.skipDisabled; + this.wrap = inputs.wrap; + this.orientation = inputs.orientation; + this.textDirection = inputs.textDirection; + this.multi = inputs.multi; + this.value = inputs.value; + this.selectionMode = inputs.selectionMode; + this.typeaheadDelay = inputs.typeaheadDelay; + this.focusManager = new ListFocus({ + ...inputs, + items: this.visibleItems, + }); + this.navigationManager = new ListNavigation({ + ...inputs, + wrap: computed(() => this.inputs.wrap() && !this.inSelection()), + items: this.visibleItems, + focusManager: this.focusManager, + }); + this.selectionManager = new ListSelection({ + ...inputs, + items: this.visibleItems, + focusManager: this.focusManager, + }); + this.typeaheadManager = new ListTypeahead({ + ...inputs, + items: this.visibleItems, + focusManager: this.focusManager, + }); + this.expansionManager = new ListExpansion({ + multiExpandable: () => true, + // TODO(ok7sai): allow pre-expanded tree items. + expandedIds: signal([]), + items: this.children, + disabled: this.disabled, + }); + } + + /** + * Sets the tree to it's default initial state. + * + * Sets the active index of the tree to the first focusable selected tree item if one exists. + * Otherwise, sets focus to the first focusable tree item. + */ + setDefaultState() { + let firstItemIndex: number | undefined; + + for (const [index, item] of this.allItems().entries()) { + if (!item.visible()) continue; + if (!this.focusManager.isFocusable(item)) continue; + + if (firstItemIndex === undefined) { + firstItemIndex = index; + } + + if (item.selected()) { + this.activeIndex.set(index); + return; + } + } + + if (firstItemIndex !== undefined) { + this.activeIndex.set(firstItemIndex); + } + } + + /** Handles keydown events on the tree. */ + onKeydown(event: KeyboardEvent) { + if (!this.disabled()) { + this.keydown().handle(event); + } + } + + /** Handles pointerdown events on the tree. */ + onPointerdown(event: PointerEvent) { + if (!this.disabled()) { + this.pointerdown().handle(event); + } + } + + /** Navigates to the first visible tree item in the tree. */ + first(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationManager.first()); + } + + /** Navigates to the last visible tree item in the tree. */ + last(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationManager.last()); + } + + /** Navigates to the next visible tree item in the tree. */ + next(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationManager.next()); + } + + /** Navigates to the previous visible tree item in the tree. */ + prev(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationManager.prev()); + } + + /** Navigates to the given tree item in the tree. */ + goto(event: PointerEvent, opts?: SelectOptions) { + const item = this._getItem(event); + this._navigate(opts, () => this.navigationManager.goto(item)); + this.toggleExpansion(item); + } + + /** Handles typeahead search navigation for the tree. */ + search(char: string, opts?: SelectOptions) { + this._navigate(opts, () => this.typeaheadManager.search(char)); + } + + /** Toggles to expand or collapse a tree item. */ + toggleExpansion(item?: TreeItemPattern) { + item ??= this.focusManager.activeItem(); + if (!item || !this.focusManager.isFocusable(item)) return; + + if (!item.expandable()) return; + if (item.expanded()) { + this.collapse(); + } else { + item.expansion.open(); + } + } + + /** Expands a tree item. */ + expand(item?: TreeItemPattern) { + item ??= this.focusManager.activeItem(); + if (!item || !this.focusManager.isFocusable(item)) return; + + if (item.expandable() && !item.expanded()) { + item.expansion.open(); + } else if (item.expanded() && item.children().length > 0) { + const firstChild = item.children()[0]; + if (this.focusManager.isFocusable(firstChild)) { + this.navigationManager.goto(firstChild); + } + } + } + + /** Expands all sibling tree items including itself. */ + expandSiblings(item?: TreeItemPattern) { + item ??= this.focusManager.activeItem(); + const siblings = item.parent()?.children(); + siblings?.forEach(item => this.expand(item)); + } + + /** Collapses a tree item. */ + collapse(item?: TreeItemPattern) { + item ??= this.focusManager.activeItem(); + if (!item || !this.focusManager.isFocusable(item)) return; + + if (item.expandable() && item.expanded()) { + item.expansion.close(); + } else if (item.parent() && item.parent() !== this) { + const parentItem = item.parent(); + if (parentItem instanceof TreeItemPattern && this.focusManager.isFocusable(parentItem)) { + this.navigationManager.goto(parentItem); + } + } + } + + /** Safely performs a navigation operation. */ + private _navigate(opts: SelectOptions = {}, operation: () => boolean) { + if (opts?.selectRange) { + this.inSelection.set(true); + this.selectionManager.rangeStartIndex.set(this.anchorIndex()); + } + + const moved = operation(); + + if (moved) { + this._updateSelection(opts); + } + + this.inSelection.set(false); + } + + /** Handles updating selection for the tree. */ + private _updateSelection(opts: SelectOptions = {anchor: true}) { + if (opts.toggle) { + this.selectionManager.toggle(); + } + if (opts.selectOne) { + this.selectionManager.selectOne(); + } + if (opts.selectRange) { + this.selectionManager.selectRange(); + } + if (!opts.anchor) { + this.anchorItem.set(this.visibleItems()[this.selectionManager.rangeStartIndex()]); + } + } + + /** Retrieves the TreeItemPattern associated with a DOM event, if any. */ + private _getItem(event: Event): TreeItemPattern | undefined { + if (!(event.target instanceof HTMLElement)) { + return; + } + const element = event.target.closest('[role="treeitem"]'); + return this.inputs.allItems().find(i => i.element() === element); + } +} diff --git a/src/cdk/BUILD.bazel b/src/cdk/BUILD.bazel index 387948d75f9c..4ba6388d6d99 100644 --- a/src/cdk/BUILD.bazel +++ b/src/cdk/BUILD.bazel @@ -84,6 +84,7 @@ filegroup( copy_to_directory( name = "adev_assets", srcs = [ + "//src/cdk/drag-drop:json_api", "//src/cdk/testing:json_api", "//src/cdk/testing/protractor:json_api", "//src/cdk/testing/selenium-webdriver:json_api", diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index dc1770436476..948d0b78cf87 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -142,7 +142,7 @@ export class FocusMonitor implements OnDestroy { }; /** Used to reference correct document/window */ - protected _document? = inject(DOCUMENT, {optional: true}); + protected _document = inject(DOCUMENT); /** Subject for stopping our InputModalityDetector subscription. */ private readonly _stopInputModalityDetector = new Subject(); @@ -206,7 +206,7 @@ export class FocusMonitor implements OnDestroy { // If the element is inside the shadow DOM, we need to bind our focus/blur listeners to // the shadow root, rather than the `document`, because the browser won't emit focus events // to the `document`, if focus is moving within the same shadow root. - const rootNode = _getShadowRoot(nativeElement) || this._getDocument(); + const rootNode = _getShadowRoot(nativeElement) || this._document; const cachedInfo = this._elementInfo.get(nativeElement); // Check if we're already monitoring this element. @@ -280,7 +280,7 @@ export class FocusMonitor implements OnDestroy { options?: FocusOptions, ): void { const nativeElement = coerceElement(element); - const focusedElement = this._getDocument().activeElement; + const focusedElement = this._document.activeElement; // If the element is focused already, calling `focus` again won't trigger the event listener // which means that the focus classes won't be updated. If that's the case, update the classes @@ -303,15 +303,9 @@ export class FocusMonitor implements OnDestroy { this._elementInfo.forEach((_info, element) => this.stopMonitoring(element)); } - /** Access injected document if available or fallback to global document reference */ - private _getDocument(): Document { - return this._document || document; - } - /** Use defaultView of injected document if available or fallback to global window reference */ private _getWindow(): Window { - const doc = this._getDocument(); - return doc.defaultView || window; + return this._document.defaultView || window; } private _getFocusOrigin(focusEventTarget: HTMLElement | null): FocusOrigin { diff --git a/src/cdk/a11y/key-manager/activedescendant-key-manager.ts b/src/cdk/a11y/key-manager/activedescendant-key-manager.ts index 3bc4d85606c0..d02fb120e58b 100644 --- a/src/cdk/a11y/key-manager/activedescendant-key-manager.ts +++ b/src/cdk/a11y/key-manager/activedescendant-key-manager.ts @@ -37,6 +37,13 @@ export class ActiveDescendantKeyManager extends ListKeyManager extends ListKeyManager { */ override setActiveItem(item: T): void; + /** + * Sets the active item to the item that is specified and focuses it. + * @param item Item to be set as active. + */ + override setActiveItem(item: T | number): void; + override setActiveItem(item: any): void { super.setActiveItem(item); diff --git a/src/cdk/a11y/key-manager/list-key-manager.ts b/src/cdk/a11y/key-manager/list-key-manager.ts index 55796cbe1d01..f2f9b398e462 100644 --- a/src/cdk/a11y/key-manager/list-key-manager.ts +++ b/src/cdk/a11y/key-manager/list-key-manager.ts @@ -39,7 +39,7 @@ export type ListKeyManagerModifierKey = 'altKey' | 'ctrlKey' | 'metaKey' | 'shif * of items, it will set the active item correctly when arrow events occur. */ export class ListKeyManager { - private _activeItemIndex = -1; + private _activeItemIndex = signal(-1); private _activeItem = signal(null); private _wrap = false; private _typeaheadSubscription = Subscription.EMPTY; @@ -203,13 +203,19 @@ export class ListKeyManager { */ setActiveItem(item: T): void; + /** + * Sets the active item to the specified item. + * @param item The item to be set as active. + */ + setActiveItem(item: T | number): void; + setActiveItem(item: any): void { const previousActiveItem = this._activeItem(); this.updateActiveItem(item); if (this._activeItem() !== previousActiveItem) { - this.change.next(this._activeItemIndex); + this.change.next(this._activeItemIndex()); } } @@ -279,7 +285,7 @@ export class ListKeyManager { case PAGE_UP: if (this._pageUpAndDown.enabled && isModifierAllowed) { - const targetIndex = this._activeItemIndex - this._pageUpAndDown.delta; + const targetIndex = this._activeItemIndex() - this._pageUpAndDown.delta; this._setActiveItemByIndex(targetIndex > 0 ? targetIndex : 0, 1); break; } else { @@ -288,7 +294,7 @@ export class ListKeyManager { case PAGE_DOWN: if (this._pageUpAndDown.enabled && isModifierAllowed) { - const targetIndex = this._activeItemIndex + this._pageUpAndDown.delta; + const targetIndex = this._activeItemIndex() + this._pageUpAndDown.delta; const itemsLength = this._getItemsArray().length; this._setActiveItemByIndex(targetIndex < itemsLength ? targetIndex : itemsLength - 1, -1); break; @@ -312,7 +318,7 @@ export class ListKeyManager { /** Index of the currently active item. */ get activeItemIndex(): number | null { - return this._activeItemIndex; + return this._activeItemIndex(); } /** The active item. */ @@ -337,12 +343,12 @@ export class ListKeyManager { /** Sets the active item to the next enabled item in the list. */ setNextItemActive(): void { - this._activeItemIndex < 0 ? this.setFirstItemActive() : this._setActiveItemByDelta(1); + this._activeItemIndex() < 0 ? this.setFirstItemActive() : this._setActiveItemByDelta(1); } /** Sets the active item to a previous enabled item in the list. */ setPreviousItemActive(): void { - this._activeItemIndex < 0 && this._wrap + this._activeItemIndex() < 0 && this._wrap ? this.setLastItemActive() : this._setActiveItemByDelta(-1); } @@ -366,7 +372,7 @@ export class ListKeyManager { // Explicitly check for `null` and `undefined` because other falsy values are valid. this._activeItem.set(activeItem == null ? null : activeItem); - this._activeItemIndex = index; + this._activeItemIndex.set(index); this._typeahead?.setCurrentSelectedItemIndex(index); } @@ -398,7 +404,7 @@ export class ListKeyManager { const items = this._getItemsArray(); for (let i = 1; i <= items.length; i++) { - const index = (this._activeItemIndex + delta * i + items.length) % items.length; + const index = (this._activeItemIndex() + delta * i + items.length) % items.length; const item = items[index]; if (!this._skipPredicateFn(item)) { @@ -414,7 +420,7 @@ export class ListKeyManager { * it encounters either end of the list, it will stop and not wrap. */ private _setActiveInDefaultMode(delta: -1 | 1): void { - this._setActiveItemByIndex(this._activeItemIndex + delta, delta); + this._setActiveItemByIndex(this._activeItemIndex() + delta, delta); } /** @@ -456,8 +462,8 @@ export class ListKeyManager { if (activeItem) { const newIndex = newItems.indexOf(activeItem); - if (newIndex > -1 && newIndex !== this._activeItemIndex) { - this._activeItemIndex = newIndex; + if (newIndex > -1 && newIndex !== this._activeItemIndex()) { + this._activeItemIndex.set(newIndex); this._typeahead?.setCurrentSelectedItemIndex(newIndex); } } diff --git a/src/cdk/accordion/accordion-item.ts b/src/cdk/accordion/accordion-item.ts index 97a9cdd07d5b..bd32f3f10d8c 100644 --- a/src/cdk/accordion/accordion-item.ts +++ b/src/cdk/accordion/accordion-item.ts @@ -16,6 +16,7 @@ import { booleanAttribute, inject, OnInit, + signal, } from '@angular/core'; import {_IdGenerator} from '../a11y'; import {UniqueSelectionDispatcher} from '../collections'; @@ -90,7 +91,14 @@ export class CdkAccordionItem implements OnInit, OnDestroy { private _expanded = false; /** Whether the AccordionItem is disabled. */ - @Input({transform: booleanAttribute}) disabled: boolean = false; + @Input({transform: booleanAttribute}) + get disabled() { + return this._disabled(); + } + set disabled(value: boolean) { + this._disabled.set(value); + } + private _disabled = signal(false); /** Unregister function for _expansionDispatcher. */ private _removeUniqueSelectionListener: () => void = () => {}; diff --git a/src/cdk/collections/dispose-view-repeater-strategy.ts b/src/cdk/collections/dispose-view-repeater-strategy.ts index c2b10ecff26b..3404ee747942 100644 --- a/src/cdk/collections/dispose-view-repeater-strategy.ts +++ b/src/cdk/collections/dispose-view-repeater-strategy.ts @@ -23,7 +23,7 @@ import { /** * A repeater that destroys views when they are removed from a - * {@link ViewContainerRef}. When new items are inserted into the container, + * `ViewContainerRef`. When new items are inserted into the container, * the repeater will always construct a new embedded view for each item. * * @template T The type for the embedded view's $implicit property. diff --git a/src/cdk/collections/recycle-view-repeater-strategy.ts b/src/cdk/collections/recycle-view-repeater-strategy.ts index 2e91cad9fa7d..735e32aec9de 100644 --- a/src/cdk/collections/recycle-view-repeater-strategy.ts +++ b/src/cdk/collections/recycle-view-repeater-strategy.ts @@ -24,7 +24,7 @@ import { /** * A repeater that caches views when they are removed from a - * {@link ViewContainerRef}. When new items are inserted into the container, + * `ViewContainerRef`. When new items are inserted into the container, * the repeater will reuse one of the cached views instead of creating a new * embedded view. Recycling cached views reduces the quantity of expensive DOM * inserts. diff --git a/src/cdk/collections/view-repeater.ts b/src/cdk/collections/view-repeater.ts index eeaf232cd766..1a0a2970a7b2 100644 --- a/src/cdk/collections/view-repeater.ts +++ b/src/cdk/collections/view-repeater.ts @@ -50,14 +50,14 @@ export type _ViewRepeaterItemContextFactory _ViewRepeaterItemInsertArgs; /** - * Extracts the value of an item from an {@link IterableChangeRecord}. + * Extracts the value of an item from an `IterableChangeRecord`. * * @template T The type for the embedded view's $implicit property. * @template R The type for the item in each IterableDiffer change record. */ export type _ViewRepeaterItemValueResolver = (record: IterableChangeRecord) => T; -/** Indicates how a view was changed by a {@link _ViewRepeater}. */ +/** Indicates how a view was changed by a `_ViewRepeater`. */ export enum _ViewRepeaterOperation { /** The content of an existing view was replaced with another item. */ REPLACED, @@ -70,8 +70,7 @@ export enum _ViewRepeaterOperation { } /** - * Meta data describing the state of a view after it was updated by a - * {@link _ViewRepeater}. + * Meta data describing the state of a view after it was updated by a `_ViewRepeater`. * * @template R The type for the item in each IterableDiffer change record. * @template C The type for the context passed to each embedded view. @@ -94,7 +93,7 @@ export interface _ViewRepeaterItemChange { export type _ViewRepeaterItemChanged = (change: _ViewRepeaterItemChange) => void; /** - * Describes a strategy for rendering items in a {@link ViewContainerRef}. + * Describes a strategy for rendering items in a `ViewContainerRef`. * * @template T The type for the embedded view's $implicit property. * @template R The type for the item in each IterableDiffer change record. @@ -113,7 +112,7 @@ export interface _ViewRepeater> { } /** - * Injection token for {@link _ViewRepeater}. This token is for use by Angular Material only. + * Injection token for `_ViewRepeater`. This token is for use by Angular Material only. * @docs-private */ export const _VIEW_REPEATER_STRATEGY = new InjectionToken< diff --git a/src/cdk/dialog/BUILD.bazel b/src/cdk/dialog/BUILD.bazel index 252e8c6b2ee7..95735b0b92b3 100644 --- a/src/cdk/dialog/BUILD.bazel +++ b/src/cdk/dialog/BUILD.bazel @@ -17,7 +17,6 @@ ng_project( ), assets = [":dialog-container.css"] + glob(["**/*.html"]), deps = [ - "//:node_modules/@angular/animations", "//:node_modules/@angular/common", "//:node_modules/@angular/core", "//:node_modules/rxjs", diff --git a/src/cdk/dialog/dialog-config.ts b/src/cdk/dialog/dialog-config.ts index 888b5efc3c51..4585b11e4ba0 100644 --- a/src/cdk/dialog/dialog-config.ts +++ b/src/cdk/dialog/dialog-config.ts @@ -9,7 +9,9 @@ import {ViewContainerRef, Injector, StaticProvider, Type} from '@angular/core'; import {Direction} from '../bidi'; import {PositionStrategy, ScrollStrategy} from '../overlay'; +import {Observable} from 'rxjs'; import {BasePortalOutlet} from '../portal'; +import {FocusOrigin} from '../a11y'; /** Options for where to set focus to automatically on dialog open */ export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; @@ -17,8 +19,15 @@ export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; /** Valid ARIA roles for a dialog. */ export type DialogRole = 'dialog' | 'alertdialog'; +/** Component that can be used as the container for the dialog. */ +export type DialogContainer = BasePortalOutlet & { + _focusTrapped?: Observable; + _closeInteractionType?: FocusOrigin; + _recaptureFocus?: () => void; +}; + /** Configuration for opening a modal dialog. */ -export class DialogConfig { +export class DialogConfig { /** * Where the attached component should live in Angular's *logical* component tree. * This affects what is available for injection and the change detection order for the @@ -74,7 +83,7 @@ export class DialogConfig extends BasePortalOutlet - implements OnDestroy + implements DialogContainer, OnDestroy { protected _elementRef = inject>(ElementRef); protected _focusTrapFactory = inject(FocusTrapFactory); @@ -80,13 +81,16 @@ export class CdkDialogContainer protected _ngZone = inject(NgZone); private _focusMonitor = inject(FocusMonitor); private _renderer = inject(Renderer2); - + protected readonly _changeDetectorRef = inject(ChangeDetectorRef); + private _injector = inject(Injector); private _platform = inject(Platform); - protected _document = inject(DOCUMENT, {optional: true})!; + protected _document = inject(DOCUMENT); /** The portal outlet inside of this container into which the dialog content will be loaded. */ @ViewChild(CdkPortalOutlet, {static: true}) _portalOutlet: CdkPortalOutlet; + _focusTrapped: Observable = new Subject(); + /** The class that traps and manages focus within the dialog. */ private _focusTrap: FocusTrap | null = null; @@ -108,10 +112,6 @@ export class CdkDialogContainer */ _ariaLabelledByQueue: string[] = []; - protected readonly _changeDetectorRef = inject(ChangeDetectorRef); - - private _injector = inject(Injector); - private _isDestroyed = false; constructor(...args: unknown[]); @@ -156,6 +156,7 @@ export class CdkDialogContainer } ngOnDestroy() { + (this._focusTrapped as Subject).complete(); this._isDestroyed = true; this._restoreFocus(); } @@ -291,6 +292,7 @@ export class CdkDialogContainer this._focusByCssSelector(this._config.autoFocus!, options); break; } + (this._focusTrapped as Subject).next(); }, {injector: this._injector}, ); diff --git a/src/cdk/dialog/dialog-ref.ts b/src/cdk/dialog/dialog-ref.ts index cfe4ad0f9862..8794605f3fe7 100644 --- a/src/cdk/dialog/dialog-ref.ts +++ b/src/cdk/dialog/dialog-ref.ts @@ -9,9 +9,8 @@ import {OverlayRef} from '../overlay'; import {ESCAPE, hasModifierKey} from '../keycodes'; import {Observable, Subject, Subscription} from 'rxjs'; -import {DialogConfig} from './dialog-config'; +import {DialogConfig, DialogContainer} from './dialog-config'; import {FocusOrigin} from '../a11y'; -import {BasePortalOutlet} from '../portal'; import {ComponentRef} from '@angular/core'; /** Additional options that can be passed in when closing a dialog. */ @@ -37,10 +36,7 @@ export class DialogRef { readonly componentRef: ComponentRef | null; /** Instance of the container that is rendering out the dialog content. */ - readonly containerInstance: BasePortalOutlet & { - _closeInteractionType?: FocusOrigin; - _recaptureFocus?: () => void; - }; + readonly containerInstance: DialogContainer; /** Whether the user is allowed to close the dialog. */ disableClose: boolean | undefined; @@ -65,7 +61,7 @@ export class DialogRef { constructor( readonly overlayRef: OverlayRef, - readonly config: DialogConfig, BasePortalOutlet>, + readonly config: DialogConfig, DialogContainer>, ) { this.disableClose = config.disableClose; this.backdropClick = overlayRef.backdropClick(); @@ -114,7 +110,7 @@ export class DialogRef { closedSubject.next(result); closedSubject.complete(); (this as {componentInstance: C}).componentInstance = ( - this as {containerInstance: BasePortalOutlet} + this as {containerInstance: DialogContainer} ).containerInstance = null!; } } @@ -149,7 +145,7 @@ export class DialogRef { /** Whether the dialog is allowed to close. */ private _canClose(result?: R): boolean { - const config = this.config as DialogConfig; + const config = this.config as DialogConfig; return ( !!this.containerInstance && diff --git a/src/cdk/dialog/dialog.spec.ts b/src/cdk/dialog/dialog.spec.ts index 71383634c35b..c36e70516fb4 100644 --- a/src/cdk/dialog/dialog.spec.ts +++ b/src/cdk/dialog/dialog.spec.ts @@ -46,18 +46,6 @@ describe('Dialog', () => { beforeEach(fakeAsync(() => { TestBed.configureTestingModule({ - imports: [ - DialogModule, - ComponentWithChildViewContainer, - ComponentWithTemplateRef, - PizzaMsg, - ContentElementDialog, - DialogWithInjectedData, - DialogWithoutFocusableElements, - DirectiveWithViewContainer, - TemplateInjectorParentComponent, - TemplateInjectorInnerDirective, - ], providers: [ {provide: Location, useClass: SpyLocation}, {provide: TEMPLATE_INJECTOR_TEST_TOKEN, useValue: 'hello from test module'}, @@ -1416,7 +1404,6 @@ class DirectiveWithViewContainer { @Component({ changeDetection: ChangeDetectionStrategy.OnPush, template: 'hello', - standalone: false, }) class ComponentWithOnPushViewContainer { viewContainerRef = inject(ViewContainerRef); @@ -1509,7 +1496,6 @@ class DialogWithoutFocusableElements {} @Component({ template: ``, encapsulation: ViewEncapsulation.ShadowDom, - standalone: false, }) class ShadowDomComponent {} diff --git a/src/cdk/dialog/dialog.ts b/src/cdk/dialog/dialog.ts index 5ff00f544073..45ac68f5961c 100644 --- a/src/cdk/dialog/dialog.ts +++ b/src/cdk/dialog/dialog.ts @@ -19,7 +19,7 @@ import { signal, } from '@angular/core'; import {Observable, Subject, defer} from 'rxjs'; -import {startWith} from 'rxjs/operators'; +import {startWith, take} from 'rxjs/operators'; import {_IdGenerator} from '../a11y'; import {Direction, Directionality} from '../bidi'; import { @@ -30,8 +30,8 @@ import { OverlayContainer, OverlayRef, } from '../overlay'; -import {BasePortalOutlet, ComponentPortal, TemplatePortal} from '../portal'; -import {DialogConfig} from './dialog-config'; +import {ComponentPortal, TemplatePortal} from '../portal'; +import {DialogConfig, DialogContainer} from './dialog-config'; import {DialogRef} from './dialog-ref'; import {CdkDialogContainer} from './dialog-container'; @@ -141,14 +141,24 @@ export class Dialog implements OnDestroy { const dialogRef = new DialogRef(overlayRef, config); const dialogContainer = this._attachContainer(overlayRef, dialogRef, config); - (dialogRef as {containerInstance: BasePortalOutlet}).containerInstance = dialogContainer; - this._attachDialogContent(componentOrTemplateRef, dialogRef, dialogContainer, config); + (dialogRef as {containerInstance: DialogContainer}).containerInstance = dialogContainer; // If this is the first dialog that we're opening, hide all the non-overlay content. if (!this.openDialogs.length) { - this._hideNonDialogContentFromAssistiveTechnology(); + // Resolve this ahead of time, because some internal apps + // mock it out and depend on it being synchronous. + const overlayContainer = this._overlayContainer.getContainerElement(); + + if (dialogContainer._focusTrapped) { + dialogContainer._focusTrapped.pipe(take(1)).subscribe(() => { + this._hideNonDialogContentFromAssistiveTechnology(overlayContainer); + }); + } else { + this._hideNonDialogContentFromAssistiveTechnology(overlayContainer); + } } + this._attachDialogContent(componentOrTemplateRef, dialogRef, dialogContainer, config); (this.openDialogs as DialogRef[]).push(dialogRef); dialogRef.closed.subscribe(() => this._removeOpenDialog(dialogRef, true)); this.afterOpened.next(dialogRef); @@ -233,14 +243,14 @@ export class Dialog implements OnDestroy { overlay: OverlayRef, dialogRef: DialogRef, config: DialogConfig>, - ): BasePortalOutlet { + ): DialogContainer { const userInjector = config.injector || config.viewContainerRef?.injector; const providers: StaticProvider[] = [ {provide: DialogConfig, useValue: config}, {provide: DialogRef, useValue: dialogRef}, {provide: OverlayRef, useValue: overlay}, ]; - let containerType: Type; + let containerType: Type; if (config.container) { if (typeof config.container === 'function') { @@ -274,7 +284,7 @@ export class Dialog implements OnDestroy { private _attachDialogContent( componentOrTemplateRef: ComponentType | TemplateRef, dialogRef: DialogRef, - dialogContainer: BasePortalOutlet, + dialogContainer: DialogContainer, config: DialogConfig>, ) { if (componentOrTemplateRef instanceof TemplateRef) { @@ -316,7 +326,7 @@ export class Dialog implements OnDestroy { private _createInjector( config: DialogConfig>, dialogRef: DialogRef, - dialogContainer: BasePortalOutlet, + dialogContainer: DialogContainer, fallbackInjector: Injector | undefined, ): Injector { const userInjector = config.injector || config.viewContainerRef?.injector; @@ -379,9 +389,7 @@ export class Dialog implements OnDestroy { } /** Hides all of the content that isn't an overlay from assistive technology. */ - private _hideNonDialogContentFromAssistiveTechnology() { - const overlayContainer = this._overlayContainer.getContainerElement(); - + private _hideNonDialogContentFromAssistiveTechnology(overlayContainer: HTMLElement) { // Ensure that the overlay container is attached to the DOM. if (overlayContainer.parentElement) { const siblings = overlayContainer.parentElement.children; diff --git a/src/cdk/drag-drop/BUILD.bazel b/src/cdk/drag-drop/BUILD.bazel index 9b0680d0e269..d33b90d980e5 100644 --- a/src/cdk/drag-drop/BUILD.bazel +++ b/src/cdk/drag-drop/BUILD.bazel @@ -6,6 +6,7 @@ load( "sass_binary", "ts_project", ) +load("//tools/adev-api-extraction:extract_api_to_json.bzl", "extract_api_to_json") package(default_visibility = ["//visibility:public"]) @@ -68,5 +69,20 @@ markdown_to_html( filegroup( name = "source-files", - srcs = glob(["**/*.ts"]), + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), +) + +extract_api_to_json( + name = "json_api", + srcs = [ + ":source-files", + ], + entry_point = ":index.ts", + module_name = "@angular/cdk/drag-drop", + output_name = "cdk_drag_drop.json", + private_modules = [""], + repo = "angular/components", ) diff --git a/src/cdk/drag-drop/directives/drop-list-shared.spec.ts b/src/cdk/drag-drop/directives/drop-list-shared.spec.ts index 20393fd6788a..a3ed399ffd21 100644 --- a/src/cdk/drag-drop/directives/drop-list-shared.spec.ts +++ b/src/cdk/drag-drop/directives/drop-list-shared.spec.ts @@ -1,14 +1,3 @@ -import {Directionality} from '../../bidi'; -import {Platform, _supportsShadowDom} from '../../platform'; -import {CdkScrollable, ViewportRuler} from '../../scrolling'; -import { - createMouseEvent, - createTouchEvent, - dispatchEvent, - dispatchFakeEvent, - dispatchMouseEvent, - dispatchTouchEvent, -} from '../../testing/private'; import { AfterViewInit, ChangeDetectionStrategy, @@ -25,20 +14,33 @@ import { signal, } from '@angular/core'; import {ComponentFixture, TestBed, fakeAsync, flush, tick} from '@angular/core/testing'; -import {of as observableOf} from 'rxjs'; +import {Platform, _supportsShadowDom} from '../../platform'; +import {CdkScrollable, ViewportRuler} from '../../scrolling'; +import { + createMouseEvent, + createTouchEvent, + dispatchEvent, + dispatchFakeEvent, + dispatchMouseEvent, + dispatchTouchEvent, +} from '../../testing/private'; import {extendStyles} from '../dom/styling'; import {CdkDragDrop, CdkDragEnter, CdkDragStart} from '../drag-events'; import {DragRef, Point, PreviewContainer} from '../drag-ref'; import {moveItemInArray} from '../drag-utils'; +import {provideFakeDirectionality} from '@angular/cdk/testing/private/fake-directionality'; +import {NgClass, NgFor, NgIf, NgTemplateOutlet} from '@angular/common'; import {CDK_DRAG_CONFIG, DragAxis, DragDropConfig, DropListOrientation} from './config'; import {CdkDrag} from './drag'; +import {CdkDragPlaceholder} from './drag-placeholder'; +import {CdkDragPreview} from './drag-preview'; import {CdkDropList} from './drop-list'; import {CdkDropListGroup} from './drop-list-group'; import { - createComponent as _createComponent, DragDropTestConfig, + createComponent as _createComponent, continueDraggingViaTouch, dragElementViaMouse, makeScrollable, @@ -47,9 +49,6 @@ import { stopDraggingViaTouch, tickAnimationFrames, } from './test-utils.spec'; -import {NgClass, NgFor, NgIf, NgTemplateOutlet} from '@angular/common'; -import {CdkDragPreview} from './drag-preview'; -import {CdkDragPlaceholder} from './drag-placeholder'; export const ITEM_HEIGHT = 25; export const ITEM_WIDTH = 75; @@ -551,12 +550,7 @@ export function defineCommonDropListTests(config: { it('should dispatch the correct `dropped` event in RTL horizontal drop zone', fakeAsync(() => { const fixture = createComponent(DraggableInHorizontalDropZone, { - providers: [ - { - provide: Directionality, - useValue: {value: 'rtl', change: observableOf()}, - }, - ], + providers: [provideFakeDirectionality('rtl')], }); fixture.nativeElement.setAttribute('dir', 'rtl'); @@ -812,7 +806,7 @@ export function defineCommonDropListTests(config: { startDraggingViaMouse(fixture, item); const anchor = Array.from(list.childNodes).find( - node => node.textContent === 'cdk-drag-anchor', + node => node.textContent === 'cdk-drag-marker', ); expect(anchor).toBeTruthy(); @@ -1200,12 +1194,7 @@ export function defineCommonDropListTests(config: { it('should pass the proper direction to the preview in rtl', fakeAsync(() => { const fixture = createComponent(DraggableInDropZone, { - providers: [ - { - provide: Directionality, - useValue: {value: 'rtl', change: observableOf()}, - }, - ], + providers: [provideFakeDirectionality('rtl')], }); fixture.detectChanges(); @@ -2500,12 +2489,7 @@ export function defineCommonDropListTests(config: { it('should auto-scroll right if the user holds their pointer at right edge in rtl', fakeAsync(() => { const fixture = createComponent(DraggableInScrollableHorizontalDropZone, { - providers: [ - { - provide: Directionality, - useValue: {value: 'rtl', change: observableOf()}, - }, - ], + providers: [provideFakeDirectionality('rtl')], }); fixture.nativeElement.setAttribute('dir', 'rtl'); fixture.detectChanges(); @@ -2529,12 +2513,7 @@ export function defineCommonDropListTests(config: { it('should auto-scroll left if the user holds their pointer at left edge in rtl', fakeAsync(() => { const fixture = createComponent(DraggableInScrollableHorizontalDropZone, { - providers: [ - { - provide: Directionality, - useValue: {value: 'rtl', change: observableOf()}, - }, - ], + providers: [provideFakeDirectionality('rtl')], }); fixture.nativeElement.setAttribute('dir', 'rtl'); fixture.detectChanges(); @@ -4761,6 +4740,166 @@ export function defineCommonDropListTests(config: { ); })); }); + + describe('with an anchor', () => { + function getAnchor(container: HTMLElement) { + return container.querySelector('.cdk-drag-anchor'); + } + + function getPlaceholder(container: HTMLElement) { + return container.querySelector('.cdk-drag-placeholder'); + } + + it('should create and manage the anchor element when the item is moved into a new container', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.componentInstance.hasAnchor.set(true); + fixture.detectChanges(); + + const groups = fixture.componentInstance.groupedDragItems; + const [sourceContainer, targetContainer] = Array.from( + fixture.nativeElement.querySelectorAll('.cdk-drop-list'), + ); + const item = groups[0][1]; + const targetRect = groups[1][2].element.nativeElement.getBoundingClientRect(); + const x = targetRect.left + 1; + const y = targetRect.top + 1; + + expect(getAnchor(fixture.nativeElement)).toBeFalsy(); + expect(getPlaceholder(fixture.nativeElement)).toBeFalsy(); + + startDraggingViaMouse(fixture, item.element.nativeElement); + expect(getAnchor(sourceContainer)).toBeFalsy(); + expect(getPlaceholder(sourceContainer)).toBeTruthy(); + + dispatchMouseEvent(document, 'mousemove', x, y); + fixture.detectChanges(); + const anchor = getAnchor(sourceContainer)!; + expect(anchor).toBeTruthy(); + expect(anchor.textContent).toContain('One'); + expect(anchor.classList).toContain('cdk-drag-anchor'); + expect(anchor.classList).not.toContain('cdk-drag-placeholder'); + expect(getAnchor(targetContainer)).toBeFalsy(); + expect(getPlaceholder(targetContainer)).toBeTruthy(); + + dispatchMouseEvent(document, 'mouseup', x, y); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(getAnchor(fixture.nativeElement)).toBeFalsy(); + expect(getPlaceholder(fixture.nativeElement)).toBeFalsy(); + })); + + it('should remove the anchor when the item is returned to the initial container', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.componentInstance.hasAnchor.set(true); + fixture.detectChanges(); + + const groups = fixture.componentInstance.groupedDragItems; + const [sourceContainer, targetContainer] = Array.from( + fixture.nativeElement.querySelectorAll('.cdk-drop-list'), + ); + const item = groups[0][1]; + const sourceRect = sourceContainer.getBoundingClientRect(); + const targetRect = targetContainer.getBoundingClientRect(); + + expect(getAnchor(fixture.nativeElement)).toBeFalsy(); + expect(getPlaceholder(fixture.nativeElement)).toBeFalsy(); + + startDraggingViaMouse(fixture, item.element.nativeElement); + expect(getAnchor(sourceContainer)).toBeFalsy(); + expect(getPlaceholder(sourceContainer)).toBeTruthy(); + + // Move into the second container. + dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); + fixture.detectChanges(); + expect(getAnchor(sourceContainer)).toBeTruthy(); + expect(getAnchor(targetContainer)).toBeFalsy(); + expect(getPlaceholder(sourceContainer)).toBeFalsy(); + expect(getPlaceholder(targetContainer)).toBeTruthy(); + + // Move back into the source container. + dispatchMouseEvent(document, 'mousemove', sourceRect.left + 1, sourceRect.top + 1); + fixture.detectChanges(); + expect(getAnchor(sourceContainer)).toBeFalsy(); + expect(getAnchor(targetContainer)).toBeFalsy(); + expect(getPlaceholder(sourceContainer)).toBeTruthy(); + expect(getPlaceholder(targetContainer)).toBeFalsy(); + + dispatchMouseEvent(document, 'mouseup', sourceRect.left + 1, sourceRect.top + 1); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(getAnchor(fixture.nativeElement)).toBeFalsy(); + expect(getPlaceholder(fixture.nativeElement)).toBeFalsy(); + })); + + it('should keep the anchor inside the initial container as the item is moved between containers', fakeAsync(() => { + const fixture = createComponent(ConnectedDropZones); + fixture.detectChanges(); + + // By default the drop zones are stacked on top of each other. + // Lay them out horizontally so the coordinates aren't changing while dragging. + fixture.nativeElement.style.display = 'flex'; + fixture.nativeElement.style.alignItems = 'flex-start'; + + // The extra zone isn't connected to the others by default. + fixture.componentInstance.todoConnectedTo.set([ + fixture.componentInstance.dropInstances.get(1)!, + fixture.componentInstance.dropInstances.get(2)!, + ]); + fixture.componentInstance.hasAnchor.set(true); + fixture.detectChanges(); + + const groups = fixture.componentInstance.groupedDragItems; + const [sourceContainer, secondContainer, thirdContainer] = Array.from( + fixture.nativeElement.querySelectorAll('.cdk-drop-list'), + ); + const item = groups[0][1]; + const secondRect = secondContainer.getBoundingClientRect(); + const thirdRect = thirdContainer.getBoundingClientRect(); + + expect(getAnchor(fixture.nativeElement)).toBeFalsy(); + expect(getPlaceholder(fixture.nativeElement)).toBeFalsy(); + + startDraggingViaMouse(fixture, item.element.nativeElement); + expect(getAnchor(sourceContainer)).toBeFalsy(); + expect(getPlaceholder(sourceContainer)).toBeTruthy(); + + // Move to the second container. + dispatchMouseEvent(document, 'mousemove', secondRect.left + 1, secondRect.top + 1); + fixture.detectChanges(); + expect(getAnchor(sourceContainer)).toBeTruthy(); + expect(getAnchor(secondContainer)).toBeFalsy(); + expect(getAnchor(thirdContainer)).toBeFalsy(); + + expect(getPlaceholder(sourceContainer)).toBeFalsy(); + expect(getPlaceholder(secondContainer)).toBeTruthy(); + expect(getPlaceholder(thirdContainer)).toBeFalsy(); + + // Move to the third container. + dispatchMouseEvent(document, 'mousemove', thirdRect.left + 1, thirdRect.top + 1); + fixture.detectChanges(); + expect(getAnchor(sourceContainer)).toBeTruthy(); + expect(getAnchor(secondContainer)).toBeFalsy(); + expect(getAnchor(thirdContainer)).toBeFalsy(); + + expect(getPlaceholder(sourceContainer)).toBeFalsy(); + expect(getPlaceholder(secondContainer)).toBeFalsy(); + expect(getPlaceholder(thirdContainer)).toBeTruthy(); + + // Drop the item. + dispatchMouseEvent(document, 'mouseup', thirdRect.left + 1, thirdRect.top + 1); + fixture.detectChanges(); + + flush(); + fixture.detectChanges(); + + expect(getAnchor(fixture.nativeElement)).toBeFalsy(); + expect(getPlaceholder(fixture.nativeElement)).toBeFalsy(); + })); + }); } export function assertStartToEndSorting( @@ -5347,6 +5486,7 @@ const CONNECTED_DROP_ZONES_TEMPLATE = ` #todoZone="cdkDropList" [cdkDropListData]="todo" [cdkDropListConnectedTo]="todoConnectedTo() || [doneZone]" + [cdkDropListHasAnchor]="hasAnchor()" (cdkDropListDropped)="droppedSpy($event)" (cdkDropListEntered)="enteredSpy($event)"> @for (item of todo; track item) { @@ -5362,6 +5502,7 @@ const CONNECTED_DROP_ZONES_TEMPLATE = ` #doneZone="cdkDropList" [cdkDropListData]="done" [cdkDropListConnectedTo]="doneConnectedTo() || [todoZone]" + [cdkDropListHasAnchor]="hasAnchor()" (cdkDropListDropped)="droppedSpy($event)" (cdkDropListEntered)="enteredSpy($event)"> @for (item of done; track item) { @@ -5377,6 +5518,7 @@ const CONNECTED_DROP_ZONES_TEMPLATE = ` #extraZone="cdkDropList" [cdkDropListData]="extra" [cdkDropListConnectedTo]="extraConnectedTo()!" + [cdkDropListHasAnchor]="hasAnchor()" (cdkDropListDropped)="droppedSpy($event)" (cdkDropListEntered)="enteredSpy($event)"> @for (item of extra; track item) { @@ -5402,13 +5544,14 @@ export class ConnectedDropZones implements AfterViewInit { groupedDragItems: CdkDrag[][] = []; todo = ['Zero', 'One', 'Two', 'Three']; done = ['Four', 'Five', 'Six']; - extra = []; + extra: string[] = []; droppedSpy = jasmine.createSpy('dropped spy'); enteredSpy = jasmine.createSpy('entered spy'); itemEnteredSpy = jasmine.createSpy('item entered spy'); todoConnectedTo = signal<(CdkDropList | string)[] | undefined>(undefined); doneConnectedTo = signal<(CdkDropList | string)[] | undefined>(undefined); extraConnectedTo = signal<(CdkDropList | string)[] | undefined>(undefined); + hasAnchor = signal(false); ngAfterViewInit() { this.dropInstances.forEach((dropZone, index) => { diff --git a/src/cdk/drag-drop/directives/drop-list.ts b/src/cdk/drag-drop/directives/drop-list.ts index ce1008009475..06f235c3aca9 100644 --- a/src/cdk/drag-drop/directives/drop-list.ts +++ b/src/cdk/drag-drop/directives/drop-list.ts @@ -150,6 +150,20 @@ export class CdkDropList implements OnDestroy { */ @Input('cdkDropListElementContainer') elementContainerSelector: string | null; + /** + * By default when an item leaves its initial container, its placeholder will be transferred + * to the new container. If that's not desirable for your use case, you can enable this option + * which will clone the placeholder and leave it inside the original container. If the item is + * returned to the initial container, the anchor element will be removed automatically. + * + * The cloned placeholder can be styled by targeting the `cdk-drag-anchor` class. + * + * This option is useful in combination with `cdkDropListSortingDisabled` to implement copying + * behavior in a drop list. + */ + @Input({alias: 'cdkDropListHasAnchor', transform: booleanAttribute}) + hasAnchor: boolean; + /** Emits when the user drops an item inside the container. */ @Output('cdkDropListDropped') readonly dropped: EventEmitter> = new EventEmitter>(); @@ -339,6 +353,7 @@ export class CdkDropList implements OnDestroy { ref.sortingDisabled = this.sortingDisabled; ref.autoScrollDisabled = this.autoScrollDisabled; ref.autoScrollStep = coerceNumberProperty(this.autoScrollStep, 2); + ref.hasAnchor = this.hasAnchor; ref .connectedTo(siblings.filter(drop => drop && drop !== this).map(list => list._dropListRef)) .withOrientation(this.orientation); diff --git a/src/cdk/drag-drop/drag-drop.md b/src/cdk/drag-drop/drag-drop.md index 9832b56558fb..44d9f791d23d 100644 --- a/src/cdk/drag-drop/drag-drop.md +++ b/src/cdk/drag-drop/drag-drop.md @@ -82,6 +82,7 @@ by the directives: | `.cdk-drag-handle` | Class that is added to the host element of the cdkDragHandle directive. | | `.cdk-drag-preview` | This is the element that will be rendered next to the user's cursor as they're dragging an item in a sortable list. By default the element looks exactly like the element that is being dragged. | | `.cdk-drag-placeholder` | This is element that will be shown instead of the real element as it's being dragged inside a `cdkDropList`. By default this will look exactly like the element that is being sorted. | +| `.cdk-drag-anchor` | Only relevant when `cdkDropListHasAnchor` is enabled. Element indicating the position from which the dragged item started the drag sequence. | | `.cdk-drop-list-dragging` | A class that is added to `cdkDropList` while the user is dragging an item. | | `.cdk-drop-list-disabled` | A class that is added to `cdkDropList` when it is disabled. | | `.cdk-drop-list-receiving`| A class that is added to `cdkDropList` when it can receive an item that is being dragged inside a connected drop list. | @@ -173,6 +174,24 @@ sorting action. +### Copying items from one list to another +When the user starts dragging an item in a sortable list, by default the `cdkDropList` directive +will render out a placeholder element to show where the item will be dropped. If the item is dragged +into another list, the placeholder will be moved into the new list together with the item. + +If your use case calls for the item to remain in the original list, you can set the +`cdkDropListHasAnchor` input which will tell the `cdkDropList` to create an "anchor" element. The +anchor differs from the placeholder in that it will stay in the original container and won't move +to any subsequent containers that the item is dragged into. If the user moves the item back into +the original container, the anchor will be removed automatically. It can be styled by targeting +the `cdk-drag-anchor` CSS class. + +Combining `cdkDropListHasAnchor` and `cdkDropListSortingDisabled` makes it possible to construct a +list that user copies items from, but doesn't necessarily transfer out of (e.g. a product list and +a shopping cart). + + + ### Restricting movement within an element If you want to stop the user from being able to drag a `cdkDrag` element outside of another element, diff --git a/src/cdk/drag-drop/drag-ref.ts b/src/cdk/drag-drop/drag-ref.ts index 175e52348d27..7ec72ab499b7 100644 --- a/src/cdk/drag-drop/drag-ref.ts +++ b/src/cdk/drag-drop/drag-ref.ts @@ -85,6 +85,9 @@ const activeCapturingEventOptions = { */ const MOUSE_EVENT_IGNORE_TIME = 800; +/** Class applied to the drag placeholder. */ +const PLACEHOLDER_CLASS = 'cdk-drag-placeholder'; + // TODO(crisbeto): add an API for moving a draggable up/down the // list programmatically. Useful for keyboard controls. @@ -147,10 +150,15 @@ export class DragRef { private _pickupPositionOnPage: Point; /** - * Anchor node used to save the place in the DOM where the element was + * Marker node used to save the place in the DOM where the element was * picked up so that it can be restored at the end of the drag sequence. */ - private _anchor: Comment; + private _marker: Comment; + + /** + * Element indicating the position from which the item was picked up initially. + */ + private _anchor: HTMLElement | null = null; /** * CSS `transform` applied to the element when it isn't being dragged. We need a @@ -506,7 +514,7 @@ export class DragRef { this._rootElement?.remove(); } - this._anchor?.remove(); + this._marker?.remove(); this._destroyPreview(); this._destroyPlaceholder(); this._dragDropRegistry.removeDragItem(this); @@ -529,7 +537,7 @@ export class DragRef { this._ownerSVGElement = this._placeholderTemplate = this._previewTemplate = - this._anchor = + this._marker = this._parentDragRef = null!; } @@ -682,9 +690,10 @@ export class DragRef { /** Destroys the placeholder element and its ViewRef. */ private _destroyPlaceholder() { + this._anchor?.remove(); this._placeholder?.remove(); this._placeholderRef?.destroy(); - this._placeholder = this._placeholderRef = null!; + this._placeholder = this._anchor = this._placeholderRef = null!; } /** Handler for the `mousedown`/`touchstart` events. */ @@ -872,14 +881,14 @@ export class DragRef { const element = this._rootElement; const parent = element.parentNode as HTMLElement; const placeholder = (this._placeholder = this._createPlaceholderElement()); - const anchor = (this._anchor = - this._anchor || + const marker = (this._marker = + this._marker || this._document.createComment( - typeof ngDevMode === 'undefined' || ngDevMode ? 'cdk-drag-anchor' : '', + typeof ngDevMode === 'undefined' || ngDevMode ? 'cdk-drag-marker' : '', )); - // Insert an anchor node so that we can restore the element's position in the DOM. - parent.insertBefore(anchor, element); + // Insert a marker node so that we can restore the element's position in the DOM. + parent.insertBefore(marker, element); // There's no risk of transforms stacking when inside a drop container so // we can keep the initial transform up to date any time dragging starts. @@ -1012,7 +1021,7 @@ export class DragRef { // can throw off `NgFor` which does smart diffing and re-creates elements only when necessary, // while moving the existing elements in all other cases. toggleVisibility(this._rootElement, true, dragImportantProperties); - this._anchor.parentNode!.replaceChild(this._rootElement, this._anchor); + this._marker.parentNode!.replaceChild(this._rootElement, this._marker); this._destroyPreview(); this._destroyPlaceholder(); @@ -1081,19 +1090,23 @@ export class DragRef { if (newContainer && newContainer !== this._dropContainer) { this._ngZone.run(() => { + const exitIndex = this._dropContainer!.getItemIndex(this); + const nextItemElement = + this._dropContainer!.getItemAtIndex(exitIndex + 1)?.getVisibleElement() || null; + // Notify the old container that the item has left. this.exited.next({item: this, container: this._dropContainer!}); this._dropContainer!.exit(this); + this._conditionallyInsertAnchor(newContainer, this._dropContainer!, nextItemElement); // Notify the new container that the item has entered. this._dropContainer = newContainer!; this._dropContainer.enter( this, x, y, - newContainer === this._initialContainer && - // If we're re-entering the initial container and sorting is disabled, - // put item the into its starting index to begin with. - newContainer.sortingDisabled + // If we're re-entering the initial container and sorting is disabled, + // put item the into its starting index to begin with. + newContainer === this._initialContainer && newContainer.sortingDisabled ? this._initialIndex : undefined, ); @@ -1193,7 +1206,7 @@ export class DragRef { // Stop pointer events on the preview so the user can't // interact with it while the preview is animating. placeholder.style.pointerEvents = 'none'; - placeholder.classList.add('cdk-drag-placeholder'); + placeholder.classList.add(PLACEHOLDER_CLASS); return placeholder; } @@ -1585,6 +1598,36 @@ export class DragRef { return event.target && (event.target === handle || handle.contains(event.target as Node)); }); } + + /** Inserts the anchor element, if it's valid. */ + private _conditionallyInsertAnchor( + newContainer: DropListRef, + exitContainer: DropListRef, + nextItemElement: HTMLElement | null, + ) { + // Remove the anchor when returning to the initial container. + if (newContainer === this._initialContainer) { + this._anchor?.remove(); + this._anchor = null; + } else if (exitContainer === this._initialContainer && exitContainer.hasAnchor) { + // Insert the anchor when leaving the initial container. + const anchor = (this._anchor ??= deepCloneNode(this._placeholder)); + anchor.classList.remove(PLACEHOLDER_CLASS); + anchor.classList.add('cdk-drag-anchor'); + + // Clear the transform since the single-axis strategy uses transforms to sort the items. + anchor.style.transform = ''; + + // When the item leaves the initial container, the container's DOM will be restored to + // its original state, except for the dragged item which is removed. Insert the anchor in + // the position from which the item left so that the list looks consistent. + if (nextItemElement) { + nextItemElement.before(anchor); + } else { + coerceElement(exitContainer.element).appendChild(anchor); + } + } + } } /** Clamps a value between a minimum and a maximum. */ diff --git a/src/cdk/drag-drop/drop-list-ref.ts b/src/cdk/drag-drop/drop-list-ref.ts index ee94e4617793..f62ff93616ec 100644 --- a/src/cdk/drag-drop/drop-list-ref.ts +++ b/src/cdk/drag-drop/drop-list-ref.ts @@ -74,6 +74,11 @@ export class DropListRef { /** Number of pixels to scroll for each frame when auto-scrolling an element. */ autoScrollStep: number = 2; + /** + * Whether the items in the list should leave an anchor node when leaving the initial container. + */ + hasAnchor: boolean = false; + /** * Function that is used to determine whether an item * is allowed to be moved into a drop container. @@ -440,6 +445,16 @@ export class DropListRef { : this._draggables.indexOf(item); } + /** + * Gets the item at a specific index. + * @param index Index at which to retrieve the item. + */ + getItemAtIndex(index: number): DragRef | null { + return this._isDragging + ? this._sortStrategy.getItemAtIndex(index) + : this._draggables[index] || null; + } + /** * Whether the list is able to receive the item that * is currently being dragged inside a connected drop list. diff --git a/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts b/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts index 51b820ee8c24..4be396ab4143 100644 --- a/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts +++ b/src/cdk/drag-drop/sorting/drop-list-sort-strategy.ts @@ -33,5 +33,6 @@ export interface DropListSortStrategy { reset(): void; getActiveItemsSnapshot(): readonly DragRef[]; getItemIndex(item: DragRef): number; + getItemAtIndex(index: number): DragRef | null; updateOnScroll(topDifference: number, leftDifference: number): void; } diff --git a/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts b/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts index 9fbe5d4f3a16..f5cb575d8686 100644 --- a/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts +++ b/src/cdk/drag-drop/sorting/mixed-sort-strategy.ts @@ -222,6 +222,11 @@ export class MixedSortStrategy implements DropListSortStrategy { return this._activeItems.indexOf(item); } + /** Gets the item at a specific index. */ + getItemAtIndex(index: number): DragRef | null { + return this._activeItems[index] || null; + } + /** Used to notify the strategy that the scroll position has changed. */ updateOnScroll(): void { this._activeItems.forEach(item => { diff --git a/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts b/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts index b3c5858234f3..68c9c39e94d7 100644 --- a/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts +++ b/src/cdk/drag-drop/sorting/single-axis-sort-strategy.ts @@ -263,15 +263,12 @@ export class SingleAxisSortStrategy implements DropListSortStrategy { /** Gets the index of a specific item. */ getItemIndex(item: DragRef): number { - // Items are sorted always by top/left in the cache, however they flow differently in RTL. - // The rest of the logic still stands no matter what orientation we're in, however - // we need to invert the array when determining the index. - const items = - this.orientation === 'horizontal' && this.direction === 'rtl' - ? this._itemPositions.slice().reverse() - : this._itemPositions; + return this._getVisualItemPositions().findIndex(currentItem => currentItem.drag === item); + } - return items.findIndex(currentItem => currentItem.drag === item); + /** Gets the item at a specific index. */ + getItemAtIndex(index: number): DragRef | null { + return this._getVisualItemPositions()[index]?.drag || null; } /** Used to notify the strategy that the scroll position has changed. */ @@ -320,6 +317,15 @@ export class SingleAxisSortStrategy implements DropListSortStrategy { }); } + private _getVisualItemPositions() { + // Items are sorted always by top/left in the cache, however they flow differently in RTL. + // The rest of the logic still stands no matter what orientation we're in, however + // we need to invert the array when determining the index. + return this.orientation === 'horizontal' && this.direction === 'rtl' + ? this._itemPositions.slice().reverse() + : this._itemPositions; + } + /** * Gets the offset in pixels by which the item that is being dragged should be moved. * @param currentPosition Current position of the item. diff --git a/src/cdk/listbox/listbox.spec.ts b/src/cdk/listbox/listbox.spec.ts index a986f13eaf18..56a2817283d1 100644 --- a/src/cdk/listbox/listbox.spec.ts +++ b/src/cdk/listbox/listbox.spec.ts @@ -4,13 +4,9 @@ import {TestBed, fakeAsync, tick} from '@angular/core/testing'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {By} from '@angular/platform-browser'; import {dispatchFakeEvent, dispatchKeyboardEvent, dispatchMouseEvent} from '../testing/private'; -import {CdkListbox, CdkListboxModule, CdkOption, ListboxValueChangeEvent} from './index'; +import {CdkListbox, CdkOption, ListboxValueChangeEvent} from './index'; -function setupComponent(component: Type, imports: any[] = []) { - TestBed.configureTestingModule({ - imports: [CdkListboxModule, ...imports], - declarations: [component], - }); +function setupComponent(component: Type) { const fixture = TestBed.createComponent(component); fixture.detectChanges(); @@ -861,9 +857,7 @@ describe('CdkOption and CdkListbox', () => { describe('with FormControl', () => { it('should reflect disabled state of the FormControl', () => { - const {testComponent, fixture, listbox} = setupComponent(ListboxWithFormControl, [ - ReactiveFormsModule, - ]); + const {testComponent, fixture, listbox} = setupComponent(ListboxWithFormControl); testComponent.formControl.disable(); fixture.detectChanges(); @@ -871,9 +865,7 @@ describe('CdkOption and CdkListbox', () => { }); it('should update when FormControl value changes', () => { - const {testComponent, fixture, options} = setupComponent(ListboxWithFormControl, [ - ReactiveFormsModule, - ]); + const {testComponent, fixture, options} = setupComponent(ListboxWithFormControl); testComponent.formControl.setValue(['banana']); fixture.detectChanges(); @@ -881,9 +873,7 @@ describe('CdkOption and CdkListbox', () => { }); it('should update FormControl when selection changes', () => { - const {testComponent, fixture, optionEls} = setupComponent(ListboxWithFormControl, [ - ReactiveFormsModule, - ]); + const {testComponent, fixture, optionEls} = setupComponent(ListboxWithFormControl); const spy = jasmine.createSpy(); const subscription = testComponent.formControl.valueChanges.subscribe(spy); fixture.detectChanges(); @@ -898,9 +888,7 @@ describe('CdkOption and CdkListbox', () => { }); it('should update multi-select listbox when FormControl value changes', () => { - const {testComponent, fixture, options} = setupComponent(ListboxWithFormControl, [ - ReactiveFormsModule, - ]); + const {testComponent, fixture, options} = setupComponent(ListboxWithFormControl); testComponent.isMultiselectable = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); @@ -912,9 +900,7 @@ describe('CdkOption and CdkListbox', () => { }); it('should update FormControl when multi-selection listbox changes', () => { - const {testComponent, fixture, optionEls} = setupComponent(ListboxWithFormControl, [ - ReactiveFormsModule, - ]); + const {testComponent, fixture, optionEls} = setupComponent(ListboxWithFormControl); testComponent.isMultiselectable = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); @@ -935,9 +921,7 @@ describe('CdkOption and CdkListbox', () => { }); it('should throw when multiple values selected in single-select listbox', () => { - const {testComponent, fixture} = setupComponent(ListboxWithFormControl, [ - ReactiveFormsModule, - ]); + const {testComponent, fixture} = setupComponent(ListboxWithFormControl); expect(() => { testComponent.formControl.setValue(['orange', 'banana']); @@ -946,9 +930,7 @@ describe('CdkOption and CdkListbox', () => { }); it('should throw when an invalid value is selected', () => { - const {testComponent, fixture} = setupComponent(ListboxWithFormControl, [ - ReactiveFormsModule, - ]); + const {testComponent, fixture} = setupComponent(ListboxWithFormControl); testComponent.isMultiselectable = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); @@ -961,13 +943,13 @@ describe('CdkOption and CdkListbox', () => { it('should not throw on init with a preselected form control and a dynamic set of options', () => { expect(() => { - setupComponent(ListboxWithPreselectedFormControl, [ReactiveFormsModule]); + setupComponent(ListboxWithPreselectedFormControl); }).not.toThrow(); }); it('should throw on init if the preselected value is invalid', () => { expect(() => { - setupComponent(ListboxWithInvalidPreselectedFormControl, [ReactiveFormsModule]); + setupComponent(ListboxWithInvalidPreselectedFormControl); }).toThrowError('Listbox has selected values that do not match any of its options.'); }); }); @@ -1000,7 +982,7 @@ describe('CdkOption and CdkListbox', () => {
Peach
`, - standalone: false, + imports: [CdkListbox, CdkOption], }) class ListboxWithOptions { changedOption: CdkOption | null; @@ -1026,7 +1008,7 @@ class ListboxWithOptions { @Component({ template: `
`, - standalone: false, + imports: [CdkListbox], }) class ListboxWithNoOptions {} @@ -1042,7 +1024,7 @@ class ListboxWithNoOptions {}
Peach
`, - standalone: false, + imports: [CdkListbox, CdkOption, ReactiveFormsModule], }) class ListboxWithFormControl { formControl = new FormControl(); @@ -1058,7 +1040,7 @@ class ListboxWithFormControl { }
`, - standalone: false, + imports: [CdkListbox, CdkOption, ReactiveFormsModule], }) class ListboxWithPreselectedFormControl { options = ['a', 'b', 'c']; @@ -1073,7 +1055,7 @@ class ListboxWithPreselectedFormControl { }
`, - standalone: false, + imports: [CdkListbox, CdkOption, ReactiveFormsModule], }) class ListboxWithInvalidPreselectedFormControl { options = ['a', 'b', 'c']; @@ -1089,7 +1071,7 @@ class ListboxWithInvalidPreselectedFormControl {
  • 🍑
  • `, - standalone: false, + imports: [CdkListbox, CdkOption], }) class ListboxWithCustomTypeahead {} @@ -1103,7 +1085,7 @@ class ListboxWithCustomTypeahead {}
    Peach
    `, - standalone: false, + imports: [CdkListbox, CdkOption], }) class ListboxWithBoundValue { value = ['banana']; @@ -1120,7 +1102,7 @@ class ListboxWithBoundValue {
    Peach
    `, - standalone: false, + imports: [CdkListbox, CdkOption], }) class ListboxWithMultipleBoundValues { value = ['apple', 'banana']; @@ -1134,7 +1116,7 @@ class ListboxWithMultipleBoundValues { } `, - standalone: false, + imports: [CdkListbox, CdkOption], }) class ListboxWithObjectValues { fruits = [{name: 'Apple'}, {name: 'Orange'}, {name: 'Banana'}, {name: 'Peach'}]; diff --git a/src/cdk/menu/context-menu-trigger.spec.ts b/src/cdk/menu/context-menu-trigger.spec.ts index 3f262ccedd63..84a8043777af 100644 --- a/src/cdk/menu/context-menu-trigger.spec.ts +++ b/src/cdk/menu/context-menu-trigger.spec.ts @@ -1,6 +1,5 @@ -import {Component, ViewChild, ElementRef, Type, ViewChildren, QueryList} from '@angular/core'; -import {CdkMenuModule} from './menu-module'; -import {TestBed, waitForAsync, ComponentFixture} from '@angular/core/testing'; +import {Component, ViewChild, ElementRef, ViewChildren, QueryList} from '@angular/core'; +import {TestBed, ComponentFixture} from '@angular/core/testing'; import {CdkMenu} from './menu'; import {CdkContextMenuTrigger} from './context-menu-trigger'; import {dispatchKeyboardEvent, dispatchMouseEvent} from '../testing/private'; @@ -14,13 +13,6 @@ describe('CdkContextMenuTrigger', () => { describe('with simple context menu trigger', () => { let fixture: ComponentFixture; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [SimpleContextMenu], - }); - })); - beforeEach(() => { fixture = TestBed.createComponent(SimpleContextMenu); fixture.detectChanges(); @@ -164,13 +156,6 @@ describe('CdkContextMenuTrigger', () => { describe('nested context menu triggers', () => { let fixture: ComponentFixture; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [NestedContextMenu], - }); - })); - beforeEach(() => { fixture = TestBed.createComponent(NestedContextMenu); fixture.detectChanges(); @@ -279,13 +264,6 @@ describe('CdkContextMenuTrigger', () => { let fixture: ComponentFixture; let instance: ContextMenuWithSubmenu; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [ContextMenuWithSubmenu], - }); - })); - beforeEach(() => { fixture = TestBed.createComponent(ContextMenuWithSubmenu); fixture.detectChanges(); @@ -310,13 +288,6 @@ describe('CdkContextMenuTrigger', () => { let nativeMenuBar: HTMLElement; let nativeMenuBarTrigger: HTMLElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [ContextMenuWithMenuBarAndInlineMenu], - }); - })); - beforeEach(() => { fixture = TestBed.createComponent(ContextMenuWithMenuBarAndInlineMenu); fixture.detectChanges(); @@ -410,23 +381,10 @@ describe('CdkContextMenuTrigger', () => { }); describe('with shared triggered menu', () => { - /** - * Return a function which builds the given component and renders it. - * @param componentClass the component to create - */ - function createComponent(componentClass: Type) { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [componentClass], - }); - - const fixture = TestBed.createComponent(componentClass); + it('should allow a context menu and menubar trigger share a menu', () => { + const fixture = TestBed.createComponent(MenuBarAndContextTriggerShareMenu); fixture.detectChanges(); - return fixture; - } - it('should allow a context menu and menubar trigger share a menu', () => { - const fixture = createComponent(MenuBarAndContextTriggerShareMenu); expect(fixture.componentInstance.menus.length).toBe(0); fixture.componentInstance.menuBarTrigger.toggle(); fixture.detectChanges(); @@ -441,11 +399,6 @@ describe('CdkContextMenuTrigger', () => { }); it('should be able to pass data to the menu via the template context', () => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [ContextTriggerWithData], - }); - const fixture = TestBed.createComponent(ContextTriggerWithData); fixture.componentInstance.menuData = {message: 'Hello!'}; fixture.detectChanges(); @@ -467,7 +420,7 @@ describe('CdkContextMenuTrigger', () => { `, - standalone: false, + imports: [CdkContextMenuTrigger, CdkMenu, CdkMenuItem], }) class SimpleContextMenu { @ViewChild(CdkContextMenuTrigger) trigger: CdkContextMenuTrigger; @@ -496,7 +449,7 @@ class SimpleContextMenu {
    `, - standalone: false, + imports: [CdkContextMenuTrigger, CdkMenu], }) class NestedContextMenu { @ViewChild('cut_trigger', {read: ElementRef}) cutContext: ElementRef; @@ -522,7 +475,7 @@ class NestedContextMenu {
    `, - standalone: false, + imports: [CdkContextMenuTrigger, CdkMenuTrigger, CdkMenu, CdkMenuItem], }) class ContextMenuWithSubmenu { @ViewChild(CdkContextMenuTrigger, {read: ElementRef}) context: ElementRef; @@ -553,7 +506,7 @@ class ContextMenuWithSubmenu { `, - standalone: false, + imports: [CdkContextMenuTrigger, CdkMenuTrigger, CdkMenu, CdkMenuItem, CdkMenuBar], }) class ContextMenuWithMenuBarAndInlineMenu { @ViewChild(CdkMenuBar, {read: ElementRef}) nativeMenuBar: ElementRef; @@ -582,7 +535,7 @@ class ContextMenuWithMenuBarAndInlineMenu { `, - standalone: false, + imports: [CdkMenuBar, CdkContextMenuTrigger, CdkMenu, CdkMenuItem, CdkMenuTrigger], }) class MenuBarAndContextTriggerShareMenu { @ViewChild(CdkMenuTrigger) menuBarTrigger: CdkMenuTrigger; @@ -598,7 +551,7 @@ class MenuBarAndContextTriggerShareMenu {
    {{message}}
    `, - standalone: false, + imports: [CdkContextMenuTrigger, CdkMenu], }) class ContextTriggerWithData { @ViewChild(CdkContextMenuTrigger, {read: ElementRef}) triggerElement: ElementRef; diff --git a/src/cdk/menu/context-menu-trigger.ts b/src/cdk/menu/context-menu-trigger.ts index 2c2640d4b2c1..bf1b5a86ea67 100644 --- a/src/cdk/menu/context-menu-trigger.ts +++ b/src/cdk/menu/context-menu-trigger.ts @@ -11,7 +11,6 @@ import { ChangeDetectorRef, Directive, inject, - Injectable, Injector, Input, OnDestroy, @@ -28,7 +27,7 @@ import {_getEventTarget} from '../platform'; import {merge, partition} from 'rxjs'; import {skip, takeUntil, skipWhile} from 'rxjs/operators'; import {MENU_STACK, MenuStack} from './menu-stack'; -import {CdkMenuTriggerBase, MENU_TRIGGER} from './menu-trigger-base'; +import {CdkMenuTriggerBase, MENU_TRIGGER, MenuTracker} from './menu-trigger-base'; /** The preferred menu positions for the context menu. */ const CONTEXT_MENU_POSITIONS = STANDARD_DROPDOWN_BELOW_POSITIONS.map(position => { @@ -39,23 +38,11 @@ const CONTEXT_MENU_POSITIONS = STANDARD_DROPDOWN_BELOW_POSITIONS.map(position => return {...position, offsetX, offsetY}; }); -/** Tracks the last open context menu trigger across the entire application. */ -@Injectable({providedIn: 'root'}) -export class ContextMenuTracker { - /** The last open context menu trigger. */ - private static _openContextMenuTrigger?: CdkContextMenuTrigger; - - /** - * Close the previous open context menu and set the given one as being open. - * @param trigger The trigger for the currently open Context Menu. - */ - update(trigger: CdkContextMenuTrigger) { - if (ContextMenuTracker._openContextMenuTrigger !== trigger) { - ContextMenuTracker._openContextMenuTrigger?.close(); - ContextMenuTracker._openContextMenuTrigger = trigger; - } - } -} +/** + * @deprecated Will be removed. Use `MenuTracker` instead. + * @breaking-change 22.0.0 + */ +export {MenuTracker as ContextMenuTracker}; /** The coordinates where the context menu should open. */ export type ContextMenuCoordinates = {x: number; y: number}; @@ -85,7 +72,10 @@ export type ContextMenuCoordinates = {x: number; y: number}; export class CdkContextMenuTrigger extends CdkMenuTriggerBase implements OnDestroy { private readonly _injector = inject(Injector); private readonly _directionality = inject(Directionality, {optional: true}); - private readonly _contextMenuTracker = inject(ContextMenuTracker); + + /** The app's menu tracking registry */ + private readonly _menuTracker = inject(MenuTracker); + private readonly _changeDetectorRef = inject(ChangeDetectorRef); /** Whether the context menu is disabled. */ @@ -124,7 +114,7 @@ export class CdkContextMenuTrigger extends CdkMenuTriggerBase implements OnDestr // resulting in multiple stacked context menus being displayed. event.stopPropagation(); - this._contextMenuTracker.update(this); + this._menuTracker.update(this); this._open(event, {x: event.clientX, y: event.clientY}); // A context menu can be triggered via a mouse right click or a keyboard shortcut. diff --git a/src/cdk/menu/menu-base.ts b/src/cdk/menu/menu-base.ts index 1e51f35e0a8b..3fbe591977cd 100644 --- a/src/cdk/menu/menu-base.ts +++ b/src/cdk/menu/menu-base.ts @@ -141,6 +141,14 @@ export abstract class CdkMenuBase this.keyManager.setLastItemActive(); } + /** + * Sets the active item to the item at the specified index and focuses the newly active item. + * @param item The index of the item to be set as active, or the CdkMenuItem instance. + */ + setActiveMenuItem(item: number | CdkMenuItem) { + this.keyManager?.setActiveItem(item); + } + /** Gets the tabindex for this menu. */ _getTabIndex() { return this._tabIndexSignal(); diff --git a/src/cdk/menu/menu-trigger-base.ts b/src/cdk/menu/menu-trigger-base.ts index d0ba4728920a..7656603cc00a 100644 --- a/src/cdk/menu/menu-trigger-base.ts +++ b/src/cdk/menu/menu-trigger-base.ts @@ -10,6 +10,7 @@ import { Directive, EventEmitter, inject, + Injectable, InjectionToken, Injector, OnDestroy, @@ -42,6 +43,24 @@ export const MENU_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>( }, ); +/** Tracks the last open menu trigger across the entire application. */ +@Injectable({providedIn: 'root'}) +export class MenuTracker { + /** The last open menu trigger. */ + private static _openMenuTrigger?: CdkMenuTriggerBase; + + /** + * Close the previous open menu and set the given one as being open. + * @param trigger The trigger for the currently open Menu. + */ + update(trigger: CdkMenuTriggerBase) { + if (MenuTracker._openMenuTrigger !== trigger) { + MenuTracker._openMenuTrigger?.close(); + MenuTracker._openMenuTrigger = trigger; + } + } +} + /** * Abstract directive that implements shared logic common to all menu triggers. * This class can be extended to create custom menu trigger types. @@ -83,6 +102,9 @@ export abstract class CdkMenuTriggerBase implements OnDestroy { /** Context data to be passed along to the menu template */ menuData: unknown; + /** Close the opened menu. */ + abstract close(): void; + /** A reference to the overlay which manages the triggered menu */ protected overlayRef: OverlayRef | null = null; diff --git a/src/cdk/menu/menu-trigger.spec.ts b/src/cdk/menu/menu-trigger.spec.ts index fe821303eced..7571010df95d 100644 --- a/src/cdk/menu/menu-trigger.spec.ts +++ b/src/cdk/menu/menu-trigger.spec.ts @@ -1,13 +1,13 @@ import {ENTER, SPACE, TAB} from '../keycodes'; -import {Component, ElementRef, QueryList, Type, ViewChild, ViewChildren} from '@angular/core'; -import {ComponentFixture, TestBed, fakeAsync, tick, waitForAsync} from '@angular/core/testing'; +import {Component, ElementRef, QueryList, ViewChild, ViewChildren} from '@angular/core'; +import {ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {dispatchKeyboardEvent} from '../../cdk/testing/private'; import {CdkMenu} from './menu'; import {Menu} from './menu-interface'; import {CdkMenuItem} from './menu-item'; -import {CdkMenuModule} from './menu-module'; import {CdkMenuTrigger} from './menu-trigger'; +import {CdkMenuBar} from './menu-bar'; describe('MenuTrigger', () => { describe('on CdkMenuItem', () => { @@ -15,13 +15,6 @@ describe('MenuTrigger', () => { let menuItem: CdkMenuItem; let menuItemElement: HTMLButtonElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [TriggerForEmptyMenu], - }); - })); - beforeEach(() => { fixture = TestBed.createComponent(TriggerForEmptyMenu); fixture.detectChanges(); @@ -110,13 +103,6 @@ describe('MenuTrigger', () => { const setDocumentDirection = (dir: 'ltr' | 'rtl') => (document.dir = dir); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [MenuBarWithNestedSubMenus], - }); - })); - beforeEach(() => { fixture = TestBed.createComponent(MenuBarWithNestedSubMenus); detectChanges(); @@ -326,23 +312,9 @@ describe('MenuTrigger', () => { }); describe('with shared triggered menu', () => { - /** - * Return a function which builds the given component and renders it. - * @param componentClass the component to create - */ - function createComponent(componentClass: Type) { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [componentClass], - }); - - const fixture = TestBed.createComponent(componentClass); - fixture.detectChanges(); - return fixture; - } - it('should allow two triggers in different menubars to open the same menu', () => { - const fixture = createComponent(TriggersWithSameMenuDifferentMenuBars); + const fixture = TestBed.createComponent(TriggersWithSameMenuDifferentMenuBars); + fixture.detectChanges(); expect(fixture.componentInstance.menus.length).toBe(0); fixture.componentInstance.triggers.get(0)!.toggle(); fixture.detectChanges(); @@ -356,7 +328,8 @@ describe('MenuTrigger', () => { }); it('should allow two triggers in the same menubar open the same menu', () => { - const fixture = createComponent(TriggersWithSameMenuSameMenuBar); + const fixture = TestBed.createComponent(TriggersWithSameMenuSameMenuBar); + fixture.detectChanges(); expect(fixture.componentInstance.menus.length).toBe(0); fixture.componentInstance.triggers.get(0)!.toggle(); fixture.detectChanges(); @@ -370,7 +343,8 @@ describe('MenuTrigger', () => { }); it('should allow a trigger in a submenu references its parent menu', () => { - const fixture = createComponent(TriggerOpensItsMenu); + const fixture = TestBed.createComponent(TriggerOpensItsMenu); + fixture.detectChanges(); expect(fixture.componentInstance.menus.length).toBe(0); expect(fixture.componentInstance.triggers.length).toBe(1); fixture.componentInstance.triggers.get(0)!.toggle(); @@ -403,13 +377,6 @@ describe('MenuTrigger', () => { grabElementsForTesting(); }; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [StandaloneTriggerWithInlineMenu], - }); - })); - beforeEach(() => { fixture = TestBed.createComponent(StandaloneTriggerWithInlineMenu); detectChanges(); @@ -503,11 +470,6 @@ describe('MenuTrigger', () => { }); it('should be able to pass data to the menu via the template context', () => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [TriggerWithData], - }); - const fixture = TestBed.createComponent(TriggerWithData); fixture.componentInstance.menuData = {message: 'Hello!'}; fixture.detectChanges(); @@ -519,16 +481,8 @@ describe('MenuTrigger', () => { describe('null triggerFor', () => { let fixture: ComponentFixture; - let nativeTrigger: HTMLElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [TriggerWithNullValue], - }); - })); - beforeEach(() => { fixture = TestBed.createComponent(TriggerWithNullValue); nativeTrigger = fixture.componentInstance.nativeTrigger.nativeElement; @@ -562,11 +516,6 @@ describe('MenuTrigger', () => { }); it('should focus the first item when opening on click', fakeAsync(() => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [TriggersWithSameMenuDifferentMenuBars], - }); - const fixture = TestBed.createComponent(TriggersWithSameMenuDifferentMenuBars); fixture.detectChanges(); @@ -587,7 +536,7 @@ describe('MenuTrigger', () => {
    `, - standalone: false, + imports: [CdkMenuBar, CdkMenu, CdkMenuItem, CdkMenuTrigger], }) class TriggerForEmptyMenu { @ViewChild(CdkMenuTrigger) trigger: CdkMenuTrigger; @@ -612,7 +561,7 @@ class TriggerForEmptyMenu { `, - standalone: false, + imports: [CdkMenu, CdkMenuItem, CdkMenuTrigger, CdkMenuBar], }) class MenuBarWithNestedSubMenus { @ViewChildren(CdkMenu) menus: QueryList; @@ -639,7 +588,7 @@ class MenuBarWithNestedSubMenus { `, - standalone: false, + imports: [CdkMenu, CdkMenuItem, CdkMenuTrigger, CdkMenuBar], }) class TriggersWithSameMenuDifferentMenuBars { @ViewChildren(CdkMenuTrigger) triggers: QueryList; @@ -662,7 +611,7 @@ class TriggersWithSameMenuDifferentMenuBars { `, - standalone: false, + imports: [CdkMenu, CdkMenuItem, CdkMenuTrigger, CdkMenuBar], }) class TriggersWithSameMenuSameMenuBar { @ViewChildren(CdkMenuTrigger) triggers: QueryList; @@ -681,7 +630,7 @@ class TriggersWithSameMenuSameMenuBar { `, - standalone: false, + imports: [CdkMenu, CdkMenuItem, CdkMenuTrigger, CdkMenuBar], }) class TriggerOpensItsMenu { @ViewChildren(CdkMenuTrigger) triggers: QueryList; @@ -706,7 +655,7 @@ class TriggerOpensItsMenu { `, - standalone: false, + imports: [CdkMenu, CdkMenuItem, CdkMenuTrigger], }) class StandaloneTriggerWithInlineMenu { @ViewChild(CdkMenuItem, {read: ElementRef}) nativeTrigger: ElementRef; @@ -726,7 +675,7 @@ class StandaloneTriggerWithInlineMenu {
    {{message}}
    `, - standalone: false, + imports: [CdkMenu, CdkMenuTrigger], }) class TriggerWithData { menuData: unknown; @@ -736,7 +685,7 @@ class TriggerWithData { template: ` `, - standalone: false, + imports: [CdkMenuTrigger], }) class TriggerWithNullValue { @ViewChild(CdkMenuTrigger, {static: true}) diff --git a/src/cdk/menu/menu-trigger.ts b/src/cdk/menu/menu-trigger.ts index 6fa7c834fe74..d37ab81a4205 100644 --- a/src/cdk/menu/menu-trigger.ts +++ b/src/cdk/menu/menu-trigger.ts @@ -43,7 +43,7 @@ import {takeUntil} from 'rxjs/operators'; import {CDK_MENU, Menu} from './menu-interface'; import {PARENT_OR_NEW_MENU_STACK_PROVIDER} from './menu-stack'; import {MENU_AIM} from './menu-aim'; -import {CdkMenuTriggerBase, MENU_TRIGGER} from './menu-trigger-base'; +import {CdkMenuTriggerBase, MENU_TRIGGER, MenuTracker} from './menu-trigger-base'; import {eventDispatchesNativeClick} from './event-detection'; /** @@ -86,6 +86,9 @@ export class CdkMenuTrigger extends CdkMenuTriggerBase implements OnChanges, OnD private readonly _injector = inject(Injector); private _cleanupMouseenter: () => void; + /** The app's menu tracking registry */ + private readonly _menuTracker = inject(MenuTracker); + /** The parent menu this trigger belongs to. */ private readonly _parentMenu = inject(CDK_MENU, {optional: true}); @@ -109,6 +112,9 @@ export class CdkMenuTrigger extends CdkMenuTriggerBase implements OnChanges, OnD /** Open the attached menu. */ open() { + if (!this._parentMenu) { + this._menuTracker.update(this); + } if (!this.isOpen() && this.menuTemplateRef != null) { this.opened.next(); diff --git a/src/cdk/menu/menu.md b/src/cdk/menu/menu.md index 1c43b0e6f1a2..440614930ae3 100644 --- a/src/cdk/menu/menu.md +++ b/src/cdk/menu/menu.md @@ -240,4 +240,4 @@ Finally, keyboard interaction is supported as defined in the [ARIA menubar keybo [keyboard]: https://www.w3.org/TR/wai-aria-practices-1.1/#keyboard-interaction-12 'ARIA Menubar Keyboard Interaction' -[diagram]: https://material.angular.io/assets/img/menuaim.png 'Menu Aim Diagram' +[diagram]: https://material.angular.dev/assets/img/menuaim.png 'Menu Aim Diagram' diff --git a/src/cdk/menu/menu.spec.ts b/src/cdk/menu/menu.spec.ts index 9cb6983d73ec..c602ff677f22 100644 --- a/src/cdk/menu/menu.spec.ts +++ b/src/cdk/menu/menu.spec.ts @@ -502,6 +502,31 @@ describe('Menu', () => { })); }); }); + + describe('menu with active item', () => { + let fixture: ComponentFixture; + let nativeMenuItems: HTMLElement[]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [CdkMenuModule, MenuWithActiveItem], + }); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MenuWithActiveItem); + fixture.detectChanges(); + + nativeMenuItems = fixture.debugElement + .queryAll(By.directive(CdkMenuItem)) + .map(e => e.nativeElement); + }); + + it('should set the active item with setActiveMenuItem', () => { + fixture.componentInstance.menu.setActiveMenuItem(2); + expect(document.activeElement).toEqual(nativeMenuItems[2]); + }); + }); }); @Component({ @@ -655,3 +680,18 @@ class WithComplexNestedMenusOnBottom { @ViewChildren(CdkMenu) menus: QueryList; } + +@Component({ + template: ` +
    + + + + +
    + `, + imports: [CdkMenuModule], +}) +class MenuWithActiveItem { + @ViewChild(CdkMenu) menu: CdkMenu; +} diff --git a/src/cdk/overlay/overlay-directives.spec.ts b/src/cdk/overlay/overlay-directives.spec.ts index 434b57500c77..1969e38e05ed 100644 --- a/src/cdk/overlay/overlay-directives.spec.ts +++ b/src/cdk/overlay/overlay-directives.spec.ts @@ -1,10 +1,11 @@ -import {Directionality} from '../bidi'; -import {A, ESCAPE} from '../keycodes'; -import {Component, ElementRef, Injector, ViewChild} from '@angular/core'; -import {ComponentFixture, TestBed, fakeAsync, tick, waitForAsync} from '@angular/core/testing'; +import {Component, ElementRef, Injector, signal, ViewChild, WritableSignal} from '@angular/core'; +import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {Subject} from 'rxjs'; +import {Direction} from '../bidi'; +import {A, ESCAPE} from '../keycodes'; import {createKeyboardEvent, dispatchEvent, dispatchKeyboardEvent} from '../testing/private'; +import {provideFakeDirectionality} from '../testing/private/fake-directionality'; import { CdkConnectedOverlay, CdkOverlayOrigin, @@ -27,14 +28,16 @@ import { describe('Overlay directives', () => { let overlayContainerElement: HTMLElement; let fixture: ComponentFixture; - let dir: {value: string}; + let dir: WritableSignal; let scrolledSubject = new Subject(); beforeEach(() => { + dir = signal('ltr'); + TestBed.configureTestingModule({ imports: [OverlayModule, ConnectedOverlayDirectiveTest, ConnectedOverlayPropertyInitOrder], providers: [ - {provide: Directionality, useFactory: () => (dir = {value: 'ltr'})}, + provideFakeDirectionality(dir), { provide: ScrollDispatcher, useFactory: () => ({ @@ -115,7 +118,7 @@ describe('Overlay directives', () => { }); it('should set and update the `dir` attribute', () => { - dir.value = 'rtl'; + dir.set('rtl'); fixture.componentInstance.isOpen = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); @@ -130,7 +133,7 @@ describe('Overlay directives', () => { fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - dir.value = 'ltr'; + dir.set('ltr'); fixture.componentInstance.isOpen = true; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); diff --git a/src/cdk/overlay/overlay.spec.ts b/src/cdk/overlay/overlay.spec.ts index b723d2fe36f1..b63cefa746ef 100644 --- a/src/cdk/overlay/overlay.spec.ts +++ b/src/cdk/overlay/overlay.spec.ts @@ -9,7 +9,9 @@ import { Type, ViewChild, ViewContainerRef, + WritableSignal, inject, + signal, } from '@angular/core'; import { ComponentFixture, @@ -23,8 +25,8 @@ import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {Direction, Directionality} from '../bidi'; import {CdkPortal, ComponentPortal, TemplatePortal} from '../portal'; import {dispatchFakeEvent} from '../testing/private'; +import {provideFakeDirectionality} from '../testing/private/fake-directionality'; import { - createOverlayRef, Overlay, OverlayConfig, OverlayContainer, @@ -32,6 +34,7 @@ import { OverlayRef, PositionStrategy, ScrollStrategy, + createOverlayRef, } from './index'; describe('Overlay', () => { @@ -41,22 +44,15 @@ describe('Overlay', () => { let overlayContainerElement: HTMLElement; let overlayContainer: OverlayContainer; let viewContainerFixture: ComponentFixture; - let dir: Direction; + let dir: WritableSignal; let mockLocation: SpyLocation; function setup(imports: Type[] = []) { - dir = 'ltr'; + dir = signal('ltr'); TestBed.configureTestingModule({ imports: [OverlayModule, ...imports], providers: [ - { - provide: Directionality, - useFactory: () => { - const fakeDirectionality = {}; - Object.defineProperty(fakeDirectionality, 'value', {get: () => dir}); - return fakeDirectionality; - }, - }, + provideFakeDirectionality(dir), { provide: Location, useClass: SpyLocation, @@ -175,7 +171,7 @@ describe('Overlay', () => { }); it('should take the default direction from the global Directionality', () => { - dir = 'rtl'; + dir.set('rtl'); const overlayRef = createOverlayRef(injector); overlayRef.attach(componentPortal); @@ -365,12 +361,12 @@ describe('Overlay', () => { overlayContainer.ngOnDestroy(); - TestBed.resetTestingModule().configureTestingModule({ - imports: [OverlayModule], - providers: [CustomErrorHandler, {provide: ErrorHandler, useExisting: CustomErrorHandler}], - }); - - expect(() => TestBed.compileComponents()).not.toThrow(); + expect(() => { + TestBed.resetTestingModule().configureTestingModule({ + imports: [OverlayModule], + providers: [CustomErrorHandler, {provide: ErrorHandler, useExisting: CustomErrorHandler}], + }); + }).not.toThrow(); }); it('should keep the direction in sync with the passed in Directionality', () => { diff --git a/src/cdk/schematics/ng-generate/drag-drop/index.spec.ts b/src/cdk/schematics/ng-generate/drag-drop/index.spec.ts index d20459057d12..2c962db32dff 100644 --- a/src/cdk/schematics/ng-generate/drag-drop/index.spec.ts +++ b/src/cdk/schematics/ng-generate/drag-drop/index.spec.ts @@ -12,6 +12,7 @@ describe('CDK drag-drop schematic', () => { // updating the other tests as well because `createTestApp` is responsible for creating // the project. project: 'material', + module: 'app-module.ts', }; beforeEach(() => { @@ -21,7 +22,7 @@ describe('CDK drag-drop schematic', () => { it('should create drag-drop files and add them to module', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('drag-drop', baseOptions, app); - const moduleContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const moduleContent = getFileContent(tree, '/projects/material/src/app/app-module.ts'); const files = tree.files; expect(files).toContain('/projects/material/src/app/foo/foo.component.css'); @@ -36,7 +37,7 @@ describe('CDK drag-drop schematic', () => { it('should add drag-drop module', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('drag-drop', baseOptions, app); - const moduleContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const moduleContent = getFileContent(tree, '/projects/material/src/app/app-module.ts'); expect(moduleContent).toContain('DragDropModule'); }); @@ -45,7 +46,7 @@ describe('CDK drag-drop schematic', () => { it('should generate a standalone component', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('drag-drop', {...baseOptions, standalone: true}, app); - const module = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const module = getFileContent(tree, '/projects/material/src/app/app-module.ts'); const component = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); expect(module).not.toContain('DragDropModule'); @@ -58,7 +59,7 @@ describe('CDK drag-drop schematic', () => { it('should generate a component with no imports and standalone false', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('drag-drop', {...baseOptions, standalone: false}, app); - const module = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const module = getFileContent(tree, '/projects/material/src/app/app-module.ts'); const component = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); expect(module).toContain('DragDropModule'); @@ -74,7 +75,7 @@ describe('CDK drag-drop schematic', () => { const component = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); const test = getFileContent(tree, '/projects/material/src/app/foo/foo.component.spec.ts'); - expect(tree.exists('/projects/material/src/app/app.module.ts')).toBe(false); + expect(tree.exists('/projects/material/src/app/app-module.ts')).toBe(false); expect(component).toContain('imports: ['); diff --git a/src/cdk/schematics/utils/build-component.ts b/src/cdk/schematics/utils/build-component.ts index a3dba2f64f9c..1e1433266e16 100644 --- a/src/cdk/schematics/utils/build-component.ts +++ b/src/cdk/schematics/utils/build-component.ts @@ -194,7 +194,8 @@ export function buildComponent( options.standalone = await isStandaloneSchematic(host, options); if (!options.standalone) { - options.module = findModuleFromOptions(host, options); + // TODO: Remove ext option when the Angular CLI looks for both candidate locations. + options.module = findModuleFromOptions(host, {...options, moduleExt: 'module.ts'}); } const parsedPath = parseName(options.path!, options.name); diff --git a/src/cdk/scrolling/viewport-ruler.ts b/src/cdk/scrolling/viewport-ruler.ts index bd477c3ede4b..66ea77dd9f79 100644 --- a/src/cdk/scrolling/viewport-ruler.ts +++ b/src/cdk/scrolling/viewport-ruler.ts @@ -36,7 +36,7 @@ export class ViewportRuler implements OnDestroy { private readonly _change = new Subject(); /** Used to reference correct document/window */ - protected _document = inject(DOCUMENT, {optional: true})!; + protected _document = inject(DOCUMENT); constructor(...args: unknown[]); diff --git a/src/cdk/scrolling/virtual-scroll-viewport.html b/src/cdk/scrolling/virtual-scroll-viewport.html index 39f165cfff3c..bead4decd00b 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.html +++ b/src/cdk/scrolling/virtual-scroll-viewport.html @@ -10,4 +10,4 @@ so that the scrollbar captures the size of the entire data set. -->
    + [style.width]="_totalContentWidth()" [style.height]="_totalContentHeight()"> diff --git a/src/cdk/scrolling/virtual-scroll-viewport.spec.ts b/src/cdk/scrolling/virtual-scroll-viewport.spec.ts index a023cb718439..2357db6d4f29 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.spec.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.spec.ts @@ -7,7 +7,6 @@ import { ScrollingModule, } from '../scrolling'; import { - ApplicationRef, Component, Directive, TrackByFunction, @@ -814,25 +813,17 @@ describe('CdkVirtualScrollViewport', () => { })); describe('viewChange change detection behavior', () => { - let appRef: ApplicationRef; - - beforeEach(() => { - appRef = TestBed.inject(ApplicationRef); - }); - - it('should not run change detection if there are no viewChange listeners', fakeAsync(() => { + it('should not emit viewChange if there are no listeners', fakeAsync(() => { + const viewChangeSpy = spyOn(testComponent.virtualForOf.viewChange, 'next'); finishInit(fixture); testComponent.items = Array(10).fill(0); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); flush(); - spyOn(appRef, 'tick'); - viewport.scrollToIndex(5); triggerScroll(viewport); - - expect(appRef.tick).not.toHaveBeenCalled(); + expect(viewChangeSpy).not.toHaveBeenCalled(); })); }); }); diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index ac37f1be340a..693c3cd92553 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -23,6 +23,7 @@ import { OnInit, Optional, Output, + signal, ViewChild, ViewEncapsulation, } from '@angular/core'; @@ -137,10 +138,10 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On private _totalContentSize = 0; /** A string representing the `style.width` property value to be used for the spacer element. */ - _totalContentWidth = ''; + _totalContentWidth = signal(''); /** A string representing the `style.height` property value to be used for the spacer element. */ - _totalContentHeight = ''; + _totalContentHeight = signal(''); /** * The CSS transform applied to the rendered subset of items so that they appear within the bounds @@ -533,9 +534,11 @@ export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements On /** Calculates the `style.width` and `style.height` for the spacer element. */ private _calculateSpacerSize() { - this._totalContentHeight = - this.orientation === 'horizontal' ? '' : `${this._totalContentSize}px`; - this._totalContentWidth = - this.orientation === 'horizontal' ? `${this._totalContentSize}px` : ''; + this._totalContentHeight.set( + this.orientation === 'horizontal' ? '' : `${this._totalContentSize}px`, + ); + this._totalContentWidth.set( + this.orientation === 'horizontal' ? `${this._totalContentSize}px` : '', + ); } } diff --git a/src/cdk/scrolling/virtual-scroll-viewport.zone.spec.ts b/src/cdk/scrolling/virtual-scroll-viewport.zone.spec.ts index b423312ba20d..15c8dc8d8a1e 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.zone.spec.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.zone.spec.ts @@ -1,10 +1,11 @@ import { - ApplicationRef, Component, + Injector, NgZone, TrackByFunction, ViewChild, ViewEncapsulation, + afterNextRender, provideZoneChangeDetection, } from '@angular/core'; import {ComponentFixture, TestBed, fakeAsync, flush, waitForAsync} from '@angular/core/testing'; @@ -40,7 +41,6 @@ describe('CdkVirtualScrollViewport Zone.js intergation', () => { describe('viewChange change detection behavior', () => { it('should run change detection if there are any viewChange listeners', fakeAsync(() => { - const appRef = TestBed.inject(ApplicationRef); testComponent.virtualForOf.viewChange.subscribe(); finishInit(fixture); testComponent.items = Array(10).fill(0); @@ -48,12 +48,13 @@ describe('CdkVirtualScrollViewport Zone.js intergation', () => { fixture.detectChanges(); flush(); - spyOn(appRef, 'tick'); + const spy = jasmine.createSpy(); + afterNextRender(spy, {injector: TestBed.inject(Injector)}); viewport.scrollToIndex(5); triggerScroll(viewport); - expect(appRef.tick).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(1); })); }); }); diff --git a/src/cdk/scrolling/virtual-scrollable.ts b/src/cdk/scrolling/virtual-scrollable.ts index 52f2ae36b16f..78c3ceead357 100644 --- a/src/cdk/scrolling/virtual-scrollable.ts +++ b/src/cdk/scrolling/virtual-scrollable.ts @@ -12,7 +12,7 @@ import {CdkScrollable} from './scrollable'; export const VIRTUAL_SCROLLABLE = new InjectionToken('VIRTUAL_SCROLLABLE'); /** - * Extending the {@link CdkScrollable} to be used as scrolling container for virtual scrolling. + * Extending the `CdkScrollable` to be used as scrolling container for virtual scrolling. */ @Directive() export abstract class CdkVirtualScrollable extends CdkScrollable { diff --git a/src/cdk/stepper/stepper.ts b/src/cdk/stepper/stepper.ts index 66f99022eea9..06d82f8af560 100644 --- a/src/cdk/stepper/stepper.ts +++ b/src/cdk/stepper/stepper.ts @@ -32,6 +32,8 @@ import { booleanAttribute, numberAttribute, inject, + signal, + computed, } from '@angular/core'; import { ControlContainer, @@ -135,7 +137,13 @@ export class CdkStep implements OnChanges { @Input() stepControl: AbstractControl; /** Whether user has attempted to move away from the step. */ - interacted = false; + get interacted(): boolean { + return this._interacted(); + } + set interacted(value: boolean) { + this._interacted.set(value); + } + private _interacted = signal(false); /** Emits when the user has attempted to move away from the step. */ @Output('interacted') @@ -157,10 +165,24 @@ export class CdkStep implements OnChanges { @Input('aria-labelledby') ariaLabelledby: string; /** State of the step. */ - @Input() state: StepState; + @Input() + get state(): StepState { + return this._state()!; + } + set state(value: StepState) { + this._state.set(value); + } + private _state = signal(undefined); /** Whether the user can return to this step once it has been marked as completed. */ - @Input({transform: booleanAttribute}) editable: boolean = true; + @Input({transform: booleanAttribute}) + get editable(): boolean { + return this._editable()!; + } + set editable(value: boolean) { + this._editable.set(value); + } + private _editable = signal(true); /** Whether the completion of step is optional. */ @Input({transform: booleanAttribute}) optional: boolean = false; @@ -168,29 +190,72 @@ export class CdkStep implements OnChanges { /** Whether step is marked as completed. */ @Input({transform: booleanAttribute}) get completed(): boolean { - return this._completedOverride == null ? this._getDefaultCompleted() : this._completedOverride; + const override = this._completedOverride(); + const interacted = this._interacted(); + + if (override != null) { + return override; + } + + return interacted && (!this.stepControl || this.stepControl.valid); } set completed(value: boolean) { - this._completedOverride = value; + this._completedOverride.set(value); } - _completedOverride: boolean | null = null; + _completedOverride = signal(null); - private _getDefaultCompleted() { - return this.stepControl ? this.stepControl.valid && this.interacted : this.interacted; - } + /** Current index of the step within the stepper. */ + readonly index = signal(-1); + + /** Whether the step is selected. */ + readonly isSelected = computed(() => this._stepper.selectedIndex === this.index()); + + /** Type of indicator that should be shown for the step. */ + readonly indicatorType = computed(() => { + const selected = this.isSelected(); + const completed = this.completed; + const defaultState = this._state() ?? STEP_STATE.NUMBER; + const editable = this._editable(); + + if (this._showError() && this.hasError && !selected) { + return STEP_STATE.ERROR; + } + + if (this._displayDefaultIndicatorType) { + if (!completed || selected) { + return STEP_STATE.NUMBER; + } + return editable ? STEP_STATE.EDIT : STEP_STATE.DONE; + } else { + if (completed && !selected) { + return STEP_STATE.DONE; + } else if (completed && selected) { + return defaultState; + } + return editable && selected ? STEP_STATE.EDIT : defaultState; + } + }); + + /** Whether the user can navigate to the step. */ + readonly isNavigable = computed(() => { + const isSelected = this.isSelected(); + const isCompleted = this.completed; + return isCompleted || isSelected || !this._stepper.linear; + }); /** Whether step has an error. */ @Input({transform: booleanAttribute}) get hasError(): boolean { - return this._customError == null ? this._getDefaultError() : this._customError; + const customError = this._customError(); + return customError == null ? this._getDefaultError() : customError; } set hasError(value: boolean) { - this._customError = value; + this._customError.set(value); } - private _customError: boolean | null = null; + private _customError = signal(null); private _getDefaultError() { - return this.stepControl && this.stepControl.invalid && this.interacted; + return this.interacted && !!this.stepControl?.invalid; } constructor(...args: unknown[]); @@ -208,14 +273,14 @@ export class CdkStep implements OnChanges { /** Resets the step to its initial state. Note that this includes resetting form data. */ reset(): void { - this.interacted = false; + this._interacted.set(false); - if (this._completedOverride != null) { - this._completedOverride = false; + if (this._completedOverride() != null) { + this._completedOverride.set(false); } - if (this._customError != null) { - this._customError = false; + if (this._customError() != null) { + this._customError.set(false); } if (this.stepControl) { @@ -234,8 +299,8 @@ export class CdkStep implements OnChanges { } _markAsInteracted() { - if (!this.interacted) { - this.interacted = true; + if (!this._interacted()) { + this._interacted.set(true); this.interactedStream.emit(this); } } @@ -244,7 +309,7 @@ export class CdkStep implements OnChanges { _showError(): boolean { // We want to show the error state either if the user opted into/out of it using the // global options, or if they've explicitly set it through the `hasError` input. - return this._stepperOptions.showError ?? this._customError != null; + return this._stepperOptions.showError ?? this._customError() != null; } } @@ -281,7 +346,7 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { /** The index of the selected step. */ @Input({transform: numberAttribute}) get selectedIndex(): number { - return this._selectedIndex; + return this._selectedIndex(); } set selectedIndex(index: number) { if (this._steps) { @@ -290,21 +355,21 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { throw Error('cdkStepper: Cannot assign out-of-bounds value to `selectedIndex`.'); } - if (this._selectedIndex !== index) { + if (this.selectedIndex !== index) { this.selected?._markAsInteracted(); if ( !this._anyControlsInvalidOrPending(index) && - (index >= this._selectedIndex || this.steps.toArray()[index].editable) + (index >= this.selectedIndex || this.steps.toArray()[index].editable) ) { this._updateSelectedItemIndex(index); } } } else { - this._selectedIndex = index; + this._selectedIndex.set(index); } } - private _selectedIndex = 0; + private _selectedIndex = signal(0); /** The step that is selected. */ @Input() @@ -347,6 +412,7 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { .pipe(startWith(this._steps), takeUntil(this._destroyed)) .subscribe((steps: QueryList) => { this.steps.reset(steps.filter(step => step._stepper === this)); + this.steps.forEach((step, index) => step.index.set(index)); this.steps.notifyOnChanges(); }); } @@ -393,26 +459,26 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { .pipe(startWith(this._layoutDirection()), takeUntil(this._destroyed)) .subscribe(direction => this._keyManager?.withHorizontalOrientation(direction)); - this._keyManager.updateActiveItem(this._selectedIndex); + this._keyManager.updateActiveItem(this.selectedIndex); // No need to `takeUntil` here, because we're the ones destroying `steps`. this.steps.changes.subscribe(() => { if (!this.selected) { - this._selectedIndex = Math.max(this._selectedIndex - 1, 0); + this._selectedIndex.set(Math.max(this.selectedIndex - 1, 0)); } }); // The logic which asserts that the selected index is within bounds doesn't run before the // steps are initialized, because we don't how many steps there are yet so we may have an // invalid index on init. If that's the case, auto-correct to the default so we don't throw. - if (!this._isValidIndex(this._selectedIndex)) { - this._selectedIndex = 0; + if (!this._isValidIndex(this.selectedIndex)) { + this._selectedIndex.set(0); } // For linear step and selected index is greater than zero, // set all the previous steps to interacted so that we can navigate to previous steps. - if (this.linear && this._selectedIndex > 0) { - const visitedSteps = this.steps.toArray().slice(0, this._selectedIndex); + if (this.linear && this.selectedIndex > 0) { + const visitedSteps = this.steps.toArray().slice(0, this._selectedIndex()); for (const step of visitedSteps) { step._markAsInteracted(); @@ -430,12 +496,12 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { /** Selects and focuses the next step in list. */ next(): void { - this.selectedIndex = Math.min(this._selectedIndex + 1, this.steps.length - 1); + this.selectedIndex = Math.min(this._selectedIndex() + 1, this.steps.length - 1); } /** Selects and focuses the previous step in list. */ previous(): void { - this.selectedIndex = Math.max(this._selectedIndex - 1, 0); + this.selectedIndex = Math.max(this._selectedIndex() - 1, 0); } /** Resets the stepper to its initial state. Note that this includes clearing form data. */ @@ -462,7 +528,7 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { /** Returns position state of the step with the given index. */ _getAnimationDirection(index: number): StepContentPositionState { - const position = index - this._selectedIndex; + const position = index - this._selectedIndex(); if (position < 0) { return this._layoutDirection() === 'rtl' ? 'next' : 'previous'; } else if (position > 0) { @@ -471,60 +537,20 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { return 'current'; } - /** Returns the type of icon to be displayed. */ - _getIndicatorType(index: number, state: StepState = STEP_STATE.NUMBER): StepState { - const step = this.steps.toArray()[index]; - const isCurrentStep = this._isCurrentStep(index); - - return step._displayDefaultIndicatorType - ? this._getDefaultIndicatorLogic(step, isCurrentStep) - : this._getGuidelineLogic(step, isCurrentStep, state); - } - - private _getDefaultIndicatorLogic(step: CdkStep, isCurrentStep: boolean): StepState { - if (step._showError() && step.hasError && !isCurrentStep) { - return STEP_STATE.ERROR; - } else if (!step.completed || isCurrentStep) { - return STEP_STATE.NUMBER; - } else { - return step.editable ? STEP_STATE.EDIT : STEP_STATE.DONE; - } - } - - private _getGuidelineLogic( - step: CdkStep, - isCurrentStep: boolean, - state: StepState = STEP_STATE.NUMBER, - ): StepState { - if (step._showError() && step.hasError && !isCurrentStep) { - return STEP_STATE.ERROR; - } else if (step.completed && !isCurrentStep) { - return STEP_STATE.DONE; - } else if (step.completed && isCurrentStep) { - return state; - } else if (step.editable && isCurrentStep) { - return STEP_STATE.EDIT; - } else { - return state; - } - } - - private _isCurrentStep(index: number) { - return this._selectedIndex === index; - } - /** Returns the index of the currently-focused step header. */ - _getFocusIndex() { - return this._keyManager ? this._keyManager.activeItemIndex : this._selectedIndex; + _getFocusIndex(): number | null { + return this._keyManager ? this._keyManager.activeItemIndex : this._selectedIndex(); } private _updateSelectedItemIndex(newIndex: number): void { const stepsArray = this.steps.toArray(); + const selectedIndex = this._selectedIndex(); + this.selectionChange.emit({ selectedIndex: newIndex, - previouslySelectedIndex: this._selectedIndex, + previouslySelectedIndex: selectedIndex, selectedStep: stepsArray[newIndex], - previouslySelectedStep: stepsArray[this._selectedIndex], + previouslySelectedStep: stepsArray[selectedIndex], }); // If focus is inside the stepper, move it to the next header, otherwise it may become @@ -537,8 +563,8 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { : this._keyManager.updateActiveItem(newIndex); } - this._selectedIndex = newIndex; - this.selectedIndexChange.emit(this._selectedIndex); + this._selectedIndex.set(newIndex); + this.selectedIndexChange.emit(newIndex); this._stateChanged(); } @@ -569,7 +595,7 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { const isIncomplete = control ? control.invalid || control.pending || !step.interacted : !step.completed; - return isIncomplete && !step.optional && !step._completedOverride; + return isIncomplete && !step.optional && !step._completedOverride(); }); } diff --git a/src/cdk/table/table.md b/src/cdk/table/table.md index 3c141c9387f8..62d105ad649b 100644 --- a/src/cdk/table/table.md +++ b/src/cdk/table/table.md @@ -7,7 +7,7 @@ built. Because it enforces no opinions on these matters, developers have full co interaction patterns associated with the table. For a Material Design styled table, see the -[documentation for `MatTable`](https://material.angular.io/components/table) which builds on +[documentation for `MatTable`](https://material.angular.dev/components/table) which builds on top of the CDK data-table. @@ -204,5 +204,5 @@ selectors. For example, `` becomes ``; ``](https://material.angular.io/components/table) which includes +[documentation for ``](https://material.angular.dev/components/table) which includes the style support for this approach. diff --git a/src/cdk/table/table.spec.ts b/src/cdk/table/table.spec.ts index 534e4e146b11..08b792838694 100644 --- a/src/cdk/table/table.spec.ts +++ b/src/cdk/table/table.spec.ts @@ -35,26 +35,15 @@ import { getTableUnknownColumnError, getTableUnknownDataSourceError, } from './table-errors'; +import {NgClass} from '@angular/common'; describe('CdkTable', () => { let fixture: ComponentFixture; let component: any; let tableElement: HTMLElement; - function createComponent( - componentType: Type, - declarations: any[] = [], - ): ComponentFixture { - TestBed.configureTestingModule({ - imports: [CdkTableModule, BidiModule], - declarations: [componentType, ...declarations], - }); - - return TestBed.createComponent(componentType); - } - - function setupTableTestApp(componentType: Type, declarations: any[] = []) { - fixture = createComponent(componentType, declarations); + function setupTableTestApp(componentType: Type) { + fixture = TestBed.createComponent(componentType); component = fixture.componentInstance; fixture.detectChanges(); @@ -380,7 +369,7 @@ describe('CdkTable', () => { it('should not throw if `renderRows` is called too early', () => { // Note that we don't call `detectChanges` here, because we're testing specifically // what happens when `renderRows` is called before the first change detection run. - const fixture = createComponent(SimpleCdkTableApp); + const fixture = TestBed.createComponent(SimpleCdkTableApp); const table = fixture.debugElement.query(By.directive(CdkTable)) .componentInstance as CdkTable; expect(() => table.renderRows()).not.toThrow(); @@ -546,7 +535,7 @@ describe('CdkTable', () => { }); it('should render correctly when using native HTML tags', () => { - const thisFixture = createComponent(NativeHtmlTableApp); + const thisFixture = TestBed.createComponent(NativeHtmlTableApp); const thisTableElement = thisFixture.nativeElement.querySelector('table'); thisFixture.detectChanges(); @@ -559,7 +548,7 @@ describe('CdkTable', () => { }); it('defaults to table role in native HTML table', () => { - const fixture = createComponent(NativeHtmlTableApp); + const fixture = TestBed.createComponent(NativeHtmlTableApp); const tableElement = fixture.nativeElement.querySelector('table'); fixture.detectChanges(); expect(tableElement.getAttribute('role')).toBe('table'); @@ -579,7 +568,7 @@ describe('CdkTable', () => { }); it('should be able to nest tables', () => { - const thisFixture = createComponent(NestedHtmlTableApp); + const thisFixture = TestBed.createComponent(NestedHtmlTableApp); thisFixture.detectChanges(); const outerTable = thisFixture.nativeElement.querySelector('table'); const innerTable = outerTable.querySelector('table'); @@ -594,7 +583,7 @@ describe('CdkTable', () => { }); it('should be able to show a message when no data is being displayed in a native table', () => { - const thisFixture = createComponent(NativeHtmlTableApp); + const thisFixture = TestBed.createComponent(NativeHtmlTableApp); thisFixture.detectChanges(); // Assert that the data is inside the tbody specifically. @@ -618,7 +607,7 @@ describe('CdkTable', () => { }); it('should apply correct roles for native table elements', () => { - const thisFixture = createComponent(NativeHtmlTableApp); + const thisFixture = TestBed.createComponent(NativeHtmlTableApp); const thisTableElement: HTMLTableElement = thisFixture.nativeElement.querySelector('table'); thisFixture.detectChanges(); @@ -634,7 +623,7 @@ describe('CdkTable', () => { }); it('should hide thead/tfoot when there are no header/footer rows', () => { - const thisFixture = createComponent(NativeTableWithNoHeaderOrFooterRows); + const thisFixture = TestBed.createComponent(NativeTableWithNoHeaderOrFooterRows); const thisTableElement: HTMLTableElement = thisFixture.nativeElement.querySelector('table'); thisFixture.detectChanges(); @@ -686,23 +675,25 @@ describe('CdkTable', () => { }); it('should throw an error if two column definitions have the same name', () => { - expect(() => createComponent(DuplicateColumnDefNameCdkTableApp).detectChanges()).toThrowError( - getTableDuplicateColumnNameError('column_a').message, - ); + expect(() => + TestBed.createComponent(DuplicateColumnDefNameCdkTableApp).detectChanges(), + ).toThrowError(getTableDuplicateColumnNameError('column_a').message); }); it('should throw an error if a column definition is requested but not defined', () => { - expect(() => createComponent(MissingColumnDefCdkTableApp).detectChanges()).toThrowError( + expect(() => TestBed.createComponent(MissingColumnDefCdkTableApp).detectChanges()).toThrowError( getTableUnknownColumnError('column_a').message, ); }); it('should pick up columns that are indirect descendants', () => { - expect(() => createComponent(TableWithIndirectDescendantDefs).detectChanges()).not.toThrow(); + expect(() => + TestBed.createComponent(TableWithIndirectDescendantDefs).detectChanges(), + ).not.toThrow(); }); it('should throw an error if a column definition is requested but not defined after render', fakeAsync(() => { - const columnDefinitionMissingAfterRenderFixture = createComponent( + const columnDefinitionMissingAfterRenderFixture = TestBed.createComponent( MissingColumnDefAfterRenderCdkTableApp, ); expect(() => { @@ -713,9 +704,9 @@ describe('CdkTable', () => { })); it('should throw an error if the row definitions are missing', () => { - expect(() => createComponent(MissingAllRowDefsCdkTableApp).detectChanges()).toThrowError( - getTableMissingRowDefsError().message, - ); + expect(() => + TestBed.createComponent(MissingAllRowDefsCdkTableApp).detectChanges(), + ).toThrowError(getTableMissingRowDefsError().message); }); it('should not throw an error if columns are undefined on initialization', () => { @@ -772,7 +763,7 @@ describe('CdkTable', () => { }); it('should be able to register column, row, and header row definitions outside content', () => { - setupTableTestApp(OuterTableApp, [WrapperCdkTableApp]); + setupTableTestApp(OuterTableApp); // The first two columns were defined in the wrapped table component as content children, // while the injected columns were provided to the wrapped table from the outer component. @@ -787,7 +778,7 @@ describe('CdkTable', () => { }); it('should be able to register a no data row defined outside the table', () => { - setupTableTestApp(OuterTableApp, [WrapperCdkTableApp]); + setupTableTestApp(OuterTableApp); fixture.componentInstance.dataSource.data = []; fixture.detectChanges(); @@ -809,7 +800,9 @@ describe('CdkTable', () => { }); it('should error if there is row data that does not have a matching row template', fakeAsync(() => { - const whenRowWithoutDefaultFixture = createComponent(WhenRowWithoutDefaultCdkTableApp); + const whenRowWithoutDefaultFixture = TestBed.createComponent( + WhenRowWithoutDefaultCdkTableApp, + ); const data = whenRowWithoutDefaultFixture.componentInstance.dataSource.data; expect(() => { whenRowWithoutDefaultFixture.detectChanges(); @@ -819,7 +812,7 @@ describe('CdkTable', () => { })); it('should fail when multiple rows match data without multiTemplateDataRows', fakeAsync(() => { - let whenFixture = createComponent(WhenRowMultipleDefaultsCdkTableApp); + let whenFixture = TestBed.createComponent(WhenRowMultipleDefaultsCdkTableApp); expect(() => { whenFixture.detectChanges(); flush(); @@ -1742,7 +1735,7 @@ describe('CdkTable', () => { describe('with trackBy', () => { function createTestComponentWithTrackyByTable(trackByStrategy: string) { - fixture = createComponent(TrackByCdkTableApp); + fixture = TestBed.createComponent(TrackByCdkTableApp); component = fixture.componentInstance; component.trackByStrategy = trackByStrategy; @@ -1990,7 +1983,7 @@ describe('CdkTable', () => { }); it('should be able to show a message when no data is being displayed in the strategy ChangeDetectionOnPush', () => { - setupTableTestApp(WrapNativeHtmlTableAppOnPush, [NativeHtmlTableAppOnPush]); + setupTableTestApp(WrapNativeHtmlTableAppOnPush); expect(tableElement.querySelector('.cdk-no-data-row')).toBeFalsy(); @@ -2095,7 +2088,7 @@ class BooleanDataSource extends DataSource {
    No data
    `, - standalone: false, + imports: [CdkTableModule], }) class SimpleCdkTableApp { dataSource: FakeDataSource | undefined = new FakeDataSource(); @@ -2127,7 +2120,7 @@ class SimpleCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class CdkTableWithDifferentDataInputsApp { dataSource: DataSource | Observable | TestData[] | any = null; @@ -2148,7 +2141,7 @@ class CdkTableWithDifferentDataInputsApp { `, - standalone: false, + imports: [CdkTableModule], }) class BooleanRowCdkTableApp { dataSource = new BooleanDataSource(); @@ -2167,7 +2160,7 @@ class BooleanRowCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class NullDataCdkTableApp { dataSource = observableOf(null); @@ -2199,7 +2192,7 @@ class NullDataCdkTableApp {
    `, - standalone: false, + imports: [CdkTableModule], }) class MultipleHeaderFooterRowsCdkTableApp {} @@ -2252,7 +2245,7 @@ class MultipleHeaderFooterRowsCdkTableApp {} `, - standalone: false, + imports: [CdkTableModule], }) class WhenRowCdkTableApp { multiTemplateDataRows = false; @@ -2328,7 +2321,7 @@ class WhenRowCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class CoercedMultiTemplateDataRows extends WhenRowCdkTableApp {} @@ -2365,7 +2358,7 @@ class CoercedMultiTemplateDataRows extends WhenRowCdkTableApp {} `, - standalone: false, + imports: [CdkTableModule], }) class WhenRowWithoutDefaultCdkTableApp { dataSource: FakeDataSource = new FakeDataSource(); @@ -2410,7 +2403,7 @@ class WhenRowWithoutDefaultCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class WhenRowMultipleDefaultsCdkTableApp { dataSource: FakeDataSource = new FakeDataSource(); @@ -2432,7 +2425,7 @@ class WhenRowMultipleDefaultsCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class DynamicDataSourceCdkTableApp { dataSource: FakeDataSource | undefined; @@ -2458,7 +2451,7 @@ class DynamicDataSourceCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class TrackByCdkTableApp { trackByStrategy: 'reference' | 'propertyA' | 'index' = 'reference'; @@ -2543,7 +2536,7 @@ class StickyPositioningListenerTest implements StickyPositioningListener { } `, providers: [{provide: STICKY_POSITIONING_LISTENER, useExisting: StickyFlexLayoutCdkTableApp}], - standalone: false, + imports: [CdkTableModule, BidiModule], }) class StickyFlexLayoutCdkTableApp extends StickyPositioningListenerTest { dataSource: FakeDataSource = new FakeDataSource(); @@ -2600,7 +2593,7 @@ class StickyFlexLayoutCdkTableApp extends StickyPositioningListenerTest { } `, providers: [{provide: STICKY_POSITIONING_LISTENER, useExisting: StickyNativeLayoutCdkTableApp}], - standalone: false, + imports: [CdkTableModule], }) class StickyNativeLayoutCdkTableApp extends StickyPositioningListenerTest { dataSource: FakeDataSource = new FakeDataSource(); @@ -2632,7 +2625,7 @@ class StickyNativeLayoutCdkTableApp extends StickyPositioningListenerTest { `, - standalone: false, + imports: [CdkTableModule], }) class DynamicColumnDefinitionsCdkTableApp { dynamicColumns: any[] = []; @@ -2653,7 +2646,7 @@ class DynamicColumnDefinitionsCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class CustomRoleCdkTableApp { dataSource: FakeDataSource = new FakeDataSource(); @@ -2674,7 +2667,7 @@ class CustomRoleCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class CrazyColumnNameCdkTableApp { dataSource: FakeDataSource = new FakeDataSource(); @@ -2700,7 +2693,7 @@ class CrazyColumnNameCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class DuplicateColumnDefNameCdkTableApp { dataSource: FakeDataSource = new FakeDataSource(); @@ -2718,7 +2711,7 @@ class DuplicateColumnDefNameCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class MissingColumnDefCdkTableApp { dataSource: FakeDataSource = new FakeDataSource(); @@ -2736,7 +2729,7 @@ class MissingColumnDefCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class MissingColumnDefAfterRenderCdkTableApp implements AfterViewInit { dataSource: FakeDataSource | null = null; @@ -2760,7 +2753,7 @@ class MissingColumnDefAfterRenderCdkTableApp implements AfterViewInit { `, - standalone: false, + imports: [CdkTableModule], }) class MissingAllRowDefsCdkTableApp { dataSource: FakeDataSource = new FakeDataSource(); @@ -2779,7 +2772,7 @@ class MissingAllRowDefsCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class MissingHeaderRowDefCdkTableApp { dataSource: FakeDataSource = new FakeDataSource(); @@ -2798,7 +2791,7 @@ class MissingHeaderRowDefCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class MissingRowDefCdkTableApp { dataSource: FakeDataSource = new FakeDataSource(); @@ -2817,7 +2810,7 @@ class MissingRowDefCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class MissingFooterRowDefCdkTableApp { dataSource: FakeDataSource = new FakeDataSource(); @@ -2835,7 +2828,7 @@ class MissingFooterRowDefCdkTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class UndefinedColumnsCdkTableApp { undefinedColumns: string[]; @@ -2870,7 +2863,7 @@ class UndefinedColumnsCdkTableApp { `, - standalone: false, + imports: [CdkTableModule, NgClass], }) class RowContextCdkTableApp { dataSource: FakeDataSource = new FakeDataSource(); @@ -2896,12 +2889,12 @@ class RowContextCdkTableApp {
    No data
    `, - standalone: false, + imports: [CdkTableModule], }) class WrapperCdkTableApp implements AfterContentInit { - @ContentChildren(CdkColumnDef) columnDefs: QueryList; + @ContentChildren(CdkColumnDef, {descendants: false}) columnDefs: QueryList; @ContentChild(CdkHeaderRowDef) headerRowDef: CdkHeaderRowDef; - @ContentChildren(CdkRowDef) rowDefs: QueryList>; + @ContentChildren(CdkRowDef, {descendants: false}) rowDefs: QueryList>; @ContentChild(CdkNoDataRow) noDataRow: CdkNoDataRow; @ViewChild(CdkTable, {static: true}) table: CdkTable; @@ -2940,7 +2933,7 @@ class WrapperCdkTableApp implements AfterContentInit { `, - standalone: false, + imports: [CdkTableModule, WrapperCdkTableApp], }) class OuterTableApp { dataSource: FakeDataSource = new FakeDataSource(); @@ -2979,7 +2972,7 @@ class OuterTableApp {
    `, - standalone: false, + imports: [CdkTableModule], }) class NativeHtmlTableApp { dataSource: FakeDataSource | undefined = new FakeDataSource(); @@ -3030,7 +3023,7 @@ class NativeHtmlTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class NestedHtmlTableApp { dataSource: FakeDataSource | undefined = new FakeDataSource(); @@ -3058,7 +3051,7 @@ class NestedHtmlTableApp { `, - standalone: false, + imports: [CdkTableModule], }) class NativeTableWithNoHeaderOrFooterRows { dataSource: FakeDataSource | undefined = new FakeDataSource(); @@ -3080,7 +3073,7 @@ class NativeTableWithNoHeaderOrFooterRows { `, - standalone: false, + imports: [CdkTableModule], }) class NativeHtmlTableWithCaptionApp { dataSource: FakeDataSource | undefined = new FakeDataSource(); @@ -3109,7 +3102,7 @@ class NativeHtmlTableWithCaptionApp { `, - standalone: false, + imports: [CdkTableModule], }) class NativeHtmlTableWithColgroupAndCol { dataSource: FakeDataSource | undefined = new FakeDataSource(); @@ -3133,7 +3126,7 @@ class NativeHtmlTableWithColgroupAndCol { `, - standalone: false, + imports: [CdkTableModule], }) class TableWithIndirectDescendantDefs { dataSource = new FakeDataSource(); @@ -3166,7 +3159,7 @@ class TableWithIndirectDescendantDefs { `, changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false, + imports: [CdkTableModule], }) class NativeHtmlTableAppOnPush { @Input() dataSource: Observable | null = null; @@ -3177,7 +3170,7 @@ class NativeHtmlTableAppOnPush { template: ` `, - standalone: false, + imports: [NativeHtmlTableAppOnPush], }) class WrapNativeHtmlTableAppOnPush { dataSource: FakeDataSource = new FakeDataSource(); diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index 6debac739af8..a8ffcfabcbeb 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -389,7 +389,7 @@ export class CdkTable /** * Whether the sticky styler should recalculate cell widths when applying sticky styles. If * `false`, cached values will be used instead. This is only applicable to tables with - * {@link fixedLayout} enabled. For other tables, cell widths will always be recalculated. + * `_fixedLayout` enabled. For other tables, cell widths will always be recalculated. */ private _forceRecalculateCellWidths = true; diff --git a/src/cdk/testing/BUILD.bazel b/src/cdk/testing/BUILD.bazel index 6020eac3d830..4e8f56df7047 100644 --- a/src/cdk/testing/BUILD.bazel +++ b/src/cdk/testing/BUILD.bazel @@ -25,7 +25,10 @@ markdown_to_html( filegroup( name = "source-files", - srcs = glob(["**/*.ts"]), + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), ) ng_web_test_suite( @@ -58,4 +61,5 @@ extract_api_to_json( module_name = "@angular/cdk/testing", output_name = "cdk_testing.json", private_modules = [""], + repo = "angular/components", ) diff --git a/src/cdk/testing/change-detection.ts b/src/cdk/testing/change-detection.ts index e7ea3c38e648..458fed5cb700 100644 --- a/src/cdk/testing/change-detection.ts +++ b/src/cdk/testing/change-detection.ts @@ -8,7 +8,11 @@ import {BehaviorSubject, Subscription} from 'rxjs'; -/** Represents the status of auto change detection. */ +/** + * The status of the test harness auto change detection. If not diabled test harnesses will + * automatically trigger change detection after every action (such as a click) and before every read + * (such as getting the text of an element). + */ export interface AutoChangeDetectionStatus { /** Whether auto change detection is disabled. */ isDisabled: boolean; diff --git a/src/cdk/testing/component-harness.ts b/src/cdk/testing/component-harness.ts index f8b167def6b9..646ac6ef1edc 100644 --- a/src/cdk/testing/component-harness.ts +++ b/src/cdk/testing/component-harness.ts @@ -9,7 +9,12 @@ import {parallel} from './change-detection'; import {TestElement} from './test-element'; -/** An async function that returns a promise when called. */ +/** + * An async function that returns a promise when called. + * @deprecated This was just an alias for `() => Promise`. Use that instead. + * @breaking-change 21.0.0 Remove this alias. + * @docs-private + */ export type AsyncFactoryFn = () => Promise; /** An async function that takes an item and returns a boolean promise */ @@ -38,15 +43,23 @@ export type HarnessQuery = * Since we don't know for sure which query will match, the result type if the union of the types * for all possible results. * - * e.g. + * @usageNotes + * ### Example + * * The type: - * `LocatorFnResult<[ - * ComponentHarnessConstructor<MyHarness>, - * HarnessPredicate<MyOtherHarness>, + * ```ts + * LocatorFnResult<[ + * ComponentHarnessConstructor, + * HarnessPredicate, * string - * ]>` + * ]> + * ``` + * * is equivalent to: - * `MyHarness | MyOtherHarness | TestElement`. + * + * ```ts + * MyHarness | MyOtherHarness | TestElement + * ``` */ export type LocatorFnResult | string)[]> = { [I in keyof T]: T[I] extends new (...args: any[]) => infer C // Map `ComponentHarnessConstructor` to `C`. @@ -106,6 +119,17 @@ export interface HarnessLoader { */ getHarnessOrNull(query: HarnessQuery): Promise; + /** + * Searches for an instance of the component corresponding to the given harness type under the + * `HarnessLoader`'s root element, and returns a `ComponentHarness` for the instance on the page + * at the given index. If no matching component exists at that index, an error is thrown. + * @param query A query for a harness to create + * @param index The zero-indexed offset of the matching component instance to return + * @return An instance of the given harness type. + * @throws If a matching component instance can't be found at the given index. + */ + getHarnessAtIndex(query: HarnessQuery, index: number): Promise; + /** * Searches for all instances of the component corresponding to the given harness type under the * `HarnessLoader`'s root element, and returns a list `ComponentHarness` for each instance. @@ -114,6 +138,14 @@ export interface HarnessLoader { */ getAllHarnesses(query: HarnessQuery): Promise; + /** + * Searches for all instances of the component corresponding to the given harness type under the + * `HarnessLoader`'s root element, and returns the total count of all matching components. + * @param query A query for a harness to create + * @return An integer indicating the number of instances that were found. + */ + countHarnesses(query: HarnessQuery): Promise; + /** * Searches for an instance of the component corresponding to the given harness type under the * `HarnessLoader`'s root element, and returns a boolean indicating if any were found. @@ -138,6 +170,21 @@ export interface LocatorFactory { /** * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance * or element under the root element of this `LocatorFactory`. + * + * For example, given the following DOM and assuming `DivHarness.hostSelector` is `'div'` + * + * ```html + *
    + * ``` + * + * then we expect: + * + * ```ts + * await lf.locatorFor(DivHarness, 'div')() // Gets a `DivHarness` instance for #d1 + * await lf.locatorFor('div', DivHarness)() // Gets a `TestElement` instance for #d1 + * await lf.locatorFor('span')() // Throws because the `Promise` rejects + * ``` + * * @param queries A list of queries specifying which harnesses and elements to search for: * - A `string` searches for elements matching the CSS selector specified by the string. * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the @@ -149,20 +196,29 @@ export interface LocatorFactory { * order in the DOM, and second by order in the queries list. If no matches are found, the * `Promise` rejects. The type that the `Promise` resolves to is a union of all result types for * each query. - * - * e.g. Given the following DOM: `
    `, and assuming - * `DivHarness.hostSelector === 'div'`: - * - `await lf.locatorFor(DivHarness, 'div')()` gets a `DivHarness` instance for `#d1` - * - `await lf.locatorFor('div', DivHarness)()` gets a `TestElement` instance for `#d1` - * - `await lf.locatorFor('span')()` throws because the `Promise` rejects. */ locatorFor | string)[]>( ...queries: T - ): AsyncFactoryFn>; + ): () => Promise>; /** * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance * or element under the root element of this `LocatorFactory`. + * + * For example, given the following DOM and assuming `DivHarness.hostSelector` is `'div'` + * + * ```html + *
    + * ``` + * + * then we expect: + * + * ```ts + * await lf.locatorForOptional(DivHarness, 'div')() // Gets a `DivHarness` instance for #d1 + * await lf.locatorForOptional('div', DivHarness)() // Gets a `TestElement` instance for #d1 + * await lf.locatorForOptional('span')() // Gets `null` + * ``` + * * @param queries A list of queries specifying which harnesses and elements to search for: * - A `string` searches for elements matching the CSS selector specified by the string. * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the @@ -174,20 +230,35 @@ export interface LocatorFactory { * order in the DOM, and second by order in the queries list. If no matches are found, the * `Promise` is resolved with `null`. The type that the `Promise` resolves to is a union of all * result types for each query or null. - * - * e.g. Given the following DOM: `
    `, and assuming - * `DivHarness.hostSelector === 'div'`: - * - `await lf.locatorForOptional(DivHarness, 'div')()` gets a `DivHarness` instance for `#d1` - * - `await lf.locatorForOptional('div', DivHarness)()` gets a `TestElement` instance for `#d1` - * - `await lf.locatorForOptional('span')()` gets `null`. */ locatorForOptional | string)[]>( ...queries: T - ): AsyncFactoryFn | null>; + ): () => Promise | null>; /** * Creates an asynchronous locator function that can be used to find `ComponentHarness` instances * or elements under the root element of this `LocatorFactory`. + * + * For example, given the following DOM and assuming `DivHarness.hostSelector` is `'div'` and + * `IdIsD1Harness.hostSelector` is `'#d1'` + * + * ```html + *
    + * ``` + * + * then we expect: + * + * ```ts + * // Gets [DivHarness for #d1, TestElement for #d1, DivHarness for #d2, TestElement for #d2] + * await lf.locatorForAll(DivHarness, 'div')() + * // Gets [TestElement for #d1, TestElement for #d2] + * await lf.locatorForAll('div', '#d1')() + * // Gets [DivHarness for #d1, IdIsD1Harness for #d1, DivHarness for #d2] + * await lf.locatorForAll(DivHarness, IdIsD1Harness)() + * // Gets [] + * await lf.locatorForAll('span')() + * ``` + * * @param queries A list of queries specifying which harnesses and elements to search for: * - A `string` searches for elements matching the CSS selector specified by the string. * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the @@ -201,29 +272,10 @@ export interface LocatorFactory { * an element matches multiple `string` selectors, only one `TestElement` instance is returned * for that element. The type that the `Promise` resolves to is an array where each element is * the union of all result types for each query. - * - * e.g. Given the following DOM: `
    `, and assuming - * `DivHarness.hostSelector === 'div'` and `IdIsD1Harness.hostSelector === '#d1'`: - * - `await lf.locatorForAll(DivHarness, 'div')()` gets `[ - * DivHarness, // for #d1 - * TestElement, // for #d1 - * DivHarness, // for #d2 - * TestElement // for #d2 - * ]` - * - `await lf.locatorForAll('div', '#d1')()` gets `[ - * TestElement, // for #d1 - * TestElement // for #d2 - * ]` - * - `await lf.locatorForAll(DivHarness, IdIsD1Harness)()` gets `[ - * DivHarness, // for #d1 - * IdIsD1Harness, // for #d1 - * DivHarness // for #d2 - * ]` - * - `await lf.locatorForAll('span')()` gets `[]`. */ locatorForAll | string)[]>( ...queries: T - ): AsyncFactoryFn[]>; + ): () => Promise[]>; /** @return A `HarnessLoader` rooted at the root element of this `LocatorFactory`. */ rootHarnessLoader(): Promise; @@ -266,9 +318,8 @@ export interface LocatorFactory { } /** - * Base class for component harnesses that all component harness authors should extend. This base - * component harness provides the basic ability to locate element and sub-component harness. It - * should be inherited when defining user's own harness. + * Base class for component test harnesses that all component harness authors should extend. This + * base component harness provides the basic ability to locate element and sub-component harnesses. */ export abstract class ComponentHarness { constructor(protected readonly locatorFactory: LocatorFactory) {} @@ -290,6 +341,21 @@ export abstract class ComponentHarness { /** * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance * or element under the host element of this `ComponentHarness`. + * + * For example, given the following DOM and assuming `DivHarness.hostSelector` is `'div'` + * + * ```html + *
    + * ``` + * + * then we expect: + * + * ```ts + * await ch.locatorFor(DivHarness, 'div')() // Gets a `DivHarness` instance for #d1 + * await ch.locatorFor('div', DivHarness)() // Gets a `TestElement` instance for #d1 + * await ch.locatorFor('span')() // Throws because the `Promise` rejects + * ``` + * * @param queries A list of queries specifying which harnesses and elements to search for: * - A `string` searches for elements matching the CSS selector specified by the string. * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the @@ -301,22 +367,31 @@ export abstract class ComponentHarness { * order in the DOM, and second by order in the queries list. If no matches are found, the * `Promise` rejects. The type that the `Promise` resolves to is a union of all result types for * each query. - * - * e.g. Given the following DOM: `
    `, and assuming - * `DivHarness.hostSelector === 'div'`: - * - `await ch.locatorFor(DivHarness, 'div')()` gets a `DivHarness` instance for `#d1` - * - `await ch.locatorFor('div', DivHarness)()` gets a `TestElement` instance for `#d1` - * - `await ch.locatorFor('span')()` throws because the `Promise` rejects. */ protected locatorFor | string)[]>( ...queries: T - ): AsyncFactoryFn> { + ): () => Promise> { return this.locatorFactory.locatorFor(...queries); } /** * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance * or element under the host element of this `ComponentHarness`. + * + * For example, given the following DOM and assuming `DivHarness.hostSelector` is `'div'` + * + * ```html + *
    + * ``` + * + * then we expect: + * + * ```ts + * await ch.locatorForOptional(DivHarness, 'div')() // Gets a `DivHarness` instance for #d1 + * await ch.locatorForOptional('div', DivHarness)() // Gets a `TestElement` instance for #d1 + * await ch.locatorForOptional('span')() // Gets `null` + * ``` + * * @param queries A list of queries specifying which harnesses and elements to search for: * - A `string` searches for elements matching the CSS selector specified by the string. * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the @@ -328,22 +403,37 @@ export abstract class ComponentHarness { * order in the DOM, and second by order in the queries list. If no matches are found, the * `Promise` is resolved with `null`. The type that the `Promise` resolves to is a union of all * result types for each query or null. - * - * e.g. Given the following DOM: `
    `, and assuming - * `DivHarness.hostSelector === 'div'`: - * - `await ch.locatorForOptional(DivHarness, 'div')()` gets a `DivHarness` instance for `#d1` - * - `await ch.locatorForOptional('div', DivHarness)()` gets a `TestElement` instance for `#d1` - * - `await ch.locatorForOptional('span')()` gets `null`. */ protected locatorForOptional | string)[]>( ...queries: T - ): AsyncFactoryFn | null> { + ): () => Promise | null> { return this.locatorFactory.locatorForOptional(...queries); } /** * Creates an asynchronous locator function that can be used to find `ComponentHarness` instances * or elements under the host element of this `ComponentHarness`. + * + * For example, given the following DOM and assuming `DivHarness.hostSelector` is `'div'` and + * `IdIsD1Harness.hostSelector` is `'#d1'` + * + * ```html + *
    + * ``` + * + * then we expect: + * + * ```ts + * // Gets [DivHarness for #d1, TestElement for #d1, DivHarness for #d2, TestElement for #d2] + * await ch.locatorForAll(DivHarness, 'div')() + * // Gets [TestElement for #d1, TestElement for #d2] + * await ch.locatorForAll('div', '#d1')() + * // Gets [DivHarness for #d1, IdIsD1Harness for #d1, DivHarness for #d2] + * await ch.locatorForAll(DivHarness, IdIsD1Harness)() + * // Gets [] + * await ch.locatorForAll('span')() + * ``` + * * @param queries A list of queries specifying which harnesses and elements to search for: * - A `string` searches for elements matching the CSS selector specified by the string. * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the @@ -357,29 +447,10 @@ export abstract class ComponentHarness { * an element matches multiple `string` selectors, only one `TestElement` instance is returned * for that element. The type that the `Promise` resolves to is an array where each element is * the union of all result types for each query. - * - * e.g. Given the following DOM: `
    `, and assuming - * `DivHarness.hostSelector === 'div'` and `IdIsD1Harness.hostSelector === '#d1'`: - * - `await ch.locatorForAll(DivHarness, 'div')()` gets `[ - * DivHarness, // for #d1 - * TestElement, // for #d1 - * DivHarness, // for #d2 - * TestElement // for #d2 - * ]` - * - `await ch.locatorForAll('div', '#d1')()` gets `[ - * TestElement, // for #d1 - * TestElement // for #d2 - * ]` - * - `await ch.locatorForAll(DivHarness, IdIsD1Harness)()` gets `[ - * DivHarness, // for #d1 - * IdIsD1Harness, // for #d1 - * DivHarness // for #d2 - * ]` - * - `await ch.locatorForAll('span')()` gets `[]`. */ protected locatorForAll | string)[]>( ...queries: T - ): AsyncFactoryFn[]> { + ): () => Promise[]> { return this.locatorFactory.locatorForAll(...queries); } @@ -409,26 +480,86 @@ export abstract class ContentContainerComponentHarness { return (await this.getRootHarnessLoader()).getChildLoader(selector); } + /** + * Gets a list of `HarnessLoader` for each element matching the given selector under the current + * harness's cotnent that searches for harnesses under that element. + * @param selector The selector for elements in the component's content. + * @returns A list of `HarnessLoader` for each element matching the given selector. + */ async getAllChildLoaders(selector: S): Promise { return (await this.getRootHarnessLoader()).getAllChildLoaders(selector); } + /** + * Gets the first matching harness for the given query within the current harness's content. + * @param query The harness query to search for. + * @returns The first harness matching the given query. + * @throws If no matching harness is found. + */ async getHarness(query: HarnessQuery): Promise { return (await this.getRootHarnessLoader()).getHarness(query); } + /** + * Gets the first matching harness for the given query within the current harness's content. + * @param query The harness query to search for. + * @returns The first harness matching the given query, or null if none is found. + */ async getHarnessOrNull(query: HarnessQuery): Promise { return (await this.getRootHarnessLoader()).getHarnessOrNull(query); } + /** + * Gets a matching harness for the given query and index within the current harness's content. + * @param query The harness query to search for. + * @param index The zero-indexed offset of the component to find. + * @returns The first harness matching the given query. + * @throws If no matching harness is found. + */ + async getHarnessAtIndex( + query: HarnessQuery, + index: number, + ): Promise { + return (await this.getRootHarnessLoader()).getHarnessAtIndex(query, index); + } + + /** + * Gets all matching harnesses for the given query within the current harness's content. + * @param query The harness query to search for. + * @returns The list of harness matching the given query. + */ async getAllHarnesses(query: HarnessQuery): Promise { return (await this.getRootHarnessLoader()).getAllHarnesses(query); } + /** + * Returns the number of matching harnesses for the given query within the current harness's + * content. + * + * @param query The harness query to search for. + * @returns The number of matching harnesses for the given query. + */ + async countHarnesses(query: HarnessQuery): Promise { + return (await this.getRootHarnessLoader()).countHarnesses(query); + } + + /** + * Checks whether there is a matching harnesses for the given query within the current harness's + * content. + * + * @param query The harness query to search for. + * @returns Whether there is matching harnesses for the given query. + */ async hasHarness(query: HarnessQuery): Promise { return (await this.getRootHarnessLoader()).hasHarness(query); } @@ -442,7 +573,10 @@ export abstract class ContentContainerComponentHarness { new (locatorFactory: LocatorFactory): T; @@ -463,8 +597,8 @@ export interface BaseHarnessFilters { } /** - * A class used to associate a ComponentHarness class with predicates functions that can be used to - * filter instances of the class. + * A class used to associate a ComponentHarness class with predicate functions that can be used to + * filter instances of the class to be matched. */ export class HarnessPredicate { private _predicates: AsyncPredicate[] = []; diff --git a/src/cdk/testing/element-dimensions.ts b/src/cdk/testing/element-dimensions.ts index dc6396440824..64590a6c93be 100644 --- a/src/cdk/testing/element-dimensions.ts +++ b/src/cdk/testing/element-dimensions.ts @@ -10,8 +10,12 @@ * Dimensions for element size and its position relative to the viewport. */ export interface ElementDimensions { + /** The distance from the top of the viewport in pixels */ top: number; + /** The distance from the left of the viewport in pixels */ left: number; + /** The width of the element in pixels */ width: number; + /** The height of the element in pixels */ height: number; } diff --git a/src/cdk/testing/harness-environment.ts b/src/cdk/testing/harness-environment.ts index 647cfab56c78..a2fdb2228c40 100644 --- a/src/cdk/testing/harness-environment.ts +++ b/src/cdk/testing/harness-environment.ts @@ -8,7 +8,6 @@ import {parallel} from './change-detection'; import { - AsyncFactoryFn, ComponentHarness, ComponentHarnessConstructor, HarnessLoader, @@ -44,7 +43,7 @@ type ParsedQueries = { * element type, `E`, used by the particular test environment. */ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFactory { - // Implemented as part of the `LocatorFactory` interface. + /** The root element of this `HarnessEnvironment` as a `TestElement`. */ get rootElement(): TestElement { this._rootElement = this._rootElement || this.createTestElement(this.rawRootElement); return this._rootElement; @@ -54,17 +53,49 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac } private _rootElement: TestElement | undefined; - protected constructor(protected rawRootElement: E) {} + protected constructor( + /** The native root element of this `HarnessEnvironment`. */ + protected rawRootElement: E, + ) {} - // Implemented as part of the `LocatorFactory` interface. + /** Gets a locator factory rooted at the document root. */ documentRootLocatorFactory(): LocatorFactory { return this.createEnvironment(this.getDocumentRoot()); } - // Implemented as part of the `LocatorFactory` interface. + /** + * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance + * or element under the root element of this `HarnessEnvironment`. + * + * For example, given the following DOM and assuming `DivHarness.hostSelector` is `'div'` + * + * ```html + *
    + * ``` + * + * then we expect: + * + * ```ts + * await lf.locatorFor(DivHarness, 'div')() // Gets a `DivHarness` instance for #d1 + * await lf.locatorFor('div', DivHarness)() // Gets a `TestElement` instance for #d1 + * await lf.locatorFor('span')() // Throws because the `Promise` rejects + * ``` + * + * @param queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the CSS selector specified by the string. + * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the + * given class. + * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given + * predicate. + * @return An asynchronous locator function that searches for and returns a `Promise` for the + * first element or harness matching the given search criteria. Matches are ordered first by + * order in the DOM, and second by order in the queries list. If no matches are found, the + * `Promise` rejects. The type that the `Promise` resolves to is a union of all result types for + * each query. + */ locatorFor | string)[]>( ...queries: T - ): AsyncFactoryFn> { + ): () => Promise> { return () => _assertResultFound( this._getAllHarnessesAndTestElements(queries), @@ -72,26 +103,97 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac ); } - // Implemented as part of the `LocatorFactory` interface. + /** + * Creates an asynchronous locator function that can be used to find a `ComponentHarness` instance + * or element under the root element of this `HarnessEnvironmnet`. + * + * For example, given the following DOM and assuming `DivHarness.hostSelector` is `'div'` + * + * ```html + *
    + * ``` + * + * then we expect: + * + * ```ts + * await lf.locatorForOptional(DivHarness, 'div')() // Gets a `DivHarness` instance for #d1 + * await lf.locatorForOptional('div', DivHarness)() // Gets a `TestElement` instance for #d1 + * await lf.locatorForOptional('span')() // Gets `null` + * ``` + * + * @param queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the CSS selector specified by the string. + * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the + * given class. + * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given + * predicate. + * @return An asynchronous locator function that searches for and returns a `Promise` for the + * first element or harness matching the given search criteria. Matches are ordered first by + * order in the DOM, and second by order in the queries list. If no matches are found, the + * `Promise` is resolved with `null`. The type that the `Promise` resolves to is a union of all + * result types for each query or null. + */ locatorForOptional | string)[]>( ...queries: T - ): AsyncFactoryFn | null> { + ): () => Promise | null> { return async () => (await this._getAllHarnessesAndTestElements(queries))[0] || null; } - // Implemented as part of the `LocatorFactory` interface. + /** + * Creates an asynchronous locator function that can be used to find `ComponentHarness` instances + * or elements under the root element of this `HarnessEnvironment`. + * + * For example, given the following DOM and assuming `DivHarness.hostSelector` is `'div'` and + * `IdIsD1Harness.hostSelector` is `'#d1'` + * + * ```html + *
    + * ``` + * + * then we expect: + * + * ```ts + * // Gets [DivHarness for #d1, TestElement for #d1, DivHarness for #d2, TestElement for #d2] + * await lf.locatorForAll(DivHarness, 'div')() + * // Gets [TestElement for #d1, TestElement for #d2] + * await lf.locatorForAll('div', '#d1')() + * // Gets [DivHarness for #d1, IdIsD1Harness for #d1, DivHarness for #d2] + * await lf.locatorForAll(DivHarness, IdIsD1Harness)() + * // Gets [] + * await lf.locatorForAll('span')() + * ``` + * + * @param queries A list of queries specifying which harnesses and elements to search for: + * - A `string` searches for elements matching the CSS selector specified by the string. + * - A `ComponentHarness` constructor searches for `ComponentHarness` instances matching the + * given class. + * - A `HarnessPredicate` searches for `ComponentHarness` instances matching the given + * predicate. + * @return An asynchronous locator function that searches for and returns a `Promise` for all + * elements and harnesses matching the given search criteria. Matches are ordered first by + * order in the DOM, and second by order in the queries list. If an element matches more than + * one `ComponentHarness` class, the locator gets an instance of each for the same element. If + * an element matches multiple `string` selectors, only one `TestElement` instance is returned + * for that element. The type that the `Promise` resolves to is an array where each element is + * the union of all result types for each query. + */ locatorForAll | string)[]>( ...queries: T - ): AsyncFactoryFn[]> { + ): () => Promise[]> { return () => this._getAllHarnessesAndTestElements(queries); } - // Implemented as part of the `LocatorFactory` interface. + /** @return A `HarnessLoader` rooted at the root element of this `HarnessEnvironment`. */ async rootHarnessLoader(): Promise { return this; } - // Implemented as part of the `LocatorFactory` interface. + /** + * Gets a `HarnessLoader` instance for an element under the root of this `HarnessEnvironment`. + * @param selector The selector for the root element. + * @return A `HarnessLoader` rooted at the first element matching the given selector. + * @throws If no matching element is found for the given selector. + */ async harnessLoaderFor(selector: string): Promise { return this.createEnvironment( await _assertResultFound(this.getAllRawElements(selector), [ @@ -100,39 +202,114 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac ); } - // Implemented as part of the `LocatorFactory` interface. + /** + * Gets a `HarnessLoader` instance for an element under the root of this `HarnessEnvironment`. + * @param selector The selector for the root element. + * @return A `HarnessLoader` rooted at the first element matching the given selector, or null if + * no matching element is found. + */ async harnessLoaderForOptional(selector: string): Promise { const elements = await this.getAllRawElements(selector); return elements[0] ? this.createEnvironment(elements[0]) : null; } - // Implemented as part of the `LocatorFactory` interface. + /** + * Gets a list of `HarnessLoader` instances, one for each matching element. + * @param selector The selector for the root element. + * @return A list of `HarnessLoader`, one rooted at each element matching the given selector. + */ async harnessLoaderForAll(selector: string): Promise { const elements = await this.getAllRawElements(selector); return elements.map(element => this.createEnvironment(element)); } - // Implemented as part of the `HarnessLoader` interface. + /** + * Searches for an instance of the component corresponding to the given harness type under the + * `HarnessEnvironment`'s root element, and returns a `ComponentHarness` for that instance. If + * multiple matching components are found, a harness for the first one is returned. If no matching + * component is found, an error is thrown. + * @param query A query for a harness to create + * @return An instance of the given harness type + * @throws If a matching component instance can't be found. + */ getHarness(query: HarnessQuery): Promise { return this.locatorFor(query)(); } - // Implemented as part of the `HarnessLoader` interface. + /** + * Searches for an instance of the component corresponding to the given harness type under the + * `HarnessEnvironment`'s root element, and returns a `ComponentHarness` for that instance. If + * multiple matching components are found, a harness for the first one is returned. If no matching + * component is found, null is returned. + * @param query A query for a harness to create + * @return An instance of the given harness type (or null if not found). + */ getHarnessOrNull(query: HarnessQuery): Promise { return this.locatorForOptional(query)(); } - // Implemented as part of the `HarnessLoader` interface. + /** + * Searches for an instance of the component corresponding to the given harness type and index + * under the `HarnessEnvironment`'s root element, and returns a `ComponentHarness` for that + * instance. The index specifies the offset of the component to find. If no matching + * component is found at that index, an error is thrown. + * @param query A query for a harness to create + * @param index The zero-indexed offset of the component to find + * @return An instance of the given harness type + * @throws If a matching component instance can't be found. + */ + async getHarnessAtIndex( + query: HarnessQuery, + offset: number, + ): Promise { + if (offset < 0) { + throw Error('Index must not be negative'); + } + const harnesses = await this.locatorForAll(query)(); + if (offset >= harnesses.length) { + throw Error(`No harness was located at index ${offset}`); + } + return harnesses[offset]; + } + + /** + * Searches for all instances of the component corresponding to the given harness type under the + * `HarnessEnvironment`'s root element, and returns a list `ComponentHarness` for each instance. + * @param query A query for a harness to create + * @return A list instances of the given harness type. + */ getAllHarnesses(query: HarnessQuery): Promise { return this.locatorForAll(query)(); } - // Implemented as part of the `HarnessLoader` interface. + /** + * Searches for all instance of the component corresponding to the given harness type under the + * `HarnessEnvironment`'s root element, and returns the number that were found. + * @param query A query for a harness to create + * @return The number of instances that were found. + */ + async countHarnesses(query: HarnessQuery): Promise { + return (await this.locatorForAll(query)()).length; + } + + /** + * Searches for an instance of the component corresponding to the given harness type under the + * `HarnessEnvironment`'s root element, and returns a boolean indicating if any were found. + * @param query A query for a harness to create + * @return A boolean indicating if an instance was found. + */ async hasHarness(query: HarnessQuery): Promise { return (await this.locatorForOptional(query)()) !== null; } - // Implemented as part of the `HarnessLoader` interface. + /** + * Searches for an element with the given selector under the evironment's root element, + * and returns a `HarnessLoader` rooted at the matching element. If multiple elements match the + * selector, the first is used. If no elements match, an error is thrown. + * @param selector The selector for the root element of the new `HarnessLoader` + * @return A `HarnessLoader` rooted at the element matching the given selector. + * @throws If a matching element can't be found. + */ async getChildLoader(selector: string): Promise { return this.createEnvironment( await _assertResultFound(this.getAllRawElements(selector), [ @@ -141,7 +318,13 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac ); } - // Implemented as part of the `HarnessLoader` interface. + /** + * Searches for all elements with the given selector under the environment's root element, + * and returns an array of `HarnessLoader`s, one for each matching element, rooted at that + * element. + * @param selector The selector for the root element of the new `HarnessLoader` + * @return A list of `HarnessLoader`s, one for each matching element, rooted at that element. + */ async getAllChildLoaders(selector: string): Promise { return (await this.getAllRawElements(selector)).map(e => this.createEnvironment(e)); } @@ -154,10 +337,19 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac return new harnessType(this.createEnvironment(element)); } - // Part of LocatorFactory interface, subclasses will implement. + /** + * Flushes change detection and async tasks captured in the Angular zone. + * In most cases it should not be necessary to call this manually. However, there may be some edge + * cases where it is needed to fully flush animation events. + * This is an abstrct method that must be implemented by subclasses. + */ abstract forceStabilize(): Promise; - // Part of LocatorFactory interface, subclasses will implement. + /** + * Waits for all scheduled or running async tasks to complete. This allows harness + * authors to wait for async tasks outside of the Angular zone. + * This is an abstrct method that must be implemented by subclasses. + */ abstract waitForTasksOutsideAngular(): Promise; /** Gets the root element for the document. */ @@ -166,7 +358,7 @@ export abstract class HarnessEnvironment implements HarnessLoader, LocatorFac /** Creates a `TestElement` from a raw element. */ protected abstract createTestElement(element: E): TestElement; - /** Creates a `HarnessLoader` rooted at the given raw element. */ + /** Creates a `HarnessEnvironment` rooted at the given raw element. */ protected abstract createEnvironment(element: E): HarnessEnvironment; /** diff --git a/src/cdk/testing/private/BUILD.bazel b/src/cdk/testing/private/BUILD.bazel index b6f2bc75385a..9af9daa568f3 100644 --- a/src/cdk/testing/private/BUILD.bazel +++ b/src/cdk/testing/private/BUILD.bazel @@ -12,6 +12,8 @@ ts_project( deps = [ "//:node_modules/@angular/core", "//:node_modules/@types/jasmine", + "//:node_modules/axe-core", + "//src/cdk/bidi", "//src/cdk/testing/testbed", ], ) diff --git a/src/cdk/testing/private/fake-directionality.ts b/src/cdk/testing/private/fake-directionality.ts new file mode 100644 index 000000000000..79410ea62e0e --- /dev/null +++ b/src/cdk/testing/private/fake-directionality.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Direction, Directionality} from '@angular/cdk/bidi'; +import {EventEmitter, signal, WritableSignal} from '@angular/core'; +import {toObservable} from '@angular/core/rxjs-interop'; +import {skip} from 'rxjs/operators'; + +// Note: ngOnDestroy not needed, but must include it to match the Directionality interface. +// Implementing the interface ensures the fake stays in sync with the real API. +// tslint:disable-next-line:no-undecorated-class-with-angular-features lifecycle-hook-interface +class FakeDirectionality implements Directionality { + readonly change: EventEmitter; + + get value(): Direction { + return this.valueSignal(); + } + + constructor(readonly valueSignal: WritableSignal) { + this.change = toObservable(valueSignal).pipe(skip(1)) as EventEmitter; + } + + ngOnDestroy() {} +} + +export function provideFakeDirectionality(direction: Direction | WritableSignal) { + return { + provide: Directionality, + useFactory: () => + new FakeDirectionality(typeof direction === 'string' ? signal(direction) : direction), + deps: [], + }; +} diff --git a/src/cdk/testing/private/public-api.ts b/src/cdk/testing/private/public-api.ts index f853b686cbd2..f69ee03e8d42 100644 --- a/src/cdk/testing/private/public-api.ts +++ b/src/cdk/testing/private/public-api.ts @@ -6,8 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ +export * from './fake-directionality'; export * from './text-dedent'; export * from './wrapped-error-message'; +export * from './run-accessibility-checks'; // Re-exported for convenience. export * from '../testbed/fake-events'; diff --git a/src/cdk/testing/private/run-accessibility-checks.ts b/src/cdk/testing/private/run-accessibility-checks.ts new file mode 100644 index 000000000000..b02b966f7a95 --- /dev/null +++ b/src/cdk/testing/private/run-accessibility-checks.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import axe from 'axe-core'; + +// Basic ANSI color functions because chalk has issues with unit tests. +const colors = { + red: (text: string) => `\x1b[31m${text}\x1b[0m`, + yellow: (text: string) => `\x1b[33m${text}\x1b[0m`, + blue: (text: string) => `\x1b[34m${text}\x1b[0m`, + magenta: (text: string) => `\x1b[35m${text}\x1b[0m`, + cyan: (text: string) => `\x1b[36m${text}\x1b[0m`, + gray: (text: string) => `\x1b[90m${text}\x1b[0m`, + underline: (text: string) => `\x1b[4m${text}\x1b[0m`, + default: (text: string) => `\x1b[0m${text}\x1b[0m`, +}; + +/** + * Runs accessibility checks on a specified HTML element using the axe-core library. + * @param root The root HTML element to check for accessibility violations. + */ +export async function runAccessibilityChecks(root: HTMLElement): Promise { + const results = await axe.run(root); + + if (!results.violations.length) { + return; + } + + const reportLines: string[] = []; + const append = (text: string) => reportLines.push(colors.default(text)); + append(colors.red(`Found ${results.violations.length} accessibility violation(s):`)); + + results.violations.forEach((violation, index) => { + append(''); + append(colors.red(`Violation ${index + 1}: ${violation.id}\n`)); + + let impactText = violation.impact || 'unknown'; + switch (violation.impact) { + case 'critical': + impactText = colors.red(impactText); + break; + case 'serious': + impactText = colors.yellow(impactText); + break; + case 'moderate': + impactText = colors.blue(impactText); + break; + case 'minor': + impactText = colors.gray(impactText); + break; + default: + impactText = colors.default(impactText); + break; + } + + append(` Impact: ${impactText}`); + append(` Description: ${violation.description}`); + append(` Help: ${violation.help}`); + append(` Help URL: ${colors.underline(colors.blue(violation.helpUrl))}\n`); + + if (violation.nodes && violation.nodes.length > 0) { + append(' Failing Elements:'); + violation.nodes.forEach((node, nodeIndex) => { + append(colors.cyan(` Node ${nodeIndex + 1}:`)); + if (node.target && node.target.length > 0) { + append(` Selector: ${colors.magenta(node.target.join(', '))}`); + } + if (node.failureSummary) { + append(' Failure Summary:'); + node.failureSummary + .split('\n') + .forEach(line => append(colors.yellow(` ${line.trim()}`))); + } + }); + } + }); + + fail(reportLines.join('\n')); +} diff --git a/src/cdk/testing/protractor/BUILD.bazel b/src/cdk/testing/protractor/BUILD.bazel index fc5baddd99df..7913ee094977 100644 --- a/src/cdk/testing/protractor/BUILD.bazel +++ b/src/cdk/testing/protractor/BUILD.bazel @@ -19,7 +19,10 @@ ts_project( filegroup( name = "source-files", - srcs = glob(["**/*.ts"]), + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), ) extract_api_to_json( @@ -31,4 +34,5 @@ extract_api_to_json( module_name = "@angular/cdk/testing/protractor", output_name = "cdk_testing_protractor.json", private_modules = [""], + repo = "angular/components", ) diff --git a/src/cdk/testing/selenium-webdriver/BUILD.bazel b/src/cdk/testing/selenium-webdriver/BUILD.bazel index 19cd8a48e63b..7fc75780873e 100644 --- a/src/cdk/testing/selenium-webdriver/BUILD.bazel +++ b/src/cdk/testing/selenium-webdriver/BUILD.bazel @@ -18,7 +18,10 @@ ts_project( filegroup( name = "source-files", - srcs = glob(["**/*.ts"]), + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), ) extract_api_to_json( @@ -27,7 +30,8 @@ extract_api_to_json( ":source-files", ], entry_point = ":index.ts", - module_name = "@angular/cdk//selenium-webdriver", + module_name = "@angular/cdk/testing/selenium-webdriver", output_name = "cdk_testing_selenium_webdriver.json", private_modules = [""], + repo = "angular/components", ) diff --git a/src/cdk/testing/test-element.ts b/src/cdk/testing/test-element.ts index 191d343ea6c6..1acd25175c7b 100644 --- a/src/cdk/testing/test-element.ts +++ b/src/cdk/testing/test-element.ts @@ -180,7 +180,10 @@ export interface TestElement { dispatchEvent(name: string, data?: Record): Promise; } +/** + * Options that affect the text returned by `TestElement.text`. + */ export interface TextOptions { - /** Optional selector for elements to exclude. */ + /** Optional selector for elements whose content should be excluded from the text string. */ exclude?: string; } diff --git a/src/cdk/testing/test-harnesses.md b/src/cdk/testing/test-harnesses.md index 73866625591e..c2710b1eff6e 100644 --- a/src/cdk/testing/test-harnesses.md +++ b/src/cdk/testing/test-harnesses.md @@ -134,9 +134,12 @@ are used to create `ComponentHarness` instances for elements under this root ele | `getChildLoader(selector: string): Promise` | Searches for an element matching the given selector below the root element of this `HarnessLoader`, and returns a new `HarnessLoader` rooted at the first matching element | | `getAllChildLoaders(selector: string): Promise` | Acts like `getChildLoader`, but returns an array of `HarnessLoader` instances, one for each matching element, rather than just the first matching element | | `getHarness(harnessType: ComponentHarnessConstructor \| HarnessPredicate): Promise` | Searches for an instance of the given `ComponentHarness` class or `HarnessPredicate` below the root element of this `HarnessLoader` and returns an instance of the harness corresponding to the first matching element | +| `getHarnessAtIndex(harnessType: ComponentHarnessConstructor \| HarnessPredicate, index: number): Promise` | Acts like `getHarness`, but returns an instance of the harness corresponding to the matching element with the given index (zero-indexed) | | `getAllHarnesses(harnessType: ComponentHarnessConstructor \| HarnessPredicate): Promise` | Acts like `getHarness`, but returns an array of harness instances, one for each matching element, rather than just the first matching element | +| `countHarnesses(harnessType: ComponentHarnessConstructor \| HarnessPredicate): Promise` | Counts the number of instances of the given `ComponentHarness` class or `HarnessPredicate` below the root element of this `HarnessLoader`, and returns the result. | +| `hasHarness(harnessType: ComponentHarnessConstructor \| HarnessPredicate): Promise` | Returns true if an instance of the given `ComponentHarness` class or `HarnessPredicate` exists below the root element of this `HarnessLoader` | -Calls to `getHarness` and `getAllHarnesses` can either take `ComponentHarness` subclass or a +Calls to the harness functions can either take `ComponentHarness` subclass or a `HarnessPredicate`. `HarnessPredicate` applies additional restrictions to the search (e.g. searching for a button that has some particular text, etc). The [details of `HarnessPredicate`](#filtering-harness-instances-with-harnesspredicate) are discussed in @@ -524,7 +527,7 @@ class MyPopupHarness extends ContentContainerComponentHarness { #### Accessing elements outside of the component's host element There are times when a component harness might need to access elements outside of its corresponding -component's host element. Components that use [CDK overlay](https://material.angular.io/cdk/overlay/overview) serve as examples of this. The CDK overlay creates an element that is attached directly to the body, outside of the component's host element. In this case, +component's host element. Components that use [CDK overlay](https://material.angular.dev/cdk/overlay/overview) serve as examples of this. The CDK overlay creates an element that is attached directly to the body, outside of the component's host element. In this case, `ComponentHarness` provides a method that can be used to get a `LocatorFactory` for the root element of the document. The `LocatorFactory` supports most of the same APIs as the `ComponentHarness` base class, and can then be used to query relative to the document's root element. diff --git a/src/cdk/testing/testbed/BUILD.bazel b/src/cdk/testing/testbed/BUILD.bazel index e91f3c7ac696..ec62c0bdcaf2 100644 --- a/src/cdk/testing/testbed/BUILD.bazel +++ b/src/cdk/testing/testbed/BUILD.bazel @@ -19,7 +19,10 @@ ts_project( filegroup( name = "source-files", - srcs = glob(["**/*.ts"]), + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), ) ts_project( @@ -46,4 +49,5 @@ extract_api_to_json( module_name = "@angular/cdk/testing/testbed", output_name = "cdk_testing_testbed.json", private_modules = [""], + repo = "angular/components", ) diff --git a/src/cdk/testing/tests/cross-environment.spec.ts b/src/cdk/testing/tests/cross-environment.spec.ts index eb4f034ceb14..cdedcc19d054 100644 --- a/src/cdk/testing/tests/cross-environment.spec.ts +++ b/src/cdk/testing/tests/cross-environment.spec.ts @@ -85,9 +85,8 @@ export function crossEnvironmentSpecs( await countersLoader.getHarness(SubComponentHarness); fail('Expected to throw'); } catch (e) { - expect((e as Error).message).toBe( - 'Failed to find element matching one of the following queries:' + - '\n(SubComponentHarness with host element matching selector: "test-sub")', + expect((e as Error).message).toMatch( + /Failed to find element matching one of the following queries:\n\(_?SubComponentHarness with host element matching selector: "test-sub"\)/, ); } }); @@ -225,10 +224,8 @@ export function crossEnvironmentSpecs( await harness.requiredAncestorRestrictedMissingSubcomponent(); fail('Expected to throw'); } catch (e) { - expect((e as Error).message).toBe( - 'Failed to find element matching one of the following queries:' + - '\n(SubComponentHarness with host element matching selector: "test-sub"' + - ' satisfying the constraints: has ancestor matching selector ".not-found")', + expect((e as Error).message).toMatch( + /Failed to find element matching one of the following queries:\n\(_?SubComponentHarness with host element matching selector: "test-sub" satisfying the constraints: has ancestor matching selector ".not-found"\)/, ); } }); @@ -334,10 +331,8 @@ export function crossEnvironmentSpecs( await harness.requiredFourIteamToolsLists(); fail('Expected to throw'); } catch (e) { - expect((e as Error).message).toBe( - 'Failed to find element matching one of the following queries:' + - '\n(SubComponentHarness with host element matching selector: "test-sub" satisfying' + - ' the constraints: title = "List of test tools", item count = 4)', + expect((e as Error).message).toMatch( + /Failed to find element matching one of the following queries:\n\(_?SubComponentHarness with host element matching selector: "test-sub" satisfying the constraints: title = "List of test tools", item count = 4\)/, ); } }); @@ -695,11 +690,8 @@ export function crossEnvironmentSpecs( await harness.missingElementsAndHarnesses(); fail('Expected to throw.'); } catch (e) { - expect((e as Error).message).toBe( - 'Failed to find element matching one of the following queries:' + - '\n(TestElement for element matching selector: ".not-found"),' + - '\n(SubComponentHarness with host element matching selector: "test-sub" satisfying' + - ' the constraints: title = /not found/)', + expect((e as Error).message).toMatch( + /Failed to find element matching one of the following queries:\n\(TestElement for element matching selector: ".not-found"\),\n\(_?SubComponentHarness with host element matching selector: "test-sub" satisfying the constraints: title = \/not found\/\)/, ); } }); diff --git a/src/cdk/text-field/autosize.ts b/src/cdk/text-field/autosize.ts index 5abb0be0f97a..f7dd3cc9f267 100644 --- a/src/cdk/text-field/autosize.ts +++ b/src/cdk/text-field/autosize.ts @@ -122,7 +122,7 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { private _cachedScrollTop: number; /** Used to reference correct document/window */ - protected _document? = inject(DOCUMENT, {optional: true}); + protected _document = inject(DOCUMENT); private _hasFocus: boolean; diff --git a/src/cdk/tree/BUILD.bazel b/src/cdk/tree/BUILD.bazel index 7f8e06db6fec..58ab2cd37775 100644 --- a/src/cdk/tree/BUILD.bazel +++ b/src/cdk/tree/BUILD.bazel @@ -41,6 +41,7 @@ ts_project( "//src/cdk/bidi", "//src/cdk/collections", "//src/cdk/keycodes", + "//src/cdk/testing/private", "//src/cdk/testing/testbed", ], ) diff --git a/src/cdk/tree/tree-using-legacy-key-manager.spec.ts b/src/cdk/tree/tree-using-legacy-key-manager.spec.ts index 54f34e0ea048..fecb0b756ce6 100644 --- a/src/cdk/tree/tree-using-legacy-key-manager.spec.ts +++ b/src/cdk/tree/tree-using-legacy-key-manager.spec.ts @@ -8,12 +8,7 @@ describe('CdkTree when provided LegacyTreeKeyManager', () => { let fixture: ComponentFixture; beforeEach(() => { - TestBed.configureTestingModule({ - imports: [CdkTreeModule], - declarations: [SimpleCdkTreeApp], - providers: [NOOP_TREE_KEY_MANAGER_FACTORY_PROVIDER], - }); - + TestBed.configureTestingModule({providers: [NOOP_TREE_KEY_MANAGER_FACTORY_PROVIDER]}); fixture = TestBed.createComponent(SimpleCdkTreeApp); fixture.detectChanges(); }); @@ -80,7 +75,7 @@ class MinimalTestData { `, - standalone: false, + imports: [CdkTreeModule], }) class SimpleCdkTreeApp { isExpandable = (node: MinimalTestData) => node.children.length > 0; diff --git a/src/cdk/tree/tree-with-tree-control.spec.ts b/src/cdk/tree/tree-with-tree-control.spec.ts index d74997773e4d..49b58636efa0 100644 --- a/src/cdk/tree/tree-with-tree-control.spec.ts +++ b/src/cdk/tree/tree-with-tree-control.spec.ts @@ -5,27 +5,28 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -import {ComponentFixture, TestBed} from '@angular/core/testing'; import { Component, ErrorHandler, - ViewChild, + QueryList, + signal, TrackByFunction, - Type, - EventEmitter, ViewChildren, - QueryList, + WritableSignal, + ViewChild, } from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {BehaviorSubject, combineLatest, Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {Direction} from '../bidi'; import {CollectionViewer, DataSource} from '../collections'; -import {Directionality, Direction} from '../bidi'; import {createKeyboardEvent} from '../testing/testbed/fake-events'; -import {combineLatest, BehaviorSubject, Observable} from 'rxjs'; -import {map} from 'rxjs/operators'; -import {TreeControl} from './control/tree-control'; +import {provideFakeDirectionality} from '../testing/private/fake-directionality'; import {FlatTreeControl} from './control/flat-tree-control'; import {NestedTreeControl} from './control/nested-tree-control'; +import {TreeControl} from './control/tree-control'; import {CdkTreeModule, CdkTreeNodePadding} from './index'; import {CdkTree, CdkTreeNode} from './tree'; @@ -35,16 +36,14 @@ describe('CdkTree with TreeControl', () => { let dataSource: FakeDataSource; let treeElement: HTMLElement; let tree: CdkTree; - let dir: {value: Direction; readonly change: EventEmitter}; + let dir: WritableSignal; + + beforeEach(() => { + dir = signal('ltr'); - function configureCdkTreeTestingModule(declarations: Type[]) { TestBed.configureTestingModule({ - imports: [CdkTreeModule], providers: [ - { - provide: Directionality, - useFactory: () => (dir = {value: 'ltr', change: new EventEmitter()}), - }, + provideFakeDirectionality(dir), // Custom error handler that re-throws the error. Errors happening within // change detection phase will be reported through the handler and thrown // in Ivy. Since we do not want to pollute the "console.error", but rather @@ -58,12 +57,10 @@ describe('CdkTree with TreeControl', () => { }, }, ], - declarations: declarations, }); - } + }); it('should clear out the `mostRecentTreeNode` on destroy', () => { - configureCdkTreeTestingModule([SimpleCdkTreeApp]); const fixture = TestBed.createComponent(SimpleCdkTreeApp); fixture.detectChanges(); @@ -77,7 +74,6 @@ describe('CdkTree with TreeControl', () => { }); it('should complete the viewChange stream on destroy', () => { - configureCdkTreeTestingModule([SimpleCdkTreeApp]); const fixture = TestBed.createComponent(SimpleCdkTreeApp); fixture.detectChanges(); const spy = jasmine.createSpy('completeSpy'); @@ -94,7 +90,6 @@ describe('CdkTree with TreeControl', () => { let component: SimpleCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([SimpleCdkTreeApp]); fixture = TestBed.createComponent(SimpleCdkTreeApp); fixture.detectChanges(); @@ -242,8 +237,7 @@ describe('CdkTree with TreeControl', () => { expect(node.style.paddingLeft).toBe('10px'); expect(node.style.paddingRight).toBeFalsy(); - dir.value = 'rtl'; - dir.change.emit('rtl'); + dir.set('rtl'); fixture.detectChanges(); expect(node.style.paddingRight).toBe('10px'); @@ -256,7 +250,6 @@ describe('CdkTree with TreeControl', () => { let component: CdkTreeAppWithToggle; beforeEach(() => { - configureCdkTreeTestingModule([CdkTreeAppWithToggle]); fixture = TestBed.createComponent(CdkTreeAppWithToggle); fixture.detectChanges(); @@ -357,7 +350,6 @@ describe('CdkTree with TreeControl', () => { let component: WhenNodeCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([WhenNodeCdkTreeApp]); fixture = TestBed.createComponent(WhenNodeCdkTreeApp); fixture.detectChanges(); @@ -404,7 +396,6 @@ describe('CdkTree with TreeControl', () => { let component: ArrayDataSourceCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([ArrayDataSourceCdkTreeApp]); fixture = TestBed.createComponent(ArrayDataSourceCdkTreeApp); fixture.detectChanges(); @@ -451,7 +442,6 @@ describe('CdkTree with TreeControl', () => { let component: ObservableDataSourceCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([ObservableDataSourceCdkTreeApp]); fixture = TestBed.createComponent(ObservableDataSourceCdkTreeApp); fixture.detectChanges(); @@ -498,7 +488,6 @@ describe('CdkTree with TreeControl', () => { let component: CdkTreeAppWithTrackBy; function createTrackByTestComponent(trackByStrategy: 'reference' | 'property' | 'index') { - configureCdkTreeTestingModule([CdkTreeAppWithTrackBy]); fixture = TestBed.createComponent(CdkTreeAppWithTrackBy); component = fixture.componentInstance; component.trackByStrategy = trackByStrategy; @@ -587,7 +576,6 @@ describe('CdkTree with TreeControl', () => { }); it('should pick up indirect descendant node definitions', () => { - configureCdkTreeTestingModule([SimpleCdkTreeAppWithIndirectNodes]); const fixture = TestBed.createComponent(SimpleCdkTreeAppWithIndirectNodes); fixture.detectChanges(); treeElement = fixture.nativeElement.querySelector('cdk-tree'); @@ -602,7 +590,6 @@ describe('CdkTree with TreeControl', () => { let component: NestedCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([NestedCdkTreeApp]); fixture = TestBed.createComponent(NestedCdkTreeApp); fixture.detectChanges(); @@ -716,7 +703,6 @@ describe('CdkTree with TreeControl', () => { let component: StaticNestedCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([StaticNestedCdkTreeApp]); fixture = TestBed.createComponent(StaticNestedCdkTreeApp); fixture.detectChanges(); @@ -744,7 +730,6 @@ describe('CdkTree with TreeControl', () => { let component: WhenNodeNestedCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([WhenNodeNestedCdkTreeApp]); fixture = TestBed.createComponent(WhenNodeNestedCdkTreeApp); fixture.detectChanges(); @@ -786,7 +771,6 @@ describe('CdkTree with TreeControl', () => { let component: NestedCdkTreeAppWithToggle; beforeEach(() => { - configureCdkTreeTestingModule([NestedCdkTreeAppWithToggle]); fixture = TestBed.createComponent(NestedCdkTreeAppWithToggle); fixture.detectChanges(); @@ -932,7 +916,6 @@ describe('CdkTree with TreeControl', () => { let component: ArrayDataSourceNestedCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([ArrayDataSourceNestedCdkTreeApp]); fixture = TestBed.createComponent(ArrayDataSourceNestedCdkTreeApp); fixture.detectChanges(); @@ -972,7 +955,6 @@ describe('CdkTree with TreeControl', () => { let component: ObservableDataSourceNestedCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([ObservableDataSourceNestedCdkTreeApp]); fixture = TestBed.createComponent(ObservableDataSourceNestedCdkTreeApp); fixture.detectChanges(); @@ -1012,7 +994,6 @@ describe('CdkTree with TreeControl', () => { let component: NestedCdkTreeAppWithTrackBy; function createTrackByTestComponent(trackByStrategy: 'reference' | 'property' | 'index') { - configureCdkTreeTestingModule([NestedCdkTreeAppWithTrackBy]); fixture = TestBed.createComponent(NestedCdkTreeAppWithTrackBy); component = fixture.componentInstance; component.trackByStrategy = trackByStrategy; @@ -1142,7 +1123,6 @@ describe('CdkTree with TreeControl', () => { let component: DepthNestedCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([DepthNestedCdkTreeApp]); fixture = TestBed.createComponent(DepthNestedCdkTreeApp); fixture.detectChanges(); @@ -1173,7 +1153,6 @@ describe('CdkTree with TreeControl', () => { let nodes: HTMLElement[]; beforeEach(() => { - configureCdkTreeTestingModule([StaticNestedCdkTreeApp]); fixture = TestBed.createComponent(StaticNestedCdkTreeApp); fixture.detectChanges(); @@ -1462,7 +1441,7 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { `, - standalone: false, + imports: [CdkTreeModule], }) class SimpleCdkTreeApp { getLevel = (node: TestData) => node.level; @@ -1479,16 +1458,16 @@ class SimpleCdkTreeApp { @Component({ template: ` - + @if (true) { {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} - + } `, - standalone: false, + imports: [CdkTreeModule], }) class SimpleCdkTreeAppWithIndirectNodes extends SimpleCdkTreeApp {} @@ -1501,7 +1480,7 @@ class SimpleCdkTreeAppWithIndirectNodes extends SimpleCdkTreeApp {} `, - standalone: false, + imports: [CdkTreeModule], }) class NestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -1525,7 +1504,7 @@ class NestedCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class StaticNestedCdkTreeApp { getChildren = (node: TestData) => node.children; @@ -1562,7 +1541,7 @@ class StaticNestedCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class WhenNodeNestedCdkTreeApp { isSecondNode = (_: number, node: TestData) => node.pizzaBase.indexOf('2') > 0; @@ -1586,7 +1565,7 @@ class WhenNodeNestedCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class CdkTreeAppWithToggle { toggleRecursively: boolean = true; @@ -1607,13 +1586,13 @@ class CdkTreeAppWithToggle { cdkTreeNodeToggle [cdkTreeNodeToggleRecursive]="toggleRecursively"> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} -
    + @if (treeControl.isExpanded(node)) { -
    + } `, - standalone: false, + imports: [CdkTreeModule], }) class NestedCdkTreeAppWithToggle { toggleRecursively: boolean = true; @@ -1643,7 +1622,7 @@ class NestedCdkTreeAppWithToggle { `, - standalone: false, + imports: [CdkTreeModule], }) class WhenNodeCdkTreeApp { isOddNode = (_: number, node: TestData) => node.level % 2 === 1; @@ -1667,7 +1646,7 @@ class WhenNodeCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class ArrayDataSourceCdkTreeApp { getLevel = (node: TestData) => node.level; @@ -1694,7 +1673,7 @@ class ArrayDataSourceCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class ObservableDataSourceCdkTreeApp { getLevel = (node: TestData) => node.level; @@ -1720,7 +1699,7 @@ class ObservableDataSourceCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class ArrayDataSourceNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -1745,7 +1724,7 @@ class ArrayDataSourceNestedCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class ObservableDataSourceNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -1771,7 +1750,7 @@ class ObservableDataSourceNestedCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class DepthNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -1795,7 +1774,7 @@ class DepthNestedCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class CdkTreeAppWithTrackBy { trackByStrategy: 'reference' | 'property' | 'index' = 'reference'; @@ -1829,7 +1808,7 @@ class CdkTreeAppWithTrackBy { `, - standalone: false, + imports: [CdkTreeModule], }) class NestedCdkTreeAppWithTrackBy { trackByStrategy: 'reference' | 'property' | 'index' = 'reference'; diff --git a/src/cdk/tree/tree.md b/src/cdk/tree/tree.md index 30de3315ce20..9f8e0c7163f8 100644 --- a/src/cdk/tree/tree.md +++ b/src/cdk/tree/tree.md @@ -55,11 +55,11 @@ In order to use the tree, you must define a tree node template. There are two ty template defines the look of the tree node, expansion/collapsing control and the structure for nested children nodes. -A node definition is specified via any element with `cdkNodeDef`. This directive exports the node +A node definition is specified via any element with `cdkTreeNodeDef`. This directive exports the node data to be used in any bindings in the node template. ```html - + {{node.key}}: {{node.value}} ``` @@ -80,7 +80,7 @@ When using nested tree nodes, the node template must contain a `cdkTreeNodeOutle where the children of the node will be rendered. ```html - + {{node.value}} @@ -96,7 +96,7 @@ a tree node recursively by setting `[cdkTreeNodeToggleRecursive]` to true. activation. For icon buttons, ensure that `aria-label` is provided. ```html - + @@ -110,7 +110,7 @@ The `cdkTreeNodePadding` directive can be placed in a flat tree's node template information of a flat tree node. ```html - + {{node.value}} ``` @@ -125,10 +125,10 @@ The tree may include multiple node templates, where a template is chosen for a particular data node via the `when` predicate of the template. ```html - + {{node.value}} - + [ A special node {{node.value}} ] ``` @@ -200,11 +200,11 @@ interaction. ```html ``` In this example, `$event` contains the node's data and is equivalent to the implicit data passed in -the `cdkNodeDef` context. +the `cdkTreeNodeDef` context. diff --git a/src/cdk/tree/tree.spec.ts b/src/cdk/tree/tree.spec.ts index b9d5dce285ab..c678042f77e4 100644 --- a/src/cdk/tree/tree.spec.ts +++ b/src/cdk/tree/tree.spec.ts @@ -5,29 +5,31 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -import {ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing'; import { ChangeDetectorRef, Component, + ElementRef, ErrorHandler, - EventEmitter, QueryList, TrackByFunction, - Type, ViewChild, ViewChildren, + WritableSignal, inject, - ElementRef, + signal, } from '@angular/core'; +import {ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing'; +import {AsyncPipe} from '@angular/common'; -import {Direction, Directionality} from '../bidi'; -import {CollectionViewer, DataSource} from '../collections'; -import {combineLatest, BehaviorSubject, Observable, of} from 'rxjs'; +import {BehaviorSubject, Observable, combineLatest, of} from 'rxjs'; import {map} from 'rxjs/operators'; +import {Direction} from '../bidi'; +import {CollectionViewer, DataSource} from '../collections'; +import {provideFakeDirectionality} from '../testing/private/fake-directionality'; +import {createKeyboardEvent} from '../testing/testbed/fake-events'; import {CdkTreeModule, CdkTreeNodePadding} from './index'; import {CdkTree, CdkTreeNode} from './tree'; -import {createKeyboardEvent} from '../testing/testbed/fake-events'; /** * This is a cloned version of `tree.spec.ts` that contains all the same tests, @@ -39,16 +41,14 @@ describe('CdkTree', () => { let dataSource: FakeDataSource; let treeElement: HTMLElement; let tree: CdkTree; - let dir: {value: Direction; readonly change: EventEmitter}; + let dir: WritableSignal; + + beforeEach(() => { + dir = signal('ltr'); - function configureCdkTreeTestingModule(declarations: Type[]) { TestBed.configureTestingModule({ - imports: [CdkTreeModule], providers: [ - { - provide: Directionality, - useFactory: () => (dir = {value: 'ltr', change: new EventEmitter()}), - }, + provideFakeDirectionality(dir), // Custom error handler that re-throws the error. Errors happening within // change detection phase will be reported through the handler and thrown // in Ivy. Since we do not want to pollute the "console.error", but rather @@ -62,13 +62,11 @@ describe('CdkTree', () => { }, }, ], - declarations: declarations, }); - } + }); describe('onDestroy', () => { it('should clear out the `mostRecentTreeNode` on destroy', () => { - configureCdkTreeTestingModule([SimpleCdkTreeApp]); const fixture = TestBed.createComponent(SimpleCdkTreeApp); fixture.detectChanges(); @@ -82,7 +80,6 @@ describe('CdkTree', () => { }); it('should complete the viewChange stream on destroy', () => { - configureCdkTreeTestingModule([SimpleCdkTreeApp]); const fixture = TestBed.createComponent(SimpleCdkTreeApp); fixture.detectChanges(); const spy = jasmine.createSpy('completeSpy'); @@ -100,7 +97,6 @@ describe('CdkTree', () => { let component: SimpleCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([SimpleCdkTreeApp]); fixture = TestBed.createComponent(SimpleCdkTreeApp); fixture.detectChanges(); @@ -237,7 +233,7 @@ describe('CdkTree', () => { ); }); - it('should reset element.styel to the opposite direction padding if the direction changes', () => { + it('should reset element.style to the opposite direction padding if the direction changes', () => { const node = getNodes(treeElement)[0]; component.indent = 10; @@ -247,8 +243,7 @@ describe('CdkTree', () => { expect(node.style.paddingLeft).toBe('10px'); expect(node.style.paddingRight).toBeFalsy(); - dir.value = 'rtl'; - dir.change.emit('rtl'); + dir.set('rtl'); fixture.detectChanges(); expect(node.style.paddingRight).toBe('10px'); @@ -261,7 +256,6 @@ describe('CdkTree', () => { let component: CdkTreeAppWithToggle; beforeEach(() => { - configureCdkTreeTestingModule([CdkTreeAppWithToggle]); fixture = TestBed.createComponent(CdkTreeAppWithToggle); fixture.detectChanges(); @@ -409,7 +403,6 @@ describe('CdkTree', () => { let component: WhenNodeCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([WhenNodeCdkTreeApp]); fixture = TestBed.createComponent(WhenNodeCdkTreeApp); fixture.detectChanges(); @@ -456,7 +449,6 @@ describe('CdkTree', () => { let component: ArrayDataSourceCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([ArrayDataSourceCdkTreeApp]); fixture = TestBed.createComponent(ArrayDataSourceCdkTreeApp); fixture.detectChanges(); @@ -502,7 +494,6 @@ describe('CdkTree', () => { let component: ObservableDataSourceCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([ObservableDataSourceCdkTreeApp]); fixture = TestBed.createComponent(ObservableDataSourceCdkTreeApp); fixture.detectChanges(); @@ -549,7 +540,6 @@ describe('CdkTree', () => { let component: CdkTreeAppWithTrackBy; function createTrackByTestComponent(trackByStrategy: 'reference' | 'property' | 'index') { - configureCdkTreeTestingModule([CdkTreeAppWithTrackBy]); fixture = TestBed.createComponent(CdkTreeAppWithTrackBy); component = fixture.componentInstance; component.trackByStrategy = trackByStrategy; @@ -657,7 +647,6 @@ describe('CdkTree', () => { }); it('should pick up indirect descendant node definitions', () => { - configureCdkTreeTestingModule([SimpleCdkTreeAppWithIndirectNodes]); const fixture = TestBed.createComponent(SimpleCdkTreeAppWithIndirectNodes); fixture.detectChanges(); treeElement = fixture.nativeElement.querySelector('cdk-tree'); @@ -672,7 +661,6 @@ describe('CdkTree', () => { let component: NestedCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([NestedCdkTreeApp]); fixture = TestBed.createComponent(NestedCdkTreeApp); fixture.detectChanges(); @@ -776,7 +764,6 @@ describe('CdkTree', () => { let component: StaticNestedCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([StaticNestedCdkTreeApp]); fixture = TestBed.createComponent(StaticNestedCdkTreeApp); fixture.detectChanges(); @@ -804,7 +791,6 @@ describe('CdkTree', () => { let component: WhenNodeNestedCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([WhenNodeNestedCdkTreeApp]); fixture = TestBed.createComponent(WhenNodeNestedCdkTreeApp); fixture.detectChanges(); @@ -846,7 +832,6 @@ describe('CdkTree', () => { let component: NestedCdkTreeAppWithToggle; beforeEach(() => { - configureCdkTreeTestingModule([NestedCdkTreeAppWithToggle]); fixture = TestBed.createComponent(NestedCdkTreeAppWithToggle); fixture.detectChanges(); @@ -987,7 +972,6 @@ describe('CdkTree', () => { let component: ArrayDataSourceNestedCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([ArrayDataSourceNestedCdkTreeApp]); fixture = TestBed.createComponent(ArrayDataSourceNestedCdkTreeApp); fixture.detectChanges(); @@ -1027,7 +1011,6 @@ describe('CdkTree', () => { let component: ObservableDataSourceNestedCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([ObservableDataSourceNestedCdkTreeApp]); fixture = TestBed.createComponent(ObservableDataSourceNestedCdkTreeApp); fixture.detectChanges(); @@ -1067,7 +1050,6 @@ describe('CdkTree', () => { let component: NestedCdkTreeAppWithTrackBy; function createTrackByTestComponent(trackByStrategy: 'reference' | 'property' | 'index') { - configureCdkTreeTestingModule([NestedCdkTreeAppWithTrackBy]); fixture = TestBed.createComponent(NestedCdkTreeAppWithTrackBy); component = fixture.componentInstance; component.trackByStrategy = trackByStrategy; @@ -1197,7 +1179,6 @@ describe('CdkTree', () => { let component: DepthNestedCdkTreeApp; beforeEach(() => { - configureCdkTreeTestingModule([DepthNestedCdkTreeApp]); fixture = TestBed.createComponent(DepthNestedCdkTreeApp); fixture.detectChanges(); @@ -1228,7 +1209,6 @@ describe('CdkTree', () => { let nodes: HTMLElement[]; beforeEach(() => { - configureCdkTreeTestingModule([StaticNestedCdkTreeApp]); fixture = TestBed.createComponent(StaticNestedCdkTreeApp); fixture.detectChanges(); @@ -1382,7 +1362,6 @@ describe('CdkTree', () => { let component: FlatTreeWithThreeNodes; beforeEach(() => { - configureCdkTreeTestingModule([FlatTreeWithThreeNodes]); fixture = TestBed.createComponent(FlatTreeWithThreeNodes); fixture.detectChanges(); @@ -1410,7 +1389,6 @@ describe('CdkTree', () => { let component: TypeaheadLabelFlatTreeWithThreeNodes; beforeEach(() => { - configureCdkTreeTestingModule([TypeaheadLabelFlatTreeWithThreeNodes]); fixture = TestBed.createComponent(TypeaheadLabelFlatTreeWithThreeNodes); fixture.detectChanges(); @@ -1472,7 +1450,6 @@ describe('CdkTree', () => { }); it('sets a node as expanded if attribute is ordered before `isExpandable`', () => { - configureCdkTreeTestingModule([IsExpandableOrderingTest]); const fixture = TestBed.createComponent(IsExpandableOrderingTest); fixture.detectChanges(); @@ -1483,7 +1460,6 @@ describe('CdkTree', () => { }); it('should expand/collapse all nested nodes when calling expandAll/collapseAll', () => { - configureCdkTreeTestingModule([IsExpandableOrderingTest]); const fixture = TestBed.createComponent(IsExpandableOrderingTest); const component = fixture.componentInstance; const data = fixture.componentInstance.dataSource; @@ -1713,7 +1689,7 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { `, - standalone: false, + imports: [CdkTreeModule], }) class SimpleCdkTreeApp { getLevel = (node: TestData) => node.level; @@ -1741,7 +1717,7 @@ class SimpleCdkTreeApp { } `, - standalone: false, + imports: [CdkTreeModule], }) class SimpleCdkTreeAppWithIndirectNodes extends SimpleCdkTreeApp {} @@ -1755,7 +1731,7 @@ class SimpleCdkTreeAppWithIndirectNodes extends SimpleCdkTreeApp {} `, - standalone: false, + imports: [CdkTreeModule], }) class NestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -1779,7 +1755,7 @@ class NestedCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class StaticNestedCdkTreeApp { getChildren = (node: TestData) => node.children; @@ -1813,7 +1789,7 @@ class StaticNestedCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class WhenNodeNestedCdkTreeApp { isSecondNode = (_: number, node: TestData) => node.pizzaBase.indexOf('2') > 0; @@ -1837,7 +1813,7 @@ class WhenNodeNestedCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class CdkTreeAppWithToggle { toggleRecursively: boolean = true; @@ -1867,7 +1843,7 @@ class CdkTreeAppWithToggle { `, - standalone: false, + imports: [CdkTreeModule, AsyncPipe], }) class NestedCdkTreeAppWithToggle { toggleRecursively: boolean = true; @@ -1899,7 +1875,7 @@ class NestedCdkTreeAppWithToggle { `, - standalone: false, + imports: [CdkTreeModule], }) class WhenNodeCdkTreeApp { isOddNode = (_: number, node: TestData) => node.level % 2 === 1; @@ -1923,7 +1899,7 @@ class WhenNodeCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class ArrayDataSourceCdkTreeApp { getLevel = (node: TestData) => node.level; @@ -1958,7 +1934,7 @@ class ArrayDataSourceCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class ObservableDataSourceCdkTreeApp { getLevel = (node: TestData) => node.level; @@ -1983,7 +1959,7 @@ class ObservableDataSourceCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class ArrayDataSourceNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -2007,7 +1983,7 @@ class ArrayDataSourceNestedCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class ObservableDataSourceNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -2032,7 +2008,7 @@ class ObservableDataSourceNestedCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class DepthNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -2055,7 +2031,7 @@ class DepthNestedCdkTreeApp { `, - standalone: false, + imports: [CdkTreeModule], }) class CdkTreeAppWithTrackBy { trackByStrategy: 'reference' | 'property' | 'index' = 'reference'; @@ -2089,7 +2065,7 @@ class CdkTreeAppWithTrackBy { `, - standalone: false, + imports: [CdkTreeModule], }) class NestedCdkTreeAppWithTrackBy { trackByStrategy: 'reference' | 'property' | 'index' = 'reference'; @@ -2133,7 +2109,7 @@ class MinimalTestData { `, - standalone: false, + imports: [CdkTreeModule], }) class TypeaheadLabelFlatTreeWithThreeNodes { isExpandable = (node: MinimalTestData) => node.children.length > 0; @@ -2157,7 +2133,7 @@ class TypeaheadLabelFlatTreeWithThreeNodes { `, - standalone: false, + imports: [CdkTreeModule], }) class FlatTreeWithThreeNodes { isExpandable = (node: MinimalTestData) => node.children.length > 0; @@ -2184,7 +2160,7 @@ class FlatTreeWithThreeNodes { `, - standalone: false, + imports: [CdkTreeModule], }) class IsExpandableOrderingTest { getChildren = (node: MinimalTestData) => node.children; diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 497ba28d4b86..e6e485e733f5 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -1400,6 +1400,7 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI .changed.pipe( map(() => this.isExpanded), distinctUntilChanged(), + takeUntil(this._destroyed), ) .subscribe(() => this._changeDetectorRef.markForCheck()); this._tree._setNodeTypeIfUnset(this._type); diff --git a/src/components-examples/cdk-experimental/accordion/BUILD.bazel b/src/components-examples/cdk-experimental/accordion/BUILD.bazel new file mode 100644 index 000000000000..f94820f50f63 --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/BUILD.bazel @@ -0,0 +1,29 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "accordion", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//src/cdk-experimental/accordion", + "//src/material/checkbox", + "//src/material/form-field", + "//src/material/icon", + "//src/material/input", + "//src/material/select", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion-configurable/cdk-accordion-configurable-example.html b/src/components-examples/cdk-experimental/accordion/cdk-accordion-configurable/cdk-accordion-configurable-example.html new file mode 100644 index 000000000000..84045c0dd5cc --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion-configurable/cdk-accordion-configurable-example.html @@ -0,0 +1,109 @@ +
    + Wrap (ArrowKey-only) + Multi + Disabled + Skip Disabled + + + Expanded Items + + @for (item of items; track item) { + {{item}} + } + + +
    + +
    +
    +

    + +

    +
    + +

    This is the content for Item 1.

    + +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 2.

    + +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 3.

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 4

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 5

    +
    +
    +
    +
    diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion-configurable/cdk-accordion-configurable-example.ts b/src/components-examples/cdk-experimental/accordion/cdk-accordion-configurable/cdk-accordion-configurable-example.ts new file mode 100644 index 000000000000..c2b1af0bdc4c --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion-configurable/cdk-accordion-configurable-example.ts @@ -0,0 +1,49 @@ +import {Component, computed, model, Signal} from '@angular/core'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; +import {MatSelectModule} from '@angular/material/select'; +import {MatIconModule} from '@angular/material/icon'; +import { + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, +} from '@angular/cdk-experimental/accordion'; + +/** @title Configurable Accordion using UI Patterns. */ +@Component({ + selector: 'cdk-accordion-configurable-example', + exportAs: 'cdkAccordionConfigurableExample', + templateUrl: 'cdk-accordion-configurable-example.html', + styleUrl: '../cdk-accordion-examples.css', // Point to shared CSS + standalone: true, + imports: [ + ReactiveFormsModule, + MatIconModule, + MatCheckboxModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, + ], +}) +export class CdkAccordionConfigurableExample { + // Accordion Group Properties + wrap = new FormControl(true, {nonNullable: true}); + multi = new FormControl(true, {nonNullable: true}); + disabled = new FormControl(false, {nonNullable: true}); + skipDisabled = new FormControl(true, {nonNullable: true}); + expandedIds = model(['item1']); + + // Example items + items = ['item1', 'item2', 'item3', 'item4', 'item5']; + + expansionIcon(item: string): Signal { + return computed(() => (this.expandedIds().includes(item) ? 'expand_less' : 'expand_more')); + } +} diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled-focusable/cdk-accordion-disabled-focusable-example.html b/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled-focusable/cdk-accordion-disabled-focusable-example.html new file mode 100644 index 000000000000..04387df81d66 --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled-focusable/cdk-accordion-disabled-focusable-example.html @@ -0,0 +1,77 @@ +
    +
    +

    + +

    +
    + +

    This is the content for Item 1.

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 2. This should not be expandable if trigger is disabled.

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 3.

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 4

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 5

    +
    +
    +
    +
    diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled-focusable/cdk-accordion-disabled-focusable-example.ts b/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled-focusable/cdk-accordion-disabled-focusable-example.ts new file mode 100644 index 000000000000..fc6d5d694058 --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled-focusable/cdk-accordion-disabled-focusable-example.ts @@ -0,0 +1,30 @@ +import {Component, computed, model, Signal} from '@angular/core'; +import {MatIconModule} from '@angular/material/icon'; +import { + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, +} from '@angular/cdk-experimental/accordion'; + +/** @title Accordion with focusable disabled items. */ +@Component({ + selector: 'cdk-accordion-disabled-focusable-example', + templateUrl: 'cdk-accordion-disabled-focusable-example.html', + styleUrl: '../cdk-accordion-examples.css', + standalone: true, + imports: [ + MatIconModule, + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, + ], +}) +export class CdkAccordionDisabledFocusableExample { + expandedIds = model([]); + + expansionIcon(item: string): Signal { + return computed(() => (this.expandedIds().includes(item) ? 'expand_less' : 'expand_more')); + } +} diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled-skipped/cdk-accordion-disabled-skipped-example.html b/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled-skipped/cdk-accordion-disabled-skipped-example.html new file mode 100644 index 000000000000..617780d401ca --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled-skipped/cdk-accordion-disabled-skipped-example.html @@ -0,0 +1,77 @@ +
    +
    +

    + +

    +
    + +

    This is the content for Item 1.

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 2. This should not be reachable or expandable.

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 3.

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 4

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 5

    +
    +
    +
    +
    diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled-skipped/cdk-accordion-disabled-skipped-example.ts b/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled-skipped/cdk-accordion-disabled-skipped-example.ts new file mode 100644 index 000000000000..c37b1a744a79 --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled-skipped/cdk-accordion-disabled-skipped-example.ts @@ -0,0 +1,30 @@ +import {Component, computed, model, Signal} from '@angular/core'; +import {MatIconModule} from '@angular/material/icon'; +import { + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, +} from '@angular/cdk-experimental/accordion'; + +/** @title Accordion with skipped disabled items. */ +@Component({ + selector: 'cdk-accordion-disabled-skipped-example', + templateUrl: 'cdk-accordion-disabled-skipped-example.html', + styleUrl: '../cdk-accordion-examples.css', + standalone: true, + imports: [ + MatIconModule, + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, + ], +}) +export class CdkAccordionDisabledSkippedExample { + expandedIds = model([]); + + expansionIcon(item: string): Signal { + return computed(() => (this.expandedIds().includes(item) ? 'expand_less' : 'expand_more')); + } +} diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled/cdk-accordion-disabled-example.html b/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled/cdk-accordion-disabled-example.html new file mode 100644 index 000000000000..3ef9e3d3a16f --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled/cdk-accordion-disabled-example.html @@ -0,0 +1,73 @@ +
    +
    +

    + +

    +
    + +

    This is the content for Item 1.

    + +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 2.

    + +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 3.

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 4

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 5

    +
    +
    +
    +
    diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled/cdk-accordion-disabled-example.ts b/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled/cdk-accordion-disabled-example.ts new file mode 100644 index 000000000000..3476b9e84590 --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion-disabled/cdk-accordion-disabled-example.ts @@ -0,0 +1,30 @@ +import {Component, computed, model, Signal} from '@angular/core'; +import {MatIconModule} from '@angular/material/icon'; +import { + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, +} from '@angular/cdk-experimental/accordion'; + +/** @title Disabled Accordion. */ +@Component({ + selector: 'cdk-accordion-disabled-example', + templateUrl: 'cdk-accordion-disabled-example.html', + styleUrl: '../cdk-accordion-examples.css', + standalone: true, + imports: [ + MatIconModule, + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, + ], +}) +export class CdkAccordionDisabledExample { + expandedIds = model(['item1']); + + expansionIcon(item: string): Signal { + return computed(() => (this.expandedIds().includes(item) ? 'expand_less' : 'expand_more')); + } +} diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion-examples.css b/src/components-examples/cdk-experimental/accordion/cdk-accordion-examples.css new file mode 100644 index 000000000000..9cf934ec507a --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion-examples.css @@ -0,0 +1,54 @@ +/* Common styles for CDK Accordion examples */ +.example-accordion-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + padding-bottom: 16px; + margin-bottom: 16px; +} + +.example-accordion-header { + margin: 0; +} + +.example-accordion-header .cdk-accordion-trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 12px 16px; + border: 1px solid transparent; + background-color: var(--mat-sys-surface); + cursor: pointer; + border-radius: 4px; + margin-bottom: 4px; +} + +.example-accordion-header .cdk-accordion-trigger:hover { + background-color: var(--mat-sys-surface-container-highest); +} + +.example-accordion-header .cdk-accordion-trigger.cdk-active { + background-color: var(--mat-sys-surface-dim); +} + +.example-accordion-header .cdk-accordion-trigger:focus { + outline: 2px solid var(--mat-sys-primary); + outline-offset: -2px; +} + +.example-accordion-header .cdk-accordion-trigger[aria-disabled='true'] { + color: inherit; + opacity: 0.6; +} + +.example-accordion-panel { + padding: 16px; + background-color: var(--mat-sys-surface-container); + margin-bottom: 4px; +} + +.example-accordion-panel[inert] { + display: none; +} diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion-multi-expansion/cdk-accordion-multi-expansion-example.html b/src/components-examples/cdk-experimental/accordion/cdk-accordion-multi-expansion/cdk-accordion-multi-expansion-example.html new file mode 100644 index 000000000000..5a95345b9df2 --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion-multi-expansion/cdk-accordion-multi-expansion-example.html @@ -0,0 +1,90 @@ +
    +
    +

    + +

    +
    + +

    This is the content for Item 1. Multiple items can be expanded.

    + +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 2.

    + +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 3.

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 4

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 5

    +
    +
    +
    +
    diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion-multi-expansion/cdk-accordion-multi-expansion-example.ts b/src/components-examples/cdk-experimental/accordion/cdk-accordion-multi-expansion/cdk-accordion-multi-expansion-example.ts new file mode 100644 index 000000000000..5c98cc70f684 --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion-multi-expansion/cdk-accordion-multi-expansion-example.ts @@ -0,0 +1,30 @@ +import {Component, computed, model, Signal} from '@angular/core'; +import {MatIconModule} from '@angular/material/icon'; +import { + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, +} from '@angular/cdk-experimental/accordion'; + +/** @title Accordion with multi-expansion. */ +@Component({ + selector: 'cdk-accordion-multi-expansion-example', + templateUrl: 'cdk-accordion-multi-expansion-example.html', + styleUrl: '../cdk-accordion-examples.css', + standalone: true, + imports: [ + MatIconModule, + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, + ], +}) +export class CdkAccordionMultiExpansionExample { + expandedIds = model([]); + + expansionIcon(item: string): Signal { + return computed(() => (this.expandedIds().includes(item) ? 'expand_less' : 'expand_more')); + } +} diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion-single-expansion/cdk-accordion-single-expansion-example.html b/src/components-examples/cdk-experimental/accordion/cdk-accordion-single-expansion/cdk-accordion-single-expansion-example.html new file mode 100644 index 000000000000..5579965c75d0 --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion-single-expansion/cdk-accordion-single-expansion-example.html @@ -0,0 +1,90 @@ +
    +
    +

    + +

    +
    + +

    This is the content for Item 1.

    + +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 2.

    + +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 3.

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 4

    +
    +
    +
    + +
    +

    + +

    +
    + +

    This is the content for Item 5

    +
    +
    +
    +
    diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion-single-expansion/cdk-accordion-single-expansion-example.ts b/src/components-examples/cdk-experimental/accordion/cdk-accordion-single-expansion/cdk-accordion-single-expansion-example.ts new file mode 100644 index 000000000000..4baea4e59896 --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion-single-expansion/cdk-accordion-single-expansion-example.ts @@ -0,0 +1,30 @@ +import {Component, computed, model, Signal} from '@angular/core'; +import {MatIconModule} from '@angular/material/icon'; +import { + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, +} from '@angular/cdk-experimental/accordion'; + +/** @title Accordion with single expansion. */ +@Component({ + selector: 'cdk-accordion-single-expansion-example', + templateUrl: 'cdk-accordion-single-expansion-example.html', + styleUrl: '../cdk-accordion-examples.css', + standalone: true, + imports: [ + MatIconModule, + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, + ], +}) +export class CdkAccordionSingleExpansionExample { + expandedIds = model([]); + + expansionIcon(item: string): Signal { + return computed(() => (this.expandedIds().includes(item) ? 'expand_less' : 'expand_more')); + } +} diff --git a/src/components-examples/cdk-experimental/accordion/index.ts b/src/components-examples/cdk-experimental/accordion/index.ts new file mode 100644 index 000000000000..2c900596445b --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/index.ts @@ -0,0 +1,6 @@ +export {CdkAccordionConfigurableExample} from './cdk-accordion-configurable/cdk-accordion-configurable-example'; +export {CdkAccordionSingleExpansionExample} from './cdk-accordion-single-expansion/cdk-accordion-single-expansion-example'; +export {CdkAccordionMultiExpansionExample} from './cdk-accordion-multi-expansion/cdk-accordion-multi-expansion-example'; +export {CdkAccordionDisabledFocusableExample} from './cdk-accordion-disabled-focusable/cdk-accordion-disabled-focusable-example'; +export {CdkAccordionDisabledSkippedExample} from './cdk-accordion-disabled-skipped/cdk-accordion-disabled-skipped-example'; +export {CdkAccordionDisabledExample} from './cdk-accordion-disabled/cdk-accordion-disabled-example'; diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-active-descendant/cdk-listbox-active-descendant-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox-active-descendant/cdk-listbox-active-descendant-example.html new file mode 100644 index 000000000000..991e7ada4027 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-active-descendant/cdk-listbox-active-descendant-example.html @@ -0,0 +1,24 @@ + +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-active-descendant/cdk-listbox-active-descendant-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox-active-descendant/cdk-listbox-active-descendant-example.ts new file mode 100644 index 000000000000..c793d16a1c86 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-active-descendant/cdk-listbox-active-descendant-example.ts @@ -0,0 +1,52 @@ +import {Component} from '@angular/core'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import {MatPseudoCheckbox} from '@angular/material/core'; + +/** + * @title Listbox with active descendant. + */ +@Component({ + selector: 'cdk-listbox-active-descendant-example', + exportAs: 'cdkListboxActiveDescendantExample', + templateUrl: 'cdk-listbox-active-descendant-example.html', + styleUrl: '../cdk-listbox-configurable/cdk-listbox-configurable-example.css', + standalone: true, + imports: [CdkListbox, CdkOption, MatPseudoCheckbox], +}) +export class CdkListboxActiveDescendantExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css b/src/components-examples/cdk-experimental/listbox/cdk-listbox-configurable/cdk-listbox-configurable-example.css similarity index 91% rename from src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css rename to src/components-examples/cdk-experimental/listbox/cdk-listbox-configurable/cdk-listbox-configurable-example.css index 9468f20b1f46..18623b6ccfdb 100644 --- a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-configurable/cdk-listbox-configurable-example.css @@ -9,7 +9,7 @@ gap: 4px; margin: 0; padding: 8px; - max-height: 50vh; + max-height: 300px; border: 1px solid var(--mat-sys-outline); border-radius: var(--mat-sys-corner-extra-small); display: flex; @@ -31,6 +31,14 @@ display: block; } +.example-listbox[aria-disabled='true'] { + opacity: 0.5; +} + +.example-listbox[aria-disabled='true'] .example-option { + pointer-events: none; +} + .example-label { padding: 16px; flex-shrink: 0; diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-configurable/cdk-listbox-configurable-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox-configurable/cdk-listbox-configurable-example.html new file mode 100644 index 000000000000..2e7b45026674 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-configurable/cdk-listbox-configurable-example.html @@ -0,0 +1,85 @@ +
    + Wrap + Multi + Disabled + Readonly + Skip Disabled + + + Selection + + @for (fruit of fruits; track fruit) { + {{fruit}} + } + + + + + Disabled Options + + @for (fruit of fruits; track fruit) { + {{fruit}} + } + + + + + Orientation + + Vertical + Horizontal + + + + + Selection strategy + + Explicit + Follow Focus + + + + + Focus strategy + + Roving Tabindex + Active Descendant + + +
    + + +
      + + + @for (fruit of fruits; track fruit) { + @let optionDisabled = disabledOptions.includes(fruit); + +
    • + + {{ fruit }} +
    • + } +
    + diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox-configurable/cdk-listbox-configurable-example.ts similarity index 84% rename from src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.ts rename to src/components-examples/cdk-experimental/listbox/cdk-listbox-configurable/cdk-listbox-configurable-example.ts index cbf8d11870b1..73aacf57894f 100644 --- a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.ts +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-configurable/cdk-listbox-configurable-example.ts @@ -6,12 +6,13 @@ import {MatSelectModule} from '@angular/material/select'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {MatPseudoCheckbox} from '@angular/material/core'; -/** @title Listbox using UI Patterns. */ +/** @title Configurable Listbox. */ @Component({ - selector: 'cdk-listbox-example', - exportAs: 'cdkListboxExample', - templateUrl: 'cdk-listbox-example.html', - styleUrl: 'cdk-listbox-example.css', + selector: 'cdk-listbox-configurable-example', + exportAs: 'cdkListboxConfigurableExample', + templateUrl: 'cdk-listbox-configurable-example.html', + styleUrl: 'cdk-listbox-configurable-example.css', + standalone: true, imports: [ CdkListbox, CdkOption, @@ -22,7 +23,7 @@ import {MatPseudoCheckbox} from '@angular/material/core'; MatPseudoCheckbox, ], }) -export class CdkListboxExample { +export class CdkListboxConfigurableExample { orientation: 'vertical' | 'horizontal' = 'vertical'; focusMode: 'roving' | 'activedescendant' = 'roving'; selectionMode: 'explicit' | 'follow' = 'explicit'; diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled-focusable/cdk-listbox-disabled-focusable-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled-focusable/cdk-listbox-disabled-focusable-example.html new file mode 100644 index 000000000000..1fa8e3c12ed9 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled-focusable/cdk-listbox-disabled-focusable-example.html @@ -0,0 +1,25 @@ + +
    +
      + + @for (fruit of fruits; track fruit; let i = $index) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled-focusable/cdk-listbox-disabled-focusable-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled-focusable/cdk-listbox-disabled-focusable-example.ts new file mode 100644 index 000000000000..e36e7abb6958 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled-focusable/cdk-listbox-disabled-focusable-example.ts @@ -0,0 +1,52 @@ +import {Component} from '@angular/core'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import {MatPseudoCheckbox} from '@angular/material/core'; + +/** + * @title Listbox with focusable disabled options. + */ +@Component({ + selector: 'cdk-listbox-disabled-focusable-example', + exportAs: 'cdkListboxDisabledFocusableExample', + templateUrl: 'cdk-listbox-disabled-focusable-example.html', + styleUrl: '../cdk-listbox-configurable/cdk-listbox-configurable-example.css', + standalone: true, + imports: [CdkListbox, CdkOption, MatPseudoCheckbox], +}) +export class CdkListboxDisabledFocusableExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled-skipped/cdk-listbox-disabled-skipped-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled-skipped/cdk-listbox-disabled-skipped-example.html new file mode 100644 index 000000000000..2a33af633673 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled-skipped/cdk-listbox-disabled-skipped-example.html @@ -0,0 +1,25 @@ + +
    +
      + + @for (fruit of fruits; track fruit; let i = $index) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled-skipped/cdk-listbox-disabled-skipped-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled-skipped/cdk-listbox-disabled-skipped-example.ts new file mode 100644 index 000000000000..634e2555d2b4 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled-skipped/cdk-listbox-disabled-skipped-example.ts @@ -0,0 +1,52 @@ +import {Component} from '@angular/core'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import {MatPseudoCheckbox} from '@angular/material/core'; + +/** + * @title Listbox with skipped disabled options. + */ +@Component({ + selector: 'cdk-listbox-disabled-skipped-example', + exportAs: 'cdkListboxDisabledSkippedExample', + templateUrl: 'cdk-listbox-disabled-skipped-example.html', + styleUrl: '../cdk-listbox-configurable/cdk-listbox-configurable-example.css', + standalone: true, + imports: [CdkListbox, CdkOption, MatPseudoCheckbox], +}) +export class CdkListboxDisabledSkippedExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled/cdk-listbox-disabled-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled/cdk-listbox-disabled-example.html new file mode 100644 index 000000000000..1a8a6454d028 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled/cdk-listbox-disabled-example.html @@ -0,0 +1,23 @@ +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled/cdk-listbox-disabled-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled/cdk-listbox-disabled-example.ts new file mode 100644 index 000000000000..a07fca12529c --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-disabled/cdk-listbox-disabled-example.ts @@ -0,0 +1,52 @@ +import {Component} from '@angular/core'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import {MatPseudoCheckbox} from '@angular/material/core'; + +/** + * @title Listbox with disabled state. + */ +@Component({ + selector: 'cdk-listbox-disabled-example', + exportAs: 'cdkListboxDisabledExample', + templateUrl: 'cdk-listbox-disabled-example.html', + styleUrl: '../cdk-listbox-configurable/cdk-listbox-configurable-example.css', + standalone: true, + imports: [CdkListbox, CdkOption, MatPseudoCheckbox], +}) +export class CdkListboxDisabledExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-horizontal/cdk-listbox-horizontal-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox-horizontal/cdk-listbox-horizontal-example.html new file mode 100644 index 000000000000..10534294e3d8 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-horizontal/cdk-listbox-horizontal-example.html @@ -0,0 +1,24 @@ + +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-horizontal/cdk-listbox-horizontal-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox-horizontal/cdk-listbox-horizontal-example.ts new file mode 100644 index 000000000000..ade7fd988ea1 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-horizontal/cdk-listbox-horizontal-example.ts @@ -0,0 +1,52 @@ +import {Component} from '@angular/core'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import {MatPseudoCheckbox} from '@angular/material/core'; + +/** + * @title Listbox with horizontal orientation. + */ +@Component({ + selector: 'cdk-listbox-horizontal-example', + exportAs: 'cdkListboxHorizontalExample', + templateUrl: 'cdk-listbox-horizontal-example.html', + styleUrl: '../cdk-listbox-configurable/cdk-listbox-configurable-example.css', + standalone: true, + imports: [CdkListbox, CdkOption, MatPseudoCheckbox], +}) +export class CdkListboxHorizontalExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select-example.ts new file mode 100644 index 000000000000..80ce5615280c --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select-example.ts @@ -0,0 +1,37 @@ +export class CdkListboxMultiSelectExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select-follow-focus/cdk-listbox-multi-select-follow-focus-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select-follow-focus/cdk-listbox-multi-select-follow-focus-example.html new file mode 100644 index 000000000000..5e432dfecba5 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select-follow-focus/cdk-listbox-multi-select-follow-focus-example.html @@ -0,0 +1,25 @@ + +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select-follow-focus/cdk-listbox-multi-select-follow-focus-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select-follow-focus/cdk-listbox-multi-select-follow-focus-example.ts new file mode 100644 index 000000000000..d28bb80451cc --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select-follow-focus/cdk-listbox-multi-select-follow-focus-example.ts @@ -0,0 +1,52 @@ +import {Component} from '@angular/core'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import {MatPseudoCheckbox} from '@angular/material/core'; + +/** + * @title Listbox with multi-selection and selection following focus. + */ +@Component({ + selector: 'cdk-listbox-multi-select-follow-focus-example', + exportAs: 'cdkListboxMultiSelectFollowFocusExample', + templateUrl: 'cdk-listbox-multi-select-follow-focus-example.html', + styleUrl: '../cdk-listbox-configurable/cdk-listbox-configurable-example.css', + standalone: true, + imports: [CdkListbox, CdkOption, MatPseudoCheckbox], +}) +export class CdkListboxMultiSelectFollowFocusExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select/cdk-listbox-multi-select-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select/cdk-listbox-multi-select-example.html new file mode 100644 index 000000000000..8e919efe4afa --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select/cdk-listbox-multi-select-example.html @@ -0,0 +1,25 @@ + +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select/cdk-listbox-multi-select-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select/cdk-listbox-multi-select-example.ts new file mode 100644 index 000000000000..5fe97263b80f --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-multi-select/cdk-listbox-multi-select-example.ts @@ -0,0 +1,48 @@ +import {Component} from '@angular/core'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import {MatPseudoCheckbox} from '@angular/material/core'; + +@Component({ + selector: 'cdk-listbox-multi-select-example', + templateUrl: './cdk-listbox-multi-select-example.html', + styleUrl: '../cdk-listbox-configurable/cdk-listbox-configurable-example.css', + standalone: true, + imports: [CdkListbox, CdkOption, MatPseudoCheckbox], +}) +export class CdkListboxMultiSelectExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-readonly/cdk-listbox-readonly-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox-readonly/cdk-listbox-readonly-example.html new file mode 100644 index 000000000000..25d87f9374ae --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-readonly/cdk-listbox-readonly-example.html @@ -0,0 +1,24 @@ + +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-readonly/cdk-listbox-readonly-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox-readonly/cdk-listbox-readonly-example.ts new file mode 100644 index 000000000000..e04d549d2bb3 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-readonly/cdk-listbox-readonly-example.ts @@ -0,0 +1,52 @@ +import {Component} from '@angular/core'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import {MatPseudoCheckbox} from '@angular/material/core'; + +/** + * @title Listbox with readonly state. + */ +@Component({ + selector: 'cdk-listbox-readonly-example', + exportAs: 'cdkListboxReadonlyExample', + templateUrl: 'cdk-listbox-readonly-example.html', + styleUrl: '../cdk-listbox-configurable/cdk-listbox-configurable-example.css', + standalone: true, + imports: [CdkListbox, CdkOption, MatPseudoCheckbox], +}) +export class CdkListboxReadonlyExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-rtl-horizontal/cdk-listbox-rtl-horizontal-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox-rtl-horizontal/cdk-listbox-rtl-horizontal-example.html new file mode 100644 index 000000000000..acbb2687b853 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-rtl-horizontal/cdk-listbox-rtl-horizontal-example.html @@ -0,0 +1,24 @@ + +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-rtl-horizontal/cdk-listbox-rtl-horizontal-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox-rtl-horizontal/cdk-listbox-rtl-horizontal-example.ts new file mode 100644 index 000000000000..2f6e890cb851 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-rtl-horizontal/cdk-listbox-rtl-horizontal-example.ts @@ -0,0 +1,52 @@ +import {Component} from '@angular/core'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import {MatPseudoCheckbox} from '@angular/material/core'; + +/** + * @title Listbox with RTL and horizontal orientation. + */ +@Component({ + selector: 'cdk-listbox-rtl-horizontal-example', + exportAs: 'cdkListboxRtlHorizontalExample', + templateUrl: 'cdk-listbox-rtl-horizontal-example.html', + styleUrl: '../cdk-listbox-configurable/cdk-listbox-configurable-example.css', + standalone: true, + imports: [CdkListbox, CdkOption, MatPseudoCheckbox], +}) +export class CdkListboxRtlHorizontalExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-single-select-follow-focus/cdk-listbox-single-select-follow-focus-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox-single-select-follow-focus/cdk-listbox-single-select-follow-focus-example.html new file mode 100644 index 000000000000..c89087b2d263 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-single-select-follow-focus/cdk-listbox-single-select-follow-focus-example.html @@ -0,0 +1,25 @@ + +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-single-select-follow-focus/cdk-listbox-single-select-follow-focus-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox-single-select-follow-focus/cdk-listbox-single-select-follow-focus-example.ts new file mode 100644 index 000000000000..1c49a9020e23 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-single-select-follow-focus/cdk-listbox-single-select-follow-focus-example.ts @@ -0,0 +1,50 @@ +import {Component} from '@angular/core'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import {MatPseudoCheckbox} from '@angular/material/core'; + +/** @title Listbox with single selection and selection follows focus. */ +@Component({ + selector: 'cdk-listbox-single-select-follow-focus-example', + exportAs: 'cdkListboxSingleSelectFollowFocusExample', + templateUrl: 'cdk-listbox-single-select-follow-focus-example.html', + styleUrl: '../cdk-listbox-configurable/cdk-listbox-configurable-example.css', // Reuse common styles + standalone: true, + imports: [CdkListbox, CdkOption, MatPseudoCheckbox], +}) +export class CdkListboxSingleSelectFollowFocusExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-single-select/cdk-listbox-single-select-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox-single-select/cdk-listbox-single-select-example.html new file mode 100644 index 000000000000..08ee0a68084c --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-single-select/cdk-listbox-single-select-example.html @@ -0,0 +1,25 @@ + +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox-single-select/cdk-listbox-single-select-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox-single-select/cdk-listbox-single-select-example.ts new file mode 100644 index 000000000000..0cfd2a89e843 --- /dev/null +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox-single-select/cdk-listbox-single-select-example.ts @@ -0,0 +1,50 @@ +import {Component} from '@angular/core'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import {MatPseudoCheckbox} from '@angular/material/core'; + +/** @title Listbox with single selection. */ +@Component({ + selector: 'cdk-listbox-single-select-example', + exportAs: 'cdkListboxSingleSelectExample', + templateUrl: 'cdk-listbox-single-select-example.html', + styleUrl: '../cdk-listbox-configurable/cdk-listbox-configurable-example.css', // Reuse common styles + standalone: true, + imports: [CdkListbox, CdkOption, MatPseudoCheckbox], +}) +export class CdkListboxSingleSelectExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html index 2e7b45026674..e69de29bb2d1 100644 --- a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html @@ -1,85 +0,0 @@ -
    - Wrap - Multi - Disabled - Readonly - Skip Disabled - - - Selection - - @for (fruit of fruits; track fruit) { - {{fruit}} - } - - - - - Disabled Options - - @for (fruit of fruits; track fruit) { - {{fruit}} - } - - - - - Orientation - - Vertical - Horizontal - - - - - Selection strategy - - Explicit - Follow Focus - - - - - Focus strategy - - Roving Tabindex - Active Descendant - - -
    - - -
      - - - @for (fruit of fruits; track fruit) { - @let optionDisabled = disabledOptions.includes(fruit); - -
    • - - {{ fruit }} -
    • - } -
    - diff --git a/src/components-examples/cdk-experimental/listbox/index.ts b/src/components-examples/cdk-experimental/listbox/index.ts index 7fa9cb73b9cc..225e166fc00f 100644 --- a/src/components-examples/cdk-experimental/listbox/index.ts +++ b/src/components-examples/cdk-experimental/listbox/index.ts @@ -1 +1,12 @@ -export {CdkListboxExample} from './cdk-listbox/cdk-listbox-example'; +export {CdkListboxConfigurableExample} from './cdk-listbox-configurable/cdk-listbox-configurable-example'; +export {CdkListboxSingleSelectExample} from './cdk-listbox-single-select/cdk-listbox-single-select-example'; +export {CdkListboxMultiSelectExample} from './cdk-listbox-multi-select/cdk-listbox-multi-select-example'; +export {CdkListboxSingleSelectFollowFocusExample} from './cdk-listbox-single-select-follow-focus/cdk-listbox-single-select-follow-focus-example'; +export {CdkListboxMultiSelectFollowFocusExample} from './cdk-listbox-multi-select-follow-focus/cdk-listbox-multi-select-follow-focus-example'; +export {CdkListboxHorizontalExample} from './cdk-listbox-horizontal/cdk-listbox-horizontal-example'; +export {CdkListboxRtlHorizontalExample} from './cdk-listbox-rtl-horizontal/cdk-listbox-rtl-horizontal-example'; +export {CdkListboxActiveDescendantExample} from './cdk-listbox-active-descendant/cdk-listbox-active-descendant-example'; +export {CdkListboxDisabledFocusableExample} from './cdk-listbox-disabled-focusable/cdk-listbox-disabled-focusable-example'; +export {CdkListboxDisabledSkippedExample} from './cdk-listbox-disabled-skipped/cdk-listbox-disabled-skipped-example'; +export {CdkListboxReadonlyExample} from './cdk-listbox-readonly/cdk-listbox-readonly-example'; +export {CdkListboxDisabledExample} from './cdk-listbox-disabled/cdk-listbox-disabled-example'; diff --git a/src/components-examples/cdk-experimental/radio-group/BUILD.bazel b/src/components-examples/cdk-experimental/radio-group/BUILD.bazel new file mode 100644 index 000000000000..e95ccca18a3e --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/BUILD.bazel @@ -0,0 +1,29 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "radio-group", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//:node_modules/@angular/core", + "//:node_modules/@angular/forms", + "//src/cdk-experimental/radio-group", + "//src/material/checkbox", + "//src/material/form-field", + "//src/material/select", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-active-descendant/cdk-radio-group-active-descendant-example.html b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-active-descendant/cdk-radio-group-active-descendant-example.html new file mode 100644 index 000000000000..4a37726ff265 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-active-descendant/cdk-radio-group-active-descendant-example.html @@ -0,0 +1,21 @@ +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-active-descendant/cdk-radio-group-active-descendant-example.ts b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-active-descendant/cdk-radio-group-active-descendant-example.ts new file mode 100644 index 000000000000..ea490f3d988f --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-active-descendant/cdk-radio-group-active-descendant-example.ts @@ -0,0 +1,50 @@ +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {CdkRadioGroup, CdkRadioButton} from '@angular/cdk-experimental/radio-group'; + +/** @title Active descendant radio group. */ +@Component({ + selector: 'cdk-radio-group-active-descendant-example', + exportAs: 'cdkRadioActiveDescendantExample', + templateUrl: 'cdk-radio-group-active-descendant-example.html', + standalone: true, + styleUrl: '../radio-common.css', + imports: [CdkRadioGroup, CdkRadioButton, FormsModule], +}) +export class CdkRadioGroupActiveDescendantExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-configurable/cdk-radio-group-configurable-example.html b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-configurable/cdk-radio-group-configurable-example.html new file mode 100644 index 000000000000..4a0841e15224 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-configurable/cdk-radio-group-configurable-example.html @@ -0,0 +1,51 @@ +
    + Disabled + Readonly + Skip Disabled + + + Disabled Radio Options + + @for (fruit of fruits; track fruit) { + {{fruit}} + } + + + + + Orientation + + Vertical + Horizontal + + + + + Focus strategy + + Roving Tabindex + Active Descendant + + +
    + + +
      + @for (fruit of fruits; track fruit) { + @let optionDisabled = disabledOptions.includes(fruit); +
    • + + {{ fruit }} +
    • + } +
    + diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-configurable/cdk-radio-group-configurable-example.ts b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-configurable/cdk-radio-group-configurable-example.ts new file mode 100644 index 000000000000..4878a9fbd4ad --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-configurable/cdk-radio-group-configurable-example.ts @@ -0,0 +1,72 @@ +import {Component} from '@angular/core'; +import {CdkRadioGroup, CdkRadioButton} from '@angular/cdk-experimental/radio-group'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; + +/** @title Configurable CDK Radio Group */ +@Component({ + selector: 'cdk-radio-group-configurable-example', + exportAs: 'cdkRadioConfigurableExample', + templateUrl: 'cdk-radio-group-configurable-example.html', + styleUrl: '../radio-common.css', + standalone: true, + imports: [ + CdkRadioGroup, + CdkRadioButton, + MatCheckboxModule, + MatFormFieldModule, + MatSelectModule, + FormsModule, + ReactiveFormsModule, + ], +}) +export class CdkRadioGroupConfigurableExample { + orientation: 'vertical' | 'horizontal' = 'vertical'; + disabled = new FormControl(false, {nonNullable: true}); + + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; + + // New controls + readonly = new FormControl(false, {nonNullable: true}); + skipDisabled = new FormControl(true, {nonNullable: true}); + focusMode: 'roving' | 'activedescendant' = 'roving'; + + // Control for which radio options are individually disabled + disabledOptions: string[] = ['Banana']; +} diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled-focusable/cdk-radio-group-disabled-focusable-example.html b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled-focusable/cdk-radio-group-disabled-focusable-example.html new file mode 100644 index 000000000000..40d78c7af080 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled-focusable/cdk-radio-group-disabled-focusable-example.html @@ -0,0 +1,22 @@ +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} {{ disabledFruits.includes(fruit) ? '(Disabled)' : '' }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled-focusable/cdk-radio-group-disabled-focusable-example.ts b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled-focusable/cdk-radio-group-disabled-focusable-example.ts new file mode 100644 index 000000000000..1af6decfda58 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled-focusable/cdk-radio-group-disabled-focusable-example.ts @@ -0,0 +1,51 @@ +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {CdkRadioGroup, CdkRadioButton} from '@angular/cdk-experimental/radio-group'; + +/** @title Radio group with disabled options that are focusable. */ +@Component({ + selector: 'cdk-radio-group-disabled-focusable-example', + exportAs: 'cdkRadioDisabledFocusableExample', + templateUrl: 'cdk-radio-group-disabled-focusable-example.html', + styleUrl: '../radio-common.css', + standalone: true, + imports: [CdkRadioGroup, CdkRadioButton, FormsModule], +}) +export class CdkRadioGroupDisabledFocusableExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; + disabledFruits = ['Banana', 'Kiwi']; +} diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled-skipped/cdk-radio-group-disabled-skipped-example.html b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled-skipped/cdk-radio-group-disabled-skipped-example.html new file mode 100644 index 000000000000..bbd845fc927d --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled-skipped/cdk-radio-group-disabled-skipped-example.html @@ -0,0 +1,21 @@ +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} {{ disabledFruits.includes(fruit) ? '(Disabled)' : '' }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled-skipped/cdk-radio-group-disabled-skipped-example.ts b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled-skipped/cdk-radio-group-disabled-skipped-example.ts new file mode 100644 index 000000000000..94e118efa161 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled-skipped/cdk-radio-group-disabled-skipped-example.ts @@ -0,0 +1,51 @@ +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {CdkRadioGroup, CdkRadioButton} from '@angular/cdk-experimental/radio-group'; + +/** @title Radio group with disabled options that are skipped. */ +@Component({ + selector: 'cdk-radio-group-disabled-skipped-example', + exportAs: 'cdkRadioDisabledSkippedExample', + templateUrl: 'cdk-radio-group-disabled-skipped-example.html', + styleUrl: '../radio-common.css', + standalone: true, + imports: [CdkRadioGroup, CdkRadioButton, FormsModule], +}) +export class CdkRadioGroupDisabledSkippedExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; + disabledFruits = ['Banana', 'Kiwi']; +} diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled/cdk-radio-group-disabled-example.html b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled/cdk-radio-group-disabled-example.html new file mode 100644 index 000000000000..9009f4cb43e4 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled/cdk-radio-group-disabled-example.html @@ -0,0 +1,20 @@ +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +

    The entire radio group is disabled. Focus should not enter the group.

    +
    diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled/cdk-radio-group-disabled-example.ts b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled/cdk-radio-group-disabled-example.ts new file mode 100644 index 000000000000..f891946ecbce --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-disabled/cdk-radio-group-disabled-example.ts @@ -0,0 +1,50 @@ +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {CdkRadioGroup, CdkRadioButton} from '@angular/cdk-experimental/radio-group'; + +/** @title Disabled radio group. */ +@Component({ + selector: 'cdk-radio-group-disabled-example', + exportAs: 'cdkRadioDisabledExample', + templateUrl: 'cdk-radio-group-disabled-example.html', + styleUrl: '../radio-common.css', + standalone: true, + imports: [CdkRadioGroup, CdkRadioButton, FormsModule], +}) +export class CdkRadioGroupDisabledExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-horizontal/cdk-radio-group-horizontal-example.html b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-horizontal/cdk-radio-group-horizontal-example.html new file mode 100644 index 000000000000..2b22dd8b2b7d --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-horizontal/cdk-radio-group-horizontal-example.html @@ -0,0 +1,21 @@ +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-horizontal/cdk-radio-group-horizontal-example.ts b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-horizontal/cdk-radio-group-horizontal-example.ts new file mode 100644 index 000000000000..05e8339eb536 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-horizontal/cdk-radio-group-horizontal-example.ts @@ -0,0 +1,50 @@ +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {CdkRadioGroup, CdkRadioButton} from '@angular/cdk-experimental/radio-group'; + +/** @title Horizontal radio group. */ +@Component({ + selector: 'cdk-radio-group-horizontal-example', + exportAs: 'cdkRadioHorizontalExample', + templateUrl: 'cdk-radio-group-horizontal-example.html', + styleUrl: '../radio-common.css', + standalone: true, + imports: [CdkRadioGroup, CdkRadioButton, FormsModule], +}) +export class CdkRadioGroupHorizontalExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-readonly/cdk-radio-group-readonly-example.html b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-readonly/cdk-radio-group-readonly-example.html new file mode 100644 index 000000000000..2eaf19aed13c --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-readonly/cdk-radio-group-readonly-example.html @@ -0,0 +1,22 @@ +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +

    The radio group is navigable, but selection cannot be changed.

    +
    diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-readonly/cdk-radio-group-readonly-example.ts b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-readonly/cdk-radio-group-readonly-example.ts new file mode 100644 index 000000000000..156ebac90e35 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-readonly/cdk-radio-group-readonly-example.ts @@ -0,0 +1,50 @@ +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {CdkRadioGroup, CdkRadioButton} from '@angular/cdk-experimental/radio-group'; + +/** @title Readonly radio group. */ +@Component({ + selector: 'cdk-radio-group-readonly-example', + exportAs: 'cdkRadioReadonlyExample', + templateUrl: 'cdk-radio-group-readonly-example.html', + styleUrl: '../radio-common.css', + standalone: true, + imports: [CdkRadioGroup, CdkRadioButton, FormsModule], +}) +export class CdkRadioGroupReadonlyExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-standard/cdk-radio-group-standard-example.html b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-standard/cdk-radio-group-standard-example.html new file mode 100644 index 000000000000..d4f2d1b0eaec --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-standard/cdk-radio-group-standard-example.html @@ -0,0 +1,18 @@ +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-standard/cdk-radio-group-standard-example.ts b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-standard/cdk-radio-group-standard-example.ts new file mode 100644 index 000000000000..d83218b19ca8 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-group-standard/cdk-radio-group-standard-example.ts @@ -0,0 +1,50 @@ +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {CdkRadioGroup, CdkRadioButton} from '@angular/cdk-experimental/radio-group'; + +/** @title Basic radio group. */ +@Component({ + selector: 'cdk-radio-group-standard-example', + exportAs: 'cdkRadioStandardExample', + templateUrl: 'cdk-radio-group-standard-example.html', + styleUrl: '../radio-common.css', + standalone: true, + imports: [CdkRadioGroup, CdkRadioButton, FormsModule], +}) +export class CdkRadioGroupStandardExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-rtl-group-horizontal/cdk-radio-group-rtl-horizontal-example.html b/src/components-examples/cdk-experimental/radio-group/cdk-radio-rtl-group-horizontal/cdk-radio-group-rtl-horizontal-example.html new file mode 100644 index 000000000000..55d953ccb8a8 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-rtl-group-horizontal/cdk-radio-group-rtl-horizontal-example.html @@ -0,0 +1,21 @@ +
    +
      + + @for (fruit of fruits; track fruit) { +
    • + + {{ fruit }} +
    • + } +
    +
    diff --git a/src/components-examples/cdk-experimental/radio-group/cdk-radio-rtl-group-horizontal/cdk-radio-group-rtl-horizontal-example.ts b/src/components-examples/cdk-experimental/radio-group/cdk-radio-rtl-group-horizontal/cdk-radio-group-rtl-horizontal-example.ts new file mode 100644 index 000000000000..46cba8f6a37a --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/cdk-radio-rtl-group-horizontal/cdk-radio-group-rtl-horizontal-example.ts @@ -0,0 +1,50 @@ +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {CdkRadioGroup, CdkRadioButton} from '@angular/cdk-experimental/radio-group'; + +/** @title RTL horizontal radio group. */ +@Component({ + selector: 'cdk-radio-group-rtl-horizontal-example', + exportAs: 'cdkRadioRtlHorizontalExample', + templateUrl: 'cdk-radio-group-rtl-horizontal-example.html', + styleUrl: '../radio-common.css', + standalone: true, + imports: [CdkRadioGroup, CdkRadioButton, FormsModule], +}) +export class CdkRadioGroupRtlHorizontalExample { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} diff --git a/src/components-examples/cdk-experimental/radio-group/index.ts b/src/components-examples/cdk-experimental/radio-group/index.ts new file mode 100644 index 000000000000..3e6f0947f13e --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/index.ts @@ -0,0 +1,9 @@ +export {CdkRadioGroupStandardExample} from './cdk-radio-group-standard/cdk-radio-group-standard-example'; +export {CdkRadioGroupHorizontalExample} from './cdk-radio-group-horizontal/cdk-radio-group-horizontal-example'; +export {CdkRadioGroupRtlHorizontalExample} from './cdk-radio-rtl-group-horizontal/cdk-radio-group-rtl-horizontal-example'; +export {CdkRadioGroupActiveDescendantExample} from './cdk-radio-group-active-descendant/cdk-radio-group-active-descendant-example'; +export {CdkRadioGroupDisabledFocusableExample} from './cdk-radio-group-disabled-focusable/cdk-radio-group-disabled-focusable-example'; +export {CdkRadioGroupDisabledSkippedExample} from './cdk-radio-group-disabled-skipped/cdk-radio-group-disabled-skipped-example'; +export {CdkRadioGroupReadonlyExample} from './cdk-radio-group-readonly/cdk-radio-group-readonly-example'; +export {CdkRadioGroupDisabledExample} from './cdk-radio-group-disabled/cdk-radio-group-disabled-example'; +export {CdkRadioGroupConfigurableExample} from './cdk-radio-group-configurable/cdk-radio-group-configurable-example'; diff --git a/src/components-examples/cdk-experimental/radio-group/radio-common.css b/src/components-examples/cdk-experimental/radio-group/radio-common.css new file mode 100644 index 000000000000..d9f137fc65c2 --- /dev/null +++ b/src/components-examples/cdk-experimental/radio-group/radio-common.css @@ -0,0 +1,110 @@ +.example-radio-controls { + display: flex; + align-items: center; + gap: 16px; + padding-bottom: 16px; +} + +.example-radio-group { + gap: 4px; + margin: 0; + padding: 8px; + max-height: 300px; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + display: flex; + list-style: none; + flex-direction: column; + overflow: scroll; +} + +.example-radio-group[aria-orientation='horizontal'] { + flex-direction: row; +} + +.example-radio-group[aria-disabled='true'] { + background-color: var(--mat-sys-surface-dim); + pointer-events: none; +} + +.example-radio-group label { + padding: 16px; + flex-shrink: 0; +} + +.example-radio-button { + gap: 16px; + padding: 16px; + display: flex; + cursor: pointer; + position: relative; + align-items: center; + border-radius: var(--mat-sys-corner-extra-small); +} + +/* Basic visual indicator for the radio button */ +.example-radio-indicator { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid var(--mat-sys-outline); + display: inline-block; + position: relative; +} + +.example-radio-button[aria-checked='true'] .example-radio-indicator { + border-color: var(--mat-sys-primary); +} + +.example-radio-button[aria-checked='true'] .example-radio-indicator::after { + content: ''; + display: block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--mat-sys-primary); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.example-radio-button[aria-disabled='true'][aria-checked='true'] .example-radio-indicator::after { + background-color: var(--mat-sys-outline); +} + +.example-radio-button.cdk-active, +.example-radio-button[aria-disabled='false']:hover { + outline: 1px solid var(--mat-sys-outline); + background: var(--mat-sys-surface-container); +} + +.example-radio-button[aria-disabled='false']:focus-within { + outline: 2px solid var(--mat-sys-primary); + background: var(--mat-sys-surface-container); +} + +.example-radio-button.cdk-active[aria-disabled='true'], +.example-radio-button[aria-disabled='true']:focus-within { + outline: 2px solid var(--mat-sys-outline); +} + +.example-radio-button[aria-disabled='true'] { + cursor: default; +} + +.example-radio-button[aria-disabled='true'] span:not(.example-radio-indicator) { + opacity: 0.3; +} + +.example-radio-button[aria-disabled='true']::before { + content: ''; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + border-radius: var(--mat-sys-corner-extra-small); + background-color: var(--mat-sys-on-surface); + opacity: var(--mat-sys-focus-state-layer-opacity); +} diff --git a/src/components-examples/cdk-experimental/tabs/cdk-active-descendant/cdk-tabs-active-descendant-example.html b/src/components-examples/cdk-experimental/tabs/cdk-active-descendant/cdk-tabs-active-descendant-example.html new file mode 100644 index 000000000000..5acc11b6e1a4 --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-active-descendant/cdk-tabs-active-descendant-example.html @@ -0,0 +1,29 @@ +
    +
      +
    • tab 1
    • +
    • tab 2
    • +
    • tab 3
    • +
    • tab 4
    • +
    • tab 5
    • +
    + +
    + Tabpanel 1 +
    + +
    + Tabpanel 2 +
    + +
    + Tabpanel 3 +
    + +
    + Tabpanel 4 +
    + +
    + Tabpanel 5 +
    +
    diff --git a/src/components-examples/cdk-experimental/tabs/cdk-active-descendant/cdk-tabs-active-descendant-example.ts b/src/components-examples/cdk-experimental/tabs/cdk-active-descendant/cdk-tabs-active-descendant-example.ts new file mode 100644 index 000000000000..bfc9ecefd36c --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-active-descendant/cdk-tabs-active-descendant-example.ts @@ -0,0 +1,19 @@ +import {Component} from '@angular/core'; +import { + CdkTab, + CdkTabs, + CdkTabList, + CdkTabPanel, + CdkTabContent, +} from '@angular/cdk-experimental/tabs'; + +/** @title Active Descendant */ +@Component({ + selector: 'cdk-tabs-active-descendant-example', + exportAs: 'cdkTabsActiveDescendantExample', + templateUrl: 'cdk-tabs-active-descendant-example.html', + styleUrls: ['../cdk-tabs-common.css'], + standalone: true, + imports: [CdkTabList, CdkTab, CdkTabs, CdkTabPanel, CdkTabContent], +}) +export class CdkTabsActiveDescendantExample {} diff --git a/src/components-examples/cdk-experimental/tabs/cdk-disabled-focusable/cdk-tabs-disabled-focusable-example.html b/src/components-examples/cdk-experimental/tabs/cdk-disabled-focusable/cdk-tabs-disabled-focusable-example.html new file mode 100644 index 000000000000..3bb4cd1b2981 --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-disabled-focusable/cdk-tabs-disabled-focusable-example.html @@ -0,0 +1,29 @@ +
    +
      +
    • tab 1
    • +
    • tab 2
    • +
    • tab 3
    • +
    • tab 4
    • +
    • tab 5
    • +
    + +
    + Tabpanel 1 +
    + +
    + Tabpanel 2 +
    + +
    + Tabpanel 3 +
    + +
    + Tabpanel 4 +
    + +
    + Tabpanel 5 +
    +
    diff --git a/src/components-examples/cdk-experimental/tabs/cdk-disabled-focusable/cdk-tabs-disabled-focusable-example.ts b/src/components-examples/cdk-experimental/tabs/cdk-disabled-focusable/cdk-tabs-disabled-focusable-example.ts new file mode 100644 index 000000000000..4cbe1f93624a --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-disabled-focusable/cdk-tabs-disabled-focusable-example.ts @@ -0,0 +1,19 @@ +import {Component} from '@angular/core'; +import { + CdkTab, + CdkTabs, + CdkTabList, + CdkTabPanel, + CdkTabContent, +} from '@angular/cdk-experimental/tabs'; + +/** @title Disabled Tabs are Focusable */ +@Component({ + selector: 'cdk-tabs-disabled-focusable-example', + exportAs: 'cdkTabsDisabledFocusableExample', + templateUrl: 'cdk-tabs-disabled-focusable-example.html', + styleUrls: ['../cdk-tabs-common.css'], + standalone: true, + imports: [CdkTabList, CdkTab, CdkTabs, CdkTabPanel, CdkTabContent], +}) +export class CdkTabsDisabledFocusableExample {} diff --git a/src/components-examples/cdk-experimental/tabs/cdk-disabled-skipped/cdk-tabs-disabled-skipped-example.html b/src/components-examples/cdk-experimental/tabs/cdk-disabled-skipped/cdk-tabs-disabled-skipped-example.html new file mode 100644 index 000000000000..5cdaae50ad84 --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-disabled-skipped/cdk-tabs-disabled-skipped-example.html @@ -0,0 +1,29 @@ +
    +
      +
    • tab 1
    • +
    • tab 2
    • +
    • tab 3
    • +
    • tab 4
    • +
    • tab 5
    • +
    + +
    + Tabpanel 1 +
    + +
    + Tabpanel 2 +
    + +
    + Tabpanel 3 +
    + +
    + Tabpanel 4 +
    + +
    + Tabpanel 5 +
    +
    diff --git a/src/components-examples/cdk-experimental/tabs/cdk-disabled-skipped/cdk-tabs-disabled-skipped-example.ts b/src/components-examples/cdk-experimental/tabs/cdk-disabled-skipped/cdk-tabs-disabled-skipped-example.ts new file mode 100644 index 000000000000..9690f0864bc2 --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-disabled-skipped/cdk-tabs-disabled-skipped-example.ts @@ -0,0 +1,19 @@ +import {Component} from '@angular/core'; +import { + CdkTab, + CdkTabs, + CdkTabList, + CdkTabPanel, + CdkTabContent, +} from '@angular/cdk-experimental/tabs'; + +/** @title Disabled Tabs are Skipped */ +@Component({ + selector: 'cdk-tabs-disabled-skipped-example', + exportAs: 'cdkTabsDisabledSkippedExample', + templateUrl: 'cdk-tabs-disabled-skipped-example.html', + styleUrls: ['../cdk-tabs-common.css'], + standalone: true, + imports: [CdkTabList, CdkTab, CdkTabs, CdkTabPanel, CdkTabContent], +}) +export class CdkTabsDisabledSkippedExample {} diff --git a/src/components-examples/cdk-experimental/tabs/cdk-disabled/cdk-tabs-disabled-example.html b/src/components-examples/cdk-experimental/tabs/cdk-disabled/cdk-tabs-disabled-example.html new file mode 100644 index 000000000000..47cba0cba4f4 --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-disabled/cdk-tabs-disabled-example.html @@ -0,0 +1,29 @@ +
    +
      +
    • tab 1
    • +
    • tab 2
    • +
    • tab 3
    • +
    • tab 4
    • +
    • tab 5
    • +
    + +
    + Tabpanel 1 +
    + +
    + Tabpanel 2 +
    + +
    + Tabpanel 3 +
    + +
    + Tabpanel 4 +
    + +
    + Tabpanel 5 +
    +
    diff --git a/src/components-examples/cdk-experimental/tabs/cdk-disabled/cdk-tabs-disabled-example.ts b/src/components-examples/cdk-experimental/tabs/cdk-disabled/cdk-tabs-disabled-example.ts new file mode 100644 index 000000000000..1b0ddbf6cf5e --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-disabled/cdk-tabs-disabled-example.ts @@ -0,0 +1,19 @@ +import {Component} from '@angular/core'; +import { + CdkTab, + CdkTabs, + CdkTabList, + CdkTabPanel, + CdkTabContent, +} from '@angular/cdk-experimental/tabs'; + +/** @title Disabled */ +@Component({ + selector: 'cdk-tabs-disabled-example', + exportAs: 'cdkTabsDisabledExample', + templateUrl: 'cdk-tabs-disabled-example.html', + styleUrls: ['../cdk-tabs-common.css'], + standalone: true, + imports: [CdkTabList, CdkTab, CdkTabs, CdkTabPanel, CdkTabContent], +}) +export class CdkTabsDisabledExample {} diff --git a/src/components-examples/cdk-experimental/tabs/cdk-explicit-selection/cdk-tabs-explicit-selection-example.html b/src/components-examples/cdk-experimental/tabs/cdk-explicit-selection/cdk-tabs-explicit-selection-example.html new file mode 100644 index 000000000000..ad82d44cf4a0 --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-explicit-selection/cdk-tabs-explicit-selection-example.html @@ -0,0 +1,29 @@ +
    +
      +
    • tab 1
    • +
    • tab 2
    • +
    • tab 3
    • +
    • tab 4
    • +
    • tab 5
    • +
    + +
    + Tabpanel 1 +
    + +
    + Tabpanel 2 +
    + +
    + Tabpanel 3 +
    + +
    + Tabpanel 4 +
    + +
    + Tabpanel 5 +
    +
    diff --git a/src/components-examples/cdk-experimental/tabs/cdk-explicit-selection/cdk-tabs-explicit-selection-example.ts b/src/components-examples/cdk-experimental/tabs/cdk-explicit-selection/cdk-tabs-explicit-selection-example.ts new file mode 100644 index 000000000000..5cbb27c0d31f --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-explicit-selection/cdk-tabs-explicit-selection-example.ts @@ -0,0 +1,19 @@ +import {Component} from '@angular/core'; +import { + CdkTab, + CdkTabs, + CdkTabList, + CdkTabPanel, + CdkTabContent, +} from '@angular/cdk-experimental/tabs'; + +/** @title Explicit selection */ +@Component({ + selector: 'cdk-tabs-explicit-selection-example', + exportAs: 'cdkTabsExplicitSelectionExample', + templateUrl: 'cdk-tabs-explicit-selection-example.html', + styleUrls: ['../cdk-tabs-common.css'], + standalone: true, + imports: [CdkTabList, CdkTab, CdkTabs, CdkTabPanel, CdkTabContent], +}) +export class CdkTabsExplicitSelectionExample {} diff --git a/src/components-examples/cdk-experimental/tabs/cdk-rtl-vertical/cdk-tabs-rtl-vertical-example.html b/src/components-examples/cdk-experimental/tabs/cdk-rtl-vertical/cdk-tabs-rtl-vertical-example.html new file mode 100644 index 000000000000..a409d56e66fa --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-rtl-vertical/cdk-tabs-rtl-vertical-example.html @@ -0,0 +1,29 @@ +
    +
      +
    • tab 1
    • +
    • tab 2
    • +
    • tab 3
    • +
    • tab 4
    • +
    • tab 5
    • +
    + +
    + Tabpanel 1 +
    + +
    + Tabpanel 2 +
    + +
    + Tabpanel 3 +
    + +
    + Tabpanel 4 +
    + +
    + Tabpanel 5 +
    +
    diff --git a/src/components-examples/cdk-experimental/tabs/cdk-rtl-vertical/cdk-tabs-rtl-vertical-example.ts b/src/components-examples/cdk-experimental/tabs/cdk-rtl-vertical/cdk-tabs-rtl-vertical-example.ts new file mode 100644 index 000000000000..19cac48ffc8e --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-rtl-vertical/cdk-tabs-rtl-vertical-example.ts @@ -0,0 +1,21 @@ +import {Component} from '@angular/core'; +import { + CdkTab, + CdkTabs, + CdkTabList, + CdkTabPanel, + CdkTabContent, +} from '@angular/cdk-experimental/tabs'; + +/** * @title RTL & Vertical */ +@Component({ + selector: 'cdk-tabs-rtl-vertical-example', + exportAs: 'cdkTabsRtlVerticalExample', + templateUrl: 'cdk-tabs-rtl-vertical-example.html', + styleUrls: ['../cdk-tabs-common.css'], + standalone: true, + imports: [CdkTabList, CdkTab, CdkTabs, CdkTabPanel, CdkTabContent], +}) +export class CdkTabsRtlVerticalExample { + selectedIndex = 0; +} diff --git a/src/components-examples/cdk-experimental/tabs/cdk-selection-follows-focus/cdk-tabs-selection-follows-focus-example.html b/src/components-examples/cdk-experimental/tabs/cdk-selection-follows-focus/cdk-tabs-selection-follows-focus-example.html new file mode 100644 index 000000000000..50ab7ecb51b6 --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-selection-follows-focus/cdk-tabs-selection-follows-focus-example.html @@ -0,0 +1,29 @@ +
    +
      +
    • tab 1
    • +
    • tab 2
    • +
    • tab 3
    • +
    • tab 4
    • +
    • tab 5
    • +
    + +
    + Tabpanel 1 +
    + +
    + Tabpanel 2 +
    + +
    + Tabpanel 3 +
    + +
    + Tabpanel 4 +
    + +
    + Tabpanel 5 +
    +
    diff --git a/src/components-examples/cdk-experimental/tabs/cdk-selection-follows-focus/cdk-tabs-selection-follows-focus-example.ts b/src/components-examples/cdk-experimental/tabs/cdk-selection-follows-focus/cdk-tabs-selection-follows-focus-example.ts new file mode 100644 index 000000000000..3ecfe8a1cb07 --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-selection-follows-focus/cdk-tabs-selection-follows-focus-example.ts @@ -0,0 +1,19 @@ +import {Component} from '@angular/core'; +import { + CdkTab, + CdkTabs, + CdkTabList, + CdkTabPanel, + CdkTabContent, +} from '@angular/cdk-experimental/tabs'; + +/** @title Selection Follows Focus */ +@Component({ + selector: 'cdk-tabs-selection-follows-focus-example', + exportAs: 'cdkTabsSelectionFollowsFocusExample', + templateUrl: 'cdk-tabs-selection-follows-focus-example.html', + styleUrls: ['../cdk-tabs-common.css'], + standalone: true, + imports: [CdkTabList, CdkTab, CdkTabs, CdkTabPanel, CdkTabContent], +}) +export class CdkTabsSelectionFollowsFocusExample {} diff --git a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.css b/src/components-examples/cdk-experimental/tabs/cdk-tabs-common.css similarity index 92% rename from src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.css rename to src/components-examples/cdk-experimental/tabs/cdk-tabs-common.css index bdd38c5e1c5d..938f2cf71a2e 100644 --- a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.css +++ b/src/components-examples/cdk-experimental/tabs/cdk-tabs-common.css @@ -44,7 +44,6 @@ .example-tablist[aria-disabled='true'] { background-color: var(--mat-sys-surface-dim); - pointer-events: none; } .example-tab { @@ -67,19 +66,14 @@ background: var(--mat-sys-surface-container); } -.example-tab[aria-selected='true'] { +.example-tablist:focus-within .example-tab.cdk-active { background-color: var(--mat-sys-secondary-container); } .example-tab[aria-disabled='true'] { background-color: var(--mat-sys-surface-dim); - pointer-events: none; } .example-tabpanel { margin: 8px; } - -.example-tabpanel[inert] { - display: none; -} diff --git a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.html b/src/components-examples/cdk-experimental/tabs/cdk-tabs-configurable/cdk-tabs-configurable-example.html similarity index 85% rename from src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.html rename to src/components-examples/cdk-experimental/tabs/cdk-tabs-configurable/cdk-tabs-configurable-example.html index 2c598324fb3e..13904f352675 100644 --- a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.html +++ b/src/components-examples/cdk-experimental/tabs/cdk-tabs-configurable/cdk-tabs-configurable-example.html @@ -26,6 +26,15 @@ Active Descendant + + + Tab selection + + Tab 1 + Tab 2 + Tab 3 + +
    @@ -39,6 +48,7 @@ [orientation]="orientation" [focusMode]="focusMode" [selectionMode]="selectionMode" + [(tab)]="tabSelection" >
  • tab 1
  • tab 2
  • diff --git a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.ts b/src/components-examples/cdk-experimental/tabs/cdk-tabs-configurable/cdk-tabs-configurable-example.ts similarity index 75% rename from src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.ts rename to src/components-examples/cdk-experimental/tabs/cdk-tabs-configurable/cdk-tabs-configurable-example.ts index 055820584393..d423d9d16b35 100644 --- a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.ts +++ b/src/components-examples/cdk-experimental/tabs/cdk-tabs-configurable/cdk-tabs-configurable-example.ts @@ -11,12 +11,13 @@ import {MatFormFieldModule} from '@angular/material/form-field'; import {MatSelectModule} from '@angular/material/select'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; -/** @title Tabs using UI Patterns. */ +/** @title Configurable Tabs. */ @Component({ - selector: 'cdk-tabs-example', - exportAs: 'cdkTabsExample', - templateUrl: 'cdk-tabs-example.html', - styleUrl: 'cdk-tabs-example.css', + selector: 'cdk-tabs-configurable-example', + exportAs: 'cdkTabsConfigurableExample', + templateUrl: 'cdk-tabs-configurable-example.html', + styleUrls: ['../cdk-tabs-common.css'], + standalone: true, imports: [ CdkTabs, CdkTabList, @@ -29,10 +30,11 @@ import {FormControl, ReactiveFormsModule} from '@angular/forms'; ReactiveFormsModule, ], }) -export class CdkTabsExample { +export class CdkTabsConfigurableExample { orientation: 'vertical' | 'horizontal' = 'horizontal'; focusMode: 'roving' | 'activedescendant' = 'roving'; selectionMode: 'explicit' | 'follow' = 'follow'; + tabSelection = 'tab-1'; wrap = new FormControl(true, {nonNullable: true}); disabled = new FormControl(false, {nonNullable: true}); diff --git a/src/components-examples/cdk-experimental/tabs/cdk-vertical-orientation/cdk-tabs-vertical-example.html b/src/components-examples/cdk-experimental/tabs/cdk-vertical-orientation/cdk-tabs-vertical-example.html new file mode 100644 index 000000000000..a409d56e66fa --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-vertical-orientation/cdk-tabs-vertical-example.html @@ -0,0 +1,29 @@ +
    +
      +
    • tab 1
    • +
    • tab 2
    • +
    • tab 3
    • +
    • tab 4
    • +
    • tab 5
    • +
    + +
    + Tabpanel 1 +
    + +
    + Tabpanel 2 +
    + +
    + Tabpanel 3 +
    + +
    + Tabpanel 4 +
    + +
    + Tabpanel 5 +
    +
    diff --git a/src/components-examples/cdk-experimental/tabs/cdk-vertical-orientation/cdk-tabs-vertical-example.ts b/src/components-examples/cdk-experimental/tabs/cdk-vertical-orientation/cdk-tabs-vertical-example.ts new file mode 100644 index 000000000000..05d7ab55a440 --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-vertical-orientation/cdk-tabs-vertical-example.ts @@ -0,0 +1,19 @@ +import {Component} from '@angular/core'; +import { + CdkTab, + CdkTabs, + CdkTabList, + CdkTabPanel, + CdkTabContent, +} from '@angular/cdk-experimental/tabs'; + +/** @title Vertical */ +@Component({ + selector: 'cdk-tabs-vertical-example', + exportAs: 'cdkTabsVerticalExample', + templateUrl: 'cdk-tabs-vertical-example.html', + styleUrls: ['../cdk-tabs-common.css'], + standalone: true, + imports: [CdkTabList, CdkTab, CdkTabs, CdkTabPanel, CdkTabContent], +}) +export class CdkTabsVerticalExample {} diff --git a/src/components-examples/cdk-experimental/tabs/index.ts b/src/components-examples/cdk-experimental/tabs/index.ts index 2a00cbe51c55..cbd4adba90e6 100644 --- a/src/components-examples/cdk-experimental/tabs/index.ts +++ b/src/components-examples/cdk-experimental/tabs/index.ts @@ -1 +1,9 @@ -export {CdkTabsExample} from './cdk-tabs/cdk-tabs-example'; +export {CdkTabsConfigurableExample} from './cdk-tabs-configurable/cdk-tabs-configurable-example'; +export {CdkTabsExplicitSelectionExample} from './cdk-explicit-selection/cdk-tabs-explicit-selection-example'; +export {CdkTabsSelectionFollowsFocusExample} from './cdk-selection-follows-focus/cdk-tabs-selection-follows-focus-example'; +export {CdkTabsVerticalExample} from './cdk-vertical-orientation/cdk-tabs-vertical-example'; +export {CdkTabsRtlVerticalExample} from './cdk-rtl-vertical/cdk-tabs-rtl-vertical-example'; +export {CdkTabsActiveDescendantExample} from './cdk-active-descendant/cdk-tabs-active-descendant-example'; +export {CdkTabsDisabledFocusableExample} from './cdk-disabled-focusable/cdk-tabs-disabled-focusable-example'; +export {CdkTabsDisabledSkippedExample} from './cdk-disabled-skipped/cdk-tabs-disabled-skipped-example'; +export {CdkTabsDisabledExample} from './cdk-disabled/cdk-tabs-disabled-example'; diff --git a/src/components-examples/cdk-experimental/tree/BUILD.bazel b/src/components-examples/cdk-experimental/tree/BUILD.bazel new file mode 100644 index 000000000000..2c349fb1e681 --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/BUILD.bazel @@ -0,0 +1,30 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "tree", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//:node_modules/@angular/core", + "//:node_modules/@angular/forms", + "//src/cdk-experimental/tree", + "//src/material/checkbox", + "//src/material/form-field", + "//src/material/icon", + "//src/material/select", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.css b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.css new file mode 100644 index 000000000000..004c5a8a48c6 --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.css @@ -0,0 +1,48 @@ +.example-tree-controls { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.example-tree-output { + padding: 10px; + margin-bottom: 16px; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-tree { + padding: 10px; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-tree-item { + cursor: pointer; + user-select: none; + list-style: none; +} + +.example-tree-item[aria-selected='true'] { + background-color: var(--mat-sys-inverse-primary); +} + +.example-tree-item[aria-selected='false'] { + background-color: var(--mat-sys-background); +} + +.example-tree-item[aria-disabled='true'] { + background-color: var(--mat-sys-surface-container); + color: var(--mat-sys-on-surface-variant); +} + +.example-tree-item-content { + display: flex; + align-items: center; + padding: 2px 0; /* Minimal padding for item itself */ +} + +.example-tree-item-icon { + margin-right: 8px; +} diff --git a/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.html b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.html new file mode 100644 index 000000000000..c2c1652ff335 --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.html @@ -0,0 +1,52 @@ +
    + Wrap + Multi + Disabled + Skip Disabled + + + Orientation + + Vertical + Horizontal + + + + + Selection Strategy + + Explicit + Follow + + + + + Focus Strategy + + Roving + Active Descendant + + +
    + +
    + Selected Values: {{ selectedValues().join(', ') || 'None' }} +
    + +
      + @for (node of treeData; track node) { + + } +
    diff --git a/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.ts b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.ts new file mode 100644 index 000000000000..a56b82ab957c --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/cdk-tree/cdk-tree-example.ts @@ -0,0 +1,143 @@ +import {Component, model, input} from '@angular/core'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {MatIconModule} from '@angular/material/icon'; +import { + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, +} from '@angular/cdk-experimental/tree'; + +interface ExampleNode { + value: string; + label?: string; + disabled?: boolean; + expanded?: boolean; + children?: ExampleNode[]; +} + +@Component({ + selector: 'example-node', + styleUrl: 'cdk-tree-example.css', + template: ` +
  • + + + {{ node().label }} + + + @if (node().children !== undefined && node().children!.length > 0) { +
    + + @for (child of node().children; track child) { + + } + +
    + } +
  • + `, + imports: [MatIconModule, CdkTreeItem, CdkTreeItemGroup, CdkTreeItemGroupContent], +}) +export class ExampleNodeComponent { + node = input.required(); +} + +/** @title Tree using CdkTree and CdkTreeItem. */ +@Component({ + selector: 'cdk-tree-example', + exportAs: 'cdkTreeExample', + templateUrl: 'cdk-tree-example.html', + styleUrl: 'cdk-tree-example.css', + imports: [ + FormsModule, + ReactiveFormsModule, + MatCheckboxModule, + MatFormFieldModule, + MatSelectModule, + MatIconModule, + CdkTree, + ExampleNodeComponent, + ], +}) +export class CdkTreeExample { + // Tree data + treeData: ExampleNode[] = [ + { + value: 'electronics', + label: 'electronics', + children: [ + { + value: 'audio', + label: 'audio equipment', + children: [ + {value: 'headphones', label: 'headphones'}, + {value: 'speakers', label: 'speakers (disabled)', disabled: true}, + {value: 'amps', label: 'amplifiers'}, + ], + }, + { + value: 'computers', + label: 'computers & tablets', + children: [ + {value: 'laptops', label: 'laptops'}, + {value: 'desktops', label: 'desktops'}, + {value: 'tablets', label: 'tablets'}, + ], + }, + {value: 'cameras', label: 'cameras'}, + ], + }, + { + value: 'furniture', + label: 'furniture', + children: [ + {value: 'tables', label: 'tables'}, + {value: 'chairs', label: 'chairs'}, + {value: 'sofas', label: 'sofas'}, + ], + }, + { + value: 'books', + label: 'books (no children)', + }, + { + value: 'clothing', + label: 'clothing (disabled parent)', + disabled: true, + children: [ + {value: 'shirts', label: 'shirts'}, + {value: 'pants', label: 'pants'}, + ], + }, + ]; + + // TODO(ok7sai): add styling to horizontal tree view. + orientation: 'vertical' | 'horizontal' = 'vertical'; + selectionMode: 'explicit' | 'follow' = 'explicit'; + focusMode: 'roving' | 'activedescendant' = 'roving'; + + multi = new FormControl(false, {nonNullable: true}); + disabled = new FormControl(false, {nonNullable: true}); + wrap = new FormControl(true, {nonNullable: true}); + skipDisabled = new FormControl(true, {nonNullable: true}); + + selectedValues = model(['books']); +} diff --git a/src/components-examples/cdk-experimental/tree/index.ts b/src/components-examples/cdk-experimental/tree/index.ts new file mode 100644 index 000000000000..731d29286979 --- /dev/null +++ b/src/components-examples/cdk-experimental/tree/index.ts @@ -0,0 +1 @@ +export {CdkTreeExample} from './cdk-tree/cdk-tree-example'; diff --git a/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.css b/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.css new file mode 100644 index 000000000000..831122b5e934 --- /dev/null +++ b/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.css @@ -0,0 +1,50 @@ +.example-container { + width: 400px; + max-width: 100%; + margin: 0 25px 25px 0; + display: inline-block; + vertical-align: top; +} + +.example-list { + border: solid 1px #ccc; + min-height: 60px; + background: white; + border-radius: 4px; + overflow: hidden; + display: block; +} + +.example-box { + padding: 20px 10px; + border-bottom: solid 1px #ccc; + color: rgba(0, 0, 0, 0.87); + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + cursor: move; + background: white; + font-size: 14px; +} + +.cdk-drag-preview { + box-sizing: border-box; + border-radius: 4px; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); +} + +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +.example-box:last-child { + border: none; +} + +.example-list.cdk-drop-list-dragging .example-box:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} diff --git a/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.html b/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.html new file mode 100644 index 000000000000..c229537adcc8 --- /dev/null +++ b/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.html @@ -0,0 +1,31 @@ +
    +

    Products

    + +
    + @for (product of products; track $index) { +
    {{product}}
    + } +
    +
    + +
    +

    Shopping cart

    + +
    + @for (product of cart; track $index) { +
    {{product}}
    + } +
    +
    + diff --git a/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.ts b/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.ts new file mode 100644 index 000000000000..ab979b7f4bf5 --- /dev/null +++ b/src/components-examples/cdk/drag-drop/cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example.ts @@ -0,0 +1,35 @@ +import {Component} from '@angular/core'; +import { + CdkDragDrop, + moveItemInArray, + copyArrayItem, + CdkDrag, + CdkDropList, +} from '@angular/cdk/drag-drop'; + +/** + * @title Drag&Drop copy between lists + */ +@Component({ + selector: 'cdk-drag-drop-copy-list-example', + templateUrl: 'cdk-drag-drop-copy-list-example.html', + styleUrl: 'cdk-drag-drop-copy-list-example.css', + imports: [CdkDropList, CdkDrag], +}) +export class CdkDragDropCopyListExample { + products = ['Bananas', 'Oranges', 'Bread', 'Butter', 'Soda', 'Eggs']; + cart = ['Tomatoes']; + + drop(event: CdkDragDrop) { + if (event.previousContainer === event.container) { + moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); + } else { + copyArrayItem( + event.previousContainer.data, + event.container.data, + event.previousIndex, + event.currentIndex, + ); + } + } +} diff --git a/src/components-examples/cdk/drag-drop/index.ts b/src/components-examples/cdk/drag-drop/index.ts index 37df41d57c2b..e54c8b6a3f8d 100644 --- a/src/components-examples/cdk/drag-drop/index.ts +++ b/src/components-examples/cdk/drag-drop/index.ts @@ -18,3 +18,4 @@ export {CdkDragDropSortPredicateExample} from './cdk-drag-drop-sort-predicate/cd export {CdkDragDropTableExample} from './cdk-drag-drop-table/cdk-drag-drop-table-example'; export {CdkDragDropMixedSortingExample} from './cdk-drag-drop-mixed-sorting/cdk-drag-drop-mixed-sorting-example'; export {CdkDragDropTabsExample} from './cdk-drag-drop-tabs/cdk-drag-drop-tabs-example'; +export {CdkDragDropCopyListExample} from './cdk-drag-drop-copy-list/cdk-drag-drop-copy-list-example'; diff --git a/src/components-examples/material/card/card-media-size/card-media-size-example.html b/src/components-examples/material/card/card-media-size/card-media-size-example.html index 891f4fde82c5..667f520484d8 100644 --- a/src/components-examples/material/card/card-media-size/card-media-size-example.html +++ b/src/components-examples/material/card/card-media-size/card-media-size-example.html @@ -4,7 +4,7 @@ Shiba Inu Small - + @@ -17,7 +17,7 @@ Shiba Inu Medium - Image of a Shiba Inu + Image of a Shiba Inu @@ -30,7 +30,7 @@ Shiba Inu Large - + @@ -43,7 +43,7 @@ Shiba Inu Extra large - + diff --git a/src/components-examples/material/card/card-overview/card-overview-example.css b/src/components-examples/material/card/card-overview/card-overview-example.css index 943bdd1cb051..9930db5b78b0 100644 --- a/src/components-examples/material/card/card-overview/card-overview-example.css +++ b/src/components-examples/material/card/card-overview/card-overview-example.css @@ -3,6 +3,6 @@ } .example-header-image { - background-image: url('https://material.angular.io/assets/img/examples/shiba1.jpg'); + background-image: url('https://material.angular.dev/assets/img/examples/shiba1.jpg'); background-size: cover; } diff --git a/src/components-examples/material/card/card-overview/card-overview-example.html b/src/components-examples/material/card/card-overview/card-overview-example.html index 4cdd3451a7cc..4617d7408291 100644 --- a/src/components-examples/material/card/card-overview/card-overview-example.html +++ b/src/components-examples/material/card/card-overview/card-overview-example.html @@ -4,7 +4,7 @@ Shiba Inu Dog Breed - Photo of a Shiba Inu + Photo of a Shiba Inu

    The Shiba Inu is the smallest of the six original and distinct spitz breeds of dog from Japan. @@ -16,4 +16,4 @@ - \ No newline at end of file + diff --git a/src/components-examples/material/chips/chips-avatar/chips-avatar-example.html b/src/components-examples/material/chips/chips-avatar/chips-avatar-example.html index 7a56290eaa53..fb3b0f4c7695 100644 --- a/src/components-examples/material/chips/chips-avatar/chips-avatar-example.html +++ b/src/components-examples/material/chips/chips-avatar/chips-avatar-example.html @@ -2,7 +2,7 @@ Photo of a Shiba Inu Dog one @@ -10,7 +10,7 @@ Photo of a Shiba Inu Dog two @@ -18,7 +18,7 @@ Photo of a Shiba Inu Dog three diff --git a/src/components-examples/material/chips/chips-input/chips-input-example.html b/src/components-examples/material/chips/chips-input/chips-input-example.html index 3d29e5442015..b1e75ee2426a 100644 --- a/src/components-examples/material/chips/chips-input/chips-input-example.html +++ b/src/components-examples/material/chips/chips-input/chips-input-example.html @@ -8,6 +8,9 @@ (edited)="edit(fruit, $event)" [aria-description]="'press enter to edit ' + fruit.name" > + {{fruit.name}} + + + + diff --git a/src/components-examples/material/menu/context-menu/context-menu-example.ts b/src/components-examples/material/menu/context-menu/context-menu-example.ts new file mode 100644 index 000000000000..ac59a7633031 --- /dev/null +++ b/src/components-examples/material/menu/context-menu/context-menu-example.ts @@ -0,0 +1,14 @@ +import {Component} from '@angular/core'; +import {MatMenuModule} from '@angular/material/menu'; +import {MatIconModule} from '@angular/material/icon'; + +/** + * @title Context menu + */ +@Component({ + selector: 'context-menu-example', + templateUrl: 'context-menu-example.html', + styleUrl: './context-menu-example.css', + imports: [MatMenuModule, MatIconModule], +}) +export class ContextMenuExample {} diff --git a/src/components-examples/material/menu/index.ts b/src/components-examples/material/menu/index.ts index f44f9d603b43..8f78a3618bc7 100644 --- a/src/components-examples/material/menu/index.ts +++ b/src/components-examples/material/menu/index.ts @@ -3,3 +3,4 @@ export {MenuOverviewExample} from './menu-overview/menu-overview-example'; export {MenuPositionExample} from './menu-position/menu-position-example'; export {MenuNestedExample} from './menu-nested/menu-nested-example'; export {MenuHarnessExample} from './menu-harness/menu-harness-example'; +export {ContextMenuExample} from './context-menu/context-menu-example'; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 8073332cd43f..87dc40f20802 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -1,8 +1,8 @@ -load("//tools:defaults.bzl", "http_server", "ng_project", "sass_binary") -load("@npm//:defs.bzl", "npm_link_all_packages") load("@aspect_rules_esbuild//esbuild:defs.bzl", "esbuild") load("@aspect_rules_js//npm:defs.bzl", "npm_package") +load("@npm//:defs.bzl", "npm_link_all_packages") load("//src/components-examples:config.bzl", "ALL_EXAMPLES") +load("//tools:defaults.bzl", "http_server", "ng_project", "sass_binary") package(default_visibility = ["//visibility:public"]) @@ -33,9 +33,12 @@ ng_project( "//src/dev-app/button-toggle", "//src/dev-app/card", "//src/dev-app/cdk-dialog", + "//src/dev-app/cdk-experimental-accordion", "//src/dev-app/cdk-experimental-combobox", "//src/dev-app/cdk-experimental-listbox", + "//src/dev-app/cdk-experimental-radio-group", "//src/dev-app/cdk-experimental-tabs", + "//src/dev-app/cdk-experimental-tree", "//src/dev-app/cdk-listbox", "//src/dev-app/cdk-menu", "//src/dev-app/checkbox", diff --git a/src/dev-app/cdk-experimental-accordion/BUILD.bazel b/src/dev-app/cdk-experimental-accordion/BUILD.bazel new file mode 100644 index 000000000000..91c35abf9d6b --- /dev/null +++ b/src/dev-app/cdk-experimental-accordion/BUILD.bazel @@ -0,0 +1,13 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "cdk-experimental-accordion", + srcs = glob(["**/*.ts"]), + assets = [ + "cdk-accordion-demo.html", + "cdk-accordion-demo.css", + ], + deps = ["//src/components-examples/cdk-experimental/accordion"], +) diff --git a/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.css b/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.css new file mode 100644 index 000000000000..248988f1da76 --- /dev/null +++ b/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.css @@ -0,0 +1,27 @@ +.example-accordion-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); + gap: 20px; +} + +.example-accordion-container { + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +.example-accordion-container, +.example-configurable-accordion-container { + border: 1px solid #555; + border-radius: 5px; + padding: 20px; +} + +.example-configurable-accordion-container { + margin-top: 40px; +} + +h4 { + height: 36px; + margin-top: 0; +} diff --git a/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.html b/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.html new file mode 100644 index 000000000000..379496af1eb3 --- /dev/null +++ b/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.html @@ -0,0 +1,38 @@ +

    +
    +
    +

    Single expansion - Able to expand and collapse accordion panels.

    + +
    + +
    +

    Multi expansion - Able to expand and collapse multiple accordion panels

    + +
    + +
    +

    + Disabled Accordions are Focusable - Able to navigate to a disabled accordion. Disabled + accordions are not expandable. +

    + +
    + +
    +

    + Disabled Accordions are Skipped - Disabled accordions are skipped over and not expandable. +

    + +
    + +
    +

    Disabled - Focus should land on the accordion group instead of an accordion trigger.

    + +
    +
    + +
    +

    Configurable Accordion

    + +
    +
    diff --git a/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.ts b/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.ts new file mode 100644 index 000000000000..127cfd8f0e0c --- /dev/null +++ b/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; +import { + CdkAccordionConfigurableExample, + CdkAccordionDisabledExample, + CdkAccordionDisabledFocusableExample, + CdkAccordionDisabledSkippedExample, + CdkAccordionMultiExpansionExample, + CdkAccordionSingleExpansionExample, +} from '@angular/components-examples/cdk-experimental/accordion'; + +@Component({ + templateUrl: 'cdk-accordion-demo.html', + styleUrl: 'cdk-accordion-demo.css', + imports: [ + CdkAccordionConfigurableExample, + CdkAccordionSingleExpansionExample, + CdkAccordionMultiExpansionExample, + CdkAccordionDisabledFocusableExample, + CdkAccordionDisabledSkippedExample, + CdkAccordionDisabledExample, + ], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkExperimentalAccordionDemo {} diff --git a/src/dev-app/cdk-experimental-listbox/BUILD.bazel b/src/dev-app/cdk-experimental-listbox/BUILD.bazel index 20e2e352c5ae..7ce8d5e6e985 100644 --- a/src/dev-app/cdk-experimental-listbox/BUILD.bazel +++ b/src/dev-app/cdk-experimental-listbox/BUILD.bazel @@ -5,7 +5,10 @@ package(default_visibility = ["//visibility:public"]) ng_project( name = "cdk-experimental-listbox", srcs = glob(["**/*.ts"]), - assets = ["cdk-listbox-demo.html"], + assets = [ + "cdk-listbox-demo.html", + ":cdk-listbox-demo.css", + ], deps = [ "//:node_modules/@angular/core", "//src/components-examples/cdk-experimental/listbox", diff --git a/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.css b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.css new file mode 100644 index 000000000000..ff545cbc82ea --- /dev/null +++ b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.css @@ -0,0 +1,20 @@ +.example-listbox-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); + gap: 20px; +} + +.example-listbox-container { + width: 500px; + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +.example-configurable-listbox-container { + padding-top: 40px; +} + +h4 { + height: 36px; +} diff --git a/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.html b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.html index 20db798ae682..47d964b3f0cd 100644 --- a/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.html +++ b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.html @@ -1,4 +1,69 @@
    -

    Listbox using UI Patterns

    - -
    \ No newline at end of file +
    +
    +

    Single Select - Able to navigate to and select an option.

    + +
    + +
    +

    + Multi Select - Able to select a range of options using shift + arrow keys, shift + home/end, + and shift + clicking. +

    + +
    + +
    +

    Single Select w/ Selection Follows Focus

    + +
    + +
    +

    + Multi Select w/ Selection Follows Focus - Able to navigate without changing selection by + holding ctrl while navigating. +

    + +
    + +
    +

    Horizontal Orientation - Able to navigate using left and right arrow keys.

    + +
    + +
    +

    RTL & Horizontal Orientation - Able to navigate through the list as expected.

    + +
    + +
    +

    Active Descendant - Able to navigate to and select an option.

    + +
    + +
    +

    Disabled Options are Focusable - Able to navigate to a disabled option. Disabled options are not selectable (including range selection).

    + +
    + +
    +

    Disabled Options are Skipped - Disabled options are skipped over. Disabled options are not selectable (including range selection).

    + +
    + +
    +

    Readonly - The listbox is navigable but selection state cannot be changed.

    + +
    + +
    +

    Disabled - Focus should land on the listbox instead of an option.

    + +
    +
    + +
    +

    Configurable Listbox

    + +
    +
    diff --git a/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.ts b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.ts index 79c86bf15aff..10165aaa31d3 100644 --- a/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.ts +++ b/src/dev-app/cdk-experimental-listbox/cdk-listbox-demo.ts @@ -6,12 +6,40 @@ * found in the LICENSE file at https://angular.dev/license */ -import {ChangeDetectionStrategy, Component} from '@angular/core'; -import {CdkListboxExample} from '@angular/components-examples/cdk-experimental/listbox'; +import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; +import { + CdkListboxConfigurableExample, + CdkListboxSingleSelectExample, + CdkListboxMultiSelectExample, + CdkListboxSingleSelectFollowFocusExample, + CdkListboxMultiSelectFollowFocusExample, + CdkListboxHorizontalExample, + CdkListboxRtlHorizontalExample, + CdkListboxActiveDescendantExample, + CdkListboxDisabledFocusableExample, + CdkListboxDisabledSkippedExample, + CdkListboxReadonlyExample, + CdkListboxDisabledExample, +} from '@angular/components-examples/cdk-experimental/listbox'; @Component({ templateUrl: 'cdk-listbox-demo.html', - imports: [CdkListboxExample], + imports: [ + CdkListboxConfigurableExample, + CdkListboxSingleSelectExample, + CdkListboxMultiSelectExample, + CdkListboxSingleSelectFollowFocusExample, + CdkListboxMultiSelectFollowFocusExample, + CdkListboxHorizontalExample, + CdkListboxRtlHorizontalExample, + CdkListboxActiveDescendantExample, + CdkListboxDisabledFocusableExample, + CdkListboxDisabledSkippedExample, + CdkListboxReadonlyExample, + CdkListboxDisabledExample, + ], + styleUrl: 'cdk-listbox-demo.css', + encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class CdkExperimentalListboxDemo {} diff --git a/src/dev-app/cdk-experimental-radio-group/BUILD.bazel b/src/dev-app/cdk-experimental-radio-group/BUILD.bazel new file mode 100644 index 000000000000..0d17c6d54292 --- /dev/null +++ b/src/dev-app/cdk-experimental-radio-group/BUILD.bazel @@ -0,0 +1,16 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "cdk-experimental-radio-group", + srcs = glob(["**/*.ts"]), + assets = [ + "cdk-radio-group-demo.html", + ":cdk-radio-group-demo.css", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/components-examples/cdk-experimental/radio-group", + ], +) diff --git a/src/dev-app/cdk-experimental-radio-group/cdk-radio-group-demo.css b/src/dev-app/cdk-experimental-radio-group/cdk-radio-group-demo.css new file mode 100644 index 000000000000..20307209d88a --- /dev/null +++ b/src/dev-app/cdk-experimental-radio-group/cdk-radio-group-demo.css @@ -0,0 +1,20 @@ +.example-radio-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); + gap: 20px; +} + +.example-radio-container { + width: 500px; + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +.example-configurable-radio-container { + padding-top: 40px; +} + +h4 { + height: 36px; +} diff --git a/src/dev-app/cdk-experimental-radio-group/cdk-radio-group-demo.html b/src/dev-app/cdk-experimental-radio-group/cdk-radio-group-demo.html new file mode 100644 index 000000000000..661bfa4debcb --- /dev/null +++ b/src/dev-app/cdk-experimental-radio-group/cdk-radio-group-demo.html @@ -0,0 +1,54 @@ +
    +
    +
    +

    Standard - Able to navigate to and select an radio button.

    + +
    + +
    +

    Horizontal Orientation - Able to navigate using left and right arrow keys.

    + +
    + +
    +

    RTL & Horizontal Orientation - Able to navigate as expected.

    + +
    + +
    +

    Active Descendant - Able to navigate to and select a radio button.

    + +
    + +
    +

    + Disabled Options are Focusable - Able to navigate to a disabled radio button. Disabled radio + buttons are not selectable. +

    + +
    + +
    +

    + Disabled Options are Skipped - Disabled radio buttons are skipped over. Disabled radio + buttons are not selectable. +

    + +
    + +
    +

    Readonly - The radio group is navigable but selection state cannot be changed.

    + +
    + +
    +

    Disabled - Focus should land on the listbox instead of an option.

    + +
    +
    + +
    +

    Configurable CDK Radio Group

    + +
    +
    diff --git a/src/dev-app/cdk-experimental-radio-group/cdk-radio-group-demo.ts b/src/dev-app/cdk-experimental-radio-group/cdk-radio-group-demo.ts new file mode 100644 index 000000000000..855e69a41d76 --- /dev/null +++ b/src/dev-app/cdk-experimental-radio-group/cdk-radio-group-demo.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; +import { + CdkRadioGroupConfigurableExample, + CdkRadioGroupStandardExample, + CdkRadioGroupHorizontalExample, + CdkRadioGroupRtlHorizontalExample, + CdkRadioGroupActiveDescendantExample, + CdkRadioGroupDisabledFocusableExample, + CdkRadioGroupDisabledSkippedExample, + CdkRadioGroupReadonlyExample, + CdkRadioGroupDisabledExample, +} from '@angular/components-examples/cdk-experimental/radio-group'; + +@Component({ + templateUrl: 'cdk-radio-group-demo.html', + imports: [ + CdkRadioGroupStandardExample, + CdkRadioGroupHorizontalExample, + CdkRadioGroupRtlHorizontalExample, + CdkRadioGroupActiveDescendantExample, + CdkRadioGroupDisabledFocusableExample, + CdkRadioGroupDisabledSkippedExample, + CdkRadioGroupReadonlyExample, + CdkRadioGroupDisabledExample, + CdkRadioGroupConfigurableExample, + ], + styleUrl: './cdk-radio-group-demo.css', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkExperimentalRadioGroupDemo {} diff --git a/src/dev-app/cdk-experimental-tabs/BUILD.bazel b/src/dev-app/cdk-experimental-tabs/BUILD.bazel index fdb482cdf87e..b8c1ac443d0d 100644 --- a/src/dev-app/cdk-experimental-tabs/BUILD.bazel +++ b/src/dev-app/cdk-experimental-tabs/BUILD.bazel @@ -5,6 +5,9 @@ package(default_visibility = ["//visibility:public"]) ng_project( name = "cdk-experimental-tabs", srcs = glob(["**/*.ts"]), - assets = ["cdk-tabs-demo.html"], + assets = [ + "cdk-tabs-demo.html", + "cdk-tabs-demo.css", + ], deps = ["//src/components-examples/cdk-experimental/tabs"], ) diff --git a/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.css b/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.css new file mode 100644 index 000000000000..200d521a2ed7 --- /dev/null +++ b/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.css @@ -0,0 +1,20 @@ +.example-tabs-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); + gap: 20px; +} + +.example-tabs-container { + width: 500px; + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +.example-configurable-tabs-container { + padding-top: 40px; +} + +h4 { + height: 36px; +} diff --git a/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.html b/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.html index 8f53b8076db1..475787820d83 100644 --- a/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.html +++ b/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.html @@ -1,4 +1,46 @@ -
    -

    Tabs using UI Patterns

    - -
    \ No newline at end of file +
    +
    +

    Explicit Selection - Able to navigate between tabs and select tabs.

    + +
    + +
    +

    Selection Follows Focus - Navigating between tabs also changes the selected tab.

    + +
    + +
    +

    Horizontal Orientation - Able to navigate between tabs.

    + +
    + +
    +

    RTL & Horizontal Orientation - Able to navigate between tabs.

    + +
    + +
    +

    Active Descendant - Able to navigate between tabs.

    + +
    + +
    +

    Disabled Tabs are Focusable - Able to navigate to a disabled tab. Disabled tabs are not selectable.

    + +
    + +
    +

    Disabled Tabs are Skipped - Disabled tabs are skipped over. Disabled tabs are not selectable.

    + +
    + +
    +

    Disabled - Focus should land on the tablist instead of a tab.

    + +
    +
    + +
    +

    Configurable Tabs

    + +
    diff --git a/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.ts b/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.ts index 39de3f744db4..1f82baa58411 100644 --- a/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.ts +++ b/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.ts @@ -7,11 +7,33 @@ */ import {ChangeDetectionStrategy, Component} from '@angular/core'; -import {CdkTabsExample} from '@angular/components-examples/cdk-experimental/tabs'; +import { + CdkTabsConfigurableExample, + CdkTabsExplicitSelectionExample, + CdkTabsSelectionFollowsFocusExample, + CdkTabsVerticalExample, + CdkTabsRtlVerticalExample, + CdkTabsActiveDescendantExample, + CdkTabsDisabledFocusableExample, + CdkTabsDisabledSkippedExample, + CdkTabsDisabledExample, +} from '@angular/components-examples/cdk-experimental/tabs'; @Component({ + selector: 'cdk-experimental-tabs-demo', templateUrl: 'cdk-tabs-demo.html', - imports: [CdkTabsExample], + styleUrls: ['cdk-tabs-demo.css'], + imports: [ + CdkTabsConfigurableExample, + CdkTabsExplicitSelectionExample, + CdkTabsSelectionFollowsFocusExample, + CdkTabsVerticalExample, + CdkTabsRtlVerticalExample, + CdkTabsActiveDescendantExample, + CdkTabsDisabledFocusableExample, + CdkTabsDisabledSkippedExample, + CdkTabsDisabledExample, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class CdkExperimentalTabsDemo {} diff --git a/src/dev-app/cdk-experimental-tree/BUILD.bazel b/src/dev-app/cdk-experimental-tree/BUILD.bazel new file mode 100644 index 000000000000..9091a214d35a --- /dev/null +++ b/src/dev-app/cdk-experimental-tree/BUILD.bazel @@ -0,0 +1,10 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "cdk-experimental-tree", + srcs = glob(["**/*.ts"]), + assets = ["cdk-tree-demo.html"], + deps = ["//src/components-examples/cdk-experimental/tree"], +) diff --git a/src/dev-app/cdk-experimental-tree/cdk-tree-demo.html b/src/dev-app/cdk-experimental-tree/cdk-tree-demo.html new file mode 100644 index 000000000000..76cfa8843aef --- /dev/null +++ b/src/dev-app/cdk-experimental-tree/cdk-tree-demo.html @@ -0,0 +1,4 @@ +
    +

    Tree View using UI Patterns

    + +
    diff --git a/src/dev-app/cdk-experimental-tree/cdk-tree-demo.ts b/src/dev-app/cdk-experimental-tree/cdk-tree-demo.ts new file mode 100644 index 000000000000..c9b973635b0a --- /dev/null +++ b/src/dev-app/cdk-experimental-tree/cdk-tree-demo.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {CdkTreeExample} from '@angular/components-examples/cdk-experimental/tree'; + +@Component({ + templateUrl: 'cdk-tree-demo.html', + imports: [CdkTreeExample], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkExperimentalTreeDemo {} diff --git a/src/dev-app/chips/chips-demo.html b/src/dev-app/chips/chips-demo.html index 4b4b169ba162..9aecfb6ba882 100644 --- a/src/dev-app/chips/chips-demo.html +++ b/src/dev-app/chips/chips-demo.html @@ -68,12 +68,12 @@

    With avatar, icons, and color

    - + Mal - + Husi + } + @if (peopleWithAvatar && person.avatar) { + {{person.avatar}} + } {{person.name}}
    -
    This div is for testing scrolled menus.
    +
    + Right click here to trigger a context menu +
    + + + + + + + + +
    + +
    diff --git a/src/dev-app/menu/menu-demo.scss b/src/dev-app/menu/menu-demo.scss index a3559edd5d40..3c0359b3be73 100644 --- a/src/dev-app/menu/menu-demo.scss +++ b/src/dev-app/menu/menu-demo.scss @@ -11,3 +11,13 @@ justify-content: flex-end; } } + +.demo-context-menu-area { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 500px; + max-width: 500px; + outline: dashed 1px; +} diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index bc01db461312..51026dea8205 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -50,11 +50,30 @@ export const DEV_APP_ROUTES: Routes = [ loadComponent: () => import('./cdk-experimental-listbox/cdk-listbox-demo').then(m => m.CdkExperimentalListboxDemo), }, + { + path: 'cdk-experimental-radio-group', + loadComponent: () => + import('./cdk-experimental-radio-group/cdk-radio-group-demo').then( + m => m.CdkExperimentalRadioGroupDemo, + ), + }, { path: 'cdk-experimental-tabs', loadComponent: () => import('./cdk-experimental-tabs/cdk-tabs-demo').then(m => m.CdkExperimentalTabsDemo), }, + { + path: 'cdk-experimental-accordion', + loadComponent: () => + import('./cdk-experimental-accordion/cdk-accordion-demo').then( + m => m.CdkExperimentalAccordionDemo, + ), + }, + { + path: 'cdk-experimental-tree', + loadComponent: () => + import('./cdk-experimental-tree/cdk-tree-demo').then(m => m.CdkExperimentalTreeDemo), + }, { path: 'cdk-dialog', loadComponent: () => import('./cdk-dialog/dialog-demo').then(m => m.DialogDemo), diff --git a/src/dev-app/theme/theme-demo.scss b/src/dev-app/theme/theme-demo.scss index c28fafaf3317..e01e12f73000 100644 --- a/src/dev-app/theme/theme-demo.scss +++ b/src/dev-app/theme/theme-demo.scss @@ -93,11 +93,11 @@ mat-expansion-panel { margin-top: 24px; overflow: visible; @include mat.expansion-overrides(( - 'container-text-font': var(--mat-sys-body-medium-font), - 'container-text-size': var(--mat-sys-body-medium-size), - 'container-text-weight': var(--mat-sys-body-medium-weight), - 'container-text-line-height': var(--mat-sys-body-medium-line-height), - 'container-text-tracking': var(--mat-sys-body-medium-tracking), + container-text-font: var(--mat-sys-body-medium-font), + container-text-size: var(--mat-sys-body-medium-size), + container-text-weight: var(--mat-sys-body-medium-weight), + container-text-line-height: var(--mat-sys-body-medium-line-height), + container-text-tracking: var(--mat-sys-body-medium-tracking), )); } diff --git a/src/e2e-app/BUILD.bazel b/src/e2e-app/BUILD.bazel index 659b2c473d52..9d6e7e992781 100644 --- a/src/e2e-app/BUILD.bazel +++ b/src/e2e-app/BUILD.bazel @@ -34,7 +34,6 @@ ng_project( exclude = ["index.html"], ), deps = [ - "//:node_modules/@angular/animations", "//:node_modules/@angular/core", "//:node_modules/@angular/forms", "//:node_modules/@angular/platform-browser", diff --git a/src/google-maps/README.md b/src/google-maps/README.md index e43b0cdb4df3..5b4488a6b575 100644 --- a/src/google-maps/README.md +++ b/src/google-maps/README.md @@ -86,3 +86,14 @@ zoom = 4; Not every option has its own input. See the API for each component to see if the option has a dedicated input or if it should be set in the options input. + +## Terms of Service + +This library uses Google Maps Platform services. Use of Google Maps Platform services through this library is subject to the Google Maps Platform [Terms of Service]. + +This library is not a Google Maps Platform Core Service. Therefore, the Google Maps Platform Terms of Service (e.g. Technical Support Services, Service Level Agreements, and Deprecation Policy) do not apply to the code in this library. + +### European Economic Area (EEA) developers + +If your billing address is in the European Economic Area, effective on 8 July 2025, the [Google Maps Platform EEA Terms of Service](https://cloud.google.com/terms/maps-platform/eea) will apply to your use of the Services. Functionality varies by region. [Learn more](https://developers.google.com/maps/comms/eea/faq). + diff --git a/src/google-maps/map-marker-clusterer/map-marker-clusterer-types.ts b/src/google-maps/map-marker-clusterer/map-marker-clusterer-types.ts index c0dae7a90648..0401167bb7be 100644 --- a/src/google-maps/map-marker-clusterer/map-marker-clusterer-types.ts +++ b/src/google-maps/map-marker-clusterer/map-marker-clusterer-types.ts @@ -68,16 +68,16 @@ export type onClusterClickHandler = ( export interface MarkerClustererOptions { markers?: Marker[]; /** - * An algorithm to cluster markers. Default is {@link SuperClusterAlgorithm}. Must - * provide a `calculate` method accepting {@link AlgorithmInput} and returning - * an array of {@link Cluster}. + * An algorithm to cluster markers. Default is `SuperClusterAlgorithm`. Must + * provide a `calculate` method accepting `AlgorithmInput` and returning + * an array of `Cluster`. */ algorithm?: Algorithm; algorithmOptions?: AlgorithmOptions; map?: google.maps.Map | null; /** - * An object that converts a {@link Cluster} into a `google.maps.Marker`. - * Default is {@link DefaultRenderer}. + * An object that converts a `Cluster` into a `google.maps.Marker`. + * Default is `DefaultRenderer`. */ renderer?: Renderer; onClusterClick?: onClusterClickHandler; @@ -93,7 +93,7 @@ export declare const defaultOnClusterClickHandler: onClusterClickHandler; export interface Renderer { /** - * Turn a {@link Cluster} into a `Marker`. + * Turn a `Cluster` into a `Marker`. * * Below is a simple example to create a marker with the number of markers in the cluster as a label. * @@ -125,7 +125,7 @@ export interface ClusterStats { export interface Algorithm { /** - * Calculates an array of {@link Cluster}. + * Calculates an array of `Cluster`. */ calculate: ({markers, map}: AlgorithmInput) => AlgorithmOutput; } @@ -156,7 +156,7 @@ export interface AlgorithmInput { export interface AlgorithmOutput { /** - * The clusters returned based upon the {@link AlgorithmInput}. + * The clusters returned based upon the `AlgorithmInput`. */ clusters: Cluster[]; /** diff --git a/src/google-maps/schematics/ng-update/index.ts b/src/google-maps/schematics/ng-update/index.ts index e195902f23b5..ca337a1bd02b 100644 --- a/src/google-maps/schematics/ng-update/index.ts +++ b/src/google-maps/schematics/ng-update/index.ts @@ -6,5 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ +import {Rule} from '@angular-devkit/schematics'; + /** Entry point for the migration schematics with target of Angular Material v20 */ -export function updateToV20(): void {} +export function updateToV20(): Rule { + return () => {}; +} diff --git a/src/material-experimental/column-resize/_column-resize-theme.scss b/src/material-experimental/column-resize/_column-resize-theme.scss index a9715bb33772..0eb01e0522b9 100644 --- a/src/material-experimental/column-resize/_column-resize-theme.scss +++ b/src/material-experimental/column-resize/_column-resize-theme.scss @@ -1,7 +1,7 @@ @use '@angular/material' as mat; +@use 'sass:map'; @mixin color($theme) { - $non-resizable-hover-divider: mat.get-theme-color($theme, foreground, divider); $resizable-hover-divider: mat.get-theme-color($theme, primary, 600); $resizable-active-divider: mat.get-theme-color($theme, primary, 600); @@ -65,12 +65,16 @@ .mat-header-cell:not(.mat-resizable)::after, .mat-mdc-header-cell:not(.mat-resizable)::after { - background: $non-resizable-hover-divider; + background: map.get($theme, _mat-system, outline); } .mat-resizable-handle { background: $resizable-hover-divider; } + + .cdk-resizable-resize-disabled > .mat-resizable-handle { + background: map.get($theme, _mat-system, outline); + } } [dir='rtl'] { @@ -137,15 +141,13 @@ @mixin density($theme) {} @mixin theme($theme) { - @include mat.private-check-duplicate-theme-styles($theme, 'mat-column-resize') { - @if mat.theme-has($theme, color) { - @include color($theme); - } - @if mat.theme-has($theme, density) { - @include density($theme); - } - @if mat.theme-has($theme, typography) { - @include typography($theme); - } + @if mat.theme-has($theme, color) { + @include color($theme); + } + @if mat.theme-has($theme, density) { + @include density($theme); + } + @if mat.theme-has($theme, typography) { + @include typography($theme); } } diff --git a/src/material-experimental/column-resize/column-resize.spec.ts b/src/material-experimental/column-resize/column-resize.spec.ts index 47ff9f13aa71..3ab5a3f70b12 100644 --- a/src/material-experimental/column-resize/column-resize.spec.ts +++ b/src/material-experimental/column-resize/column-resize.spec.ts @@ -274,7 +274,10 @@ abstract class BaseTestComponentRtl extends BaseTestComponent { } } -@Component({template: getTableTemplate(false), standalone: false}) +@Component({ + template: getTableTemplate(false), + imports: [BidiModule, MatTableModule, MatColumnResizeModule], +}) class MatResizeTest extends BaseTestComponent { @ViewChild(MatColumnResize) columnResize: AbstractMatColumnResize; } @@ -282,32 +285,47 @@ class MatResizeTest extends BaseTestComponent { @Component({ template: getTableTemplate(false), changeDetection: ChangeDetectionStrategy.OnPush, - standalone: false, + imports: [BidiModule, MatTableModule, MatColumnResizeModule], }) class MatResizeOnPushTest extends MatResizeTest {} -@Component({template: getTableTemplate(true), standalone: false}) +@Component({ + template: getTableTemplate(true), + imports: [BidiModule, MatTableModule, MatDefaultEnabledColumnResizeModule], +}) class MatResizeDefaultTest extends BaseTestComponent { @ViewChild(MatDefaultEnabledColumnResize) columnResize: AbstractMatColumnResize; } -@Component({template: getTableTemplate(true), standalone: false}) +@Component({ + template: getTableTemplate(true), + imports: [BidiModule, MatTableModule, MatDefaultEnabledColumnResizeModule], +}) class MatResizeDefaultRtlTest extends BaseTestComponentRtl { @ViewChild(MatDefaultEnabledColumnResize) columnResize: AbstractMatColumnResize; } -@Component({template: getFlexTemplate(false), standalone: false}) +@Component({ + template: getFlexTemplate(false), + imports: [BidiModule, MatTableModule, MatColumnResizeModule], +}) class MatResizeFlexTest extends BaseTestComponent { @ViewChild(MatColumnResizeFlex) columnResize: AbstractMatColumnResize; } -@Component({template: getFlexTemplate(true), standalone: false}) +@Component({ + template: getFlexTemplate(true), + imports: [BidiModule, MatTableModule, MatDefaultEnabledColumnResizeModule], +}) class MatResizeDefaultFlexTest extends BaseTestComponent { @ViewChild(MatDefaultEnabledColumnResizeFlex) columnResize: AbstractMatColumnResize; } -@Component({template: getFlexTemplate(true), standalone: false}) +@Component({ + template: getFlexTemplate(true), + imports: [BidiModule, MatTableModule, MatDefaultEnabledColumnResizeModule], +}) class MatResizeDefaultFlexRtlTest extends BaseTestComponentRtl { @ViewChild(MatDefaultEnabledColumnResizeFlex) columnResize: AbstractMatColumnResize; @@ -357,44 +375,23 @@ declare global { } const testCases = [ - [MatColumnResizeModule, MatResizeTest, 'opt-in table-based mat-table'], - [MatColumnResizeModule, MatResizeOnPushTest, 'inside OnPush component'], - [MatColumnResizeModule, MatResizeFlexTest, 'opt-in flex-based mat-table'], - [ - MatDefaultEnabledColumnResizeModule, - MatResizeDefaultTest, - 'default enabled table-based mat-table', - ], - [ - MatDefaultEnabledColumnResizeModule, - MatResizeDefaultRtlTest, - 'default enabled rtl table-based mat-table', - ], - [ - MatDefaultEnabledColumnResizeModule, - MatResizeDefaultFlexTest, - 'default enabled flex-based mat-table', - ], - [ - MatDefaultEnabledColumnResizeModule, - MatResizeDefaultFlexRtlTest, - 'default enabled rtl flex-based mat-table', - ], + [MatResizeTest, 'opt-in table-based mat-table'], + [MatResizeOnPushTest, 'inside OnPush component'], + [MatResizeFlexTest, 'opt-in flex-based mat-table'], + [MatResizeDefaultTest, 'default enabled table-based mat-table'], + [MatResizeDefaultRtlTest, 'default enabled rtl table-based mat-table'], + [MatResizeDefaultFlexTest, 'default enabled flex-based mat-table'], + [MatResizeDefaultFlexRtlTest, 'default enabled rtl flex-based mat-table'], ] as const; describe('Material Popover Edit', () => { - for (const [resizeModule, componentClass, label] of testCases) { + for (const [componentClass, label] of testCases) { describe(label, () => { let component: BaseTestComponent; let fixture: ComponentFixture; beforeEach(fakeAsync(() => { jasmine.addMatchers(approximateMatcher); - - TestBed.configureTestingModule({ - imports: [BidiModule, MatTableModule, resizeModule], - declarations: [componentClass], - }); fixture = TestBed.createComponent(componentClass); component = fixture.componentInstance; fixture.detectChanges(); @@ -645,12 +642,10 @@ describe('Material Popover Edit', () => { jasmine.addMatchers(approximateMatcher); TestBed.configureTestingModule({ - imports: [BidiModule, MatTableModule, MatColumnResizeModule], providers: [ FakeColumnSizeStore, {provide: ColumnSizeStore, useExisting: FakeColumnSizeStore}, ], - declarations: [MatResizeOnPushTest], }); fixture = TestBed.createComponent(MatResizeOnPushTest); component = fixture.componentInstance; diff --git a/src/material-experimental/popover-edit/_popover-edit-theme.scss b/src/material-experimental/popover-edit/_popover-edit-theme.scss index f5478b376faa..214c81d7e7a3 100644 --- a/src/material-experimental/popover-edit/_popover-edit-theme.scss +++ b/src/material-experimental/popover-edit/_popover-edit-theme.scss @@ -1,12 +1,13 @@ @use '@angular/cdk'; @use '@angular/material' as mat; +@use 'sass:map'; @function _hover-content-background($direction, $background-color) { @return linear-gradient($direction, rgba($background-color, 0), $background-color 8px); } @mixin color($theme) { - $background-color: mat.get-theme-color($theme, background, 'card'); + $background-color: map.get($theme, _mat-system, surface); // TODO: these structural styles don't belong in the `color` part of a theme. // We should figure out a better place for them. @@ -153,15 +154,13 @@ @mixin density($theme) {} @mixin theme($theme) { - @include mat.private-check-duplicate-theme-styles($theme, 'mat-popover-edit') { - @if mat.theme-has($theme, color) { - @include color($theme); - } - @if mat.theme-has($theme, density) { - @include density($theme); - } - @if mat.theme-has($theme, typography) { - @include typography($theme); - } + @if mat.theme-has($theme, color) { + @include color($theme); + } + @if mat.theme-has($theme, density) { + @include density($theme); + } + @if mat.theme-has($theme, typography) { + @include typography($theme); } } diff --git a/src/material-experimental/popover-edit/popover-edit.spec.ts b/src/material-experimental/popover-edit/popover-edit.spec.ts index 52ee1c7cf75b..6775393c0746 100644 --- a/src/material-experimental/popover-edit/popover-edit.spec.ts +++ b/src/material-experimental/popover-edit/popover-edit.spec.ts @@ -229,7 +229,7 @@ class ElementDataSource extends DataSource { margin: 16px; } `, - standalone: false, + imports: [MatTableModule, MatPopoverEditModule, FormsModule], }) class MatFlexTableInCell extends BaseTestComponent { displayedColumns = ['before', 'name', 'weight']; @@ -281,7 +281,7 @@ class MatFlexTableInCell extends BaseTestComponent { margin: 16px; } `, - standalone: false, + imports: [MatTableModule, MatPopoverEditModule, FormsModule], }) class MatTableInCell extends BaseTestComponent { displayedColumns = ['before', 'name', 'weight']; @@ -300,10 +300,6 @@ describe('Material Popover Edit', () => { let fixture: ComponentFixture; beforeEach(fakeAsync(() => { - TestBed.configureTestingModule({ - imports: [MatTableModule, MatPopoverEditModule, FormsModule], - declarations: [componentClass], - }); fixture = TestBed.createComponent(componentClass); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/src/material-experimental/selection/_selection.scss b/src/material-experimental/selection/_selection.scss index 05457c06bcd2..379092562f25 100644 --- a/src/material-experimental/selection/_selection.scss +++ b/src/material-experimental/selection/_selection.scss @@ -1,7 +1,4 @@ -@use '@angular/material' as mat; - @mixin theme($theme) { - @include mat.private-check-duplicate-theme-styles($theme, 'mat-selection'); } @mixin typography($theme) {} diff --git a/src/material/_index.scss b/src/material/_index.scss index 994fa88cf21d..93e89c94af7d 100644 --- a/src/material/_index.scss +++ b/src/material/_index.scss @@ -17,15 +17,13 @@ @forward './core/theming/theming' as private-* show private-clamp-density; @forward './core/typography/typography' show typography-hierarchy; @forward './core/typography/typography-utils' show font-shorthand; -@forward 'core/tokens/m2-tokens' show m2-tokens-from-theme; @forward './core/tokens/m3-system' show system-level-colors, system-level-typography, system-level-elevation, system-level-shape, system-level-motion, system-level-state, theme, theme-overrides; // Private/Internal @forward './core/density/private/all-density' show all-component-densities; -@forward './core/theming/theming' show private-check-duplicate-theme-styles, - private-legacy-get-theme, private-is-theme-object; +@forward './core/theming/theming' show private-legacy-get-theme, private-is-theme-object; @forward './core/style/private' show private-theme-elevation; @forward './core/style/vendor-prefixes' as private-* show private-user-select; @forward './core/style/variables' as private-* show $private-swift-ease-in-duration, diff --git a/src/material/autocomplete/BUILD.bazel b/src/material/autocomplete/BUILD.bazel index daae0aa320bf..7a0e06085b2b 100644 --- a/src/material/autocomplete/BUILD.bazel +++ b/src/material/autocomplete/BUILD.bazel @@ -56,6 +56,7 @@ sass_binary( src = "autocomplete.scss", deps = [ ":m2", + ":m3", "//src/cdk:sass_lib", "//src/material/core/tokens:token_utils", ], diff --git a/src/material/autocomplete/README.md b/src/material/autocomplete/README.md index 73bddeb68ee6..c02e254b2a0d 100644 --- a/src/material/autocomplete/README.md +++ b/src/material/autocomplete/README.md @@ -1 +1 @@ -See documentation on [material.angular.io](https://material.angular.io/). \ No newline at end of file +See documentation on [material.angular.dev](https://material.angular.dev/). diff --git a/src/material/autocomplete/_autocomplete-theme.scss b/src/material/autocomplete/_autocomplete-theme.scss index bc05192ced75..1eeda823fb32 100644 --- a/src/material/autocomplete/_autocomplete-theme.scss +++ b/src/material/autocomplete/_autocomplete-theme.scss @@ -1,105 +1,76 @@ @use 'sass:map'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; @use '../core/typography/typography'; -@use '../core/style/sass-utils'; @use '../core/tokens/token-utils'; @use 'm2-autocomplete'; +@use 'm3-autocomplete'; @mixin base($theme) { + $tokens: map.get(m2-autocomplete.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-autocomplete.$prefix, - m2-autocomplete.get-unthemable-tokens() - ); - } + $tokens: map.get(m3-autocomplete.get-tokens($theme), base); } + + @include token-utils.values($tokens); } @mixin color($theme) { + $tokens: map.get(m2-autocomplete.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-autocomplete.$prefix, - m2-autocomplete.get-color-tokens($theme) - ); - } + $tokens: map.get(m3-autocomplete.get-tokens($theme), color); } + + @include token-utils.values($tokens); } @mixin typography($theme) { + $tokens: map.get(m2-autocomplete.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-autocomplete.$prefix, - m2-autocomplete.get-typography-tokens($theme) - ); - } + $tokens: map.get(m3-autocomplete.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } @mixin density($theme) { + $tokens: map.get(m2-autocomplete.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - // There are no M3 density tokens for this component - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-autocomplete.$prefix, - m2-autocomplete.get-density-tokens($theme) - ); - } + $tokens: map.get(m3-autocomplete.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-autocomplete.$prefix, - tokens: m2-autocomplete.get-token-slots(), + namespace: autocomplete, + tokens: token-utils.get-overrides(m3-autocomplete.get-tokens(), autocomplete), ), ); } @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } @mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-autocomplete') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - @include token-utils.create-token-values( - m2-autocomplete.$prefix, - map.get($tokens, m2-autocomplete.$prefix) - ); } } diff --git a/src/material/autocomplete/_m2-autocomplete.scss b/src/material/autocomplete/_m2-autocomplete.scss index 6936ef19371b..bc5264890e0a 100644 --- a/src/material/autocomplete/_m2-autocomplete.scss +++ b/src/material/autocomplete/_m2-autocomplete.scss @@ -1,44 +1,19 @@ -@use '../core/tokens/m2-utils'; -@use '../core/theming/inspection'; @use '../core/style/elevation'; -@use '../core/style/sass-utils'; +@use '../core/tokens/m2-utils'; +@use 'sass:map'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, autocomplete); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { @return ( - container-shape: 4px, - container-elevation-shadow: elevation.get-box-shadow(8), - ); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - @return ( - background-color: inspection.get-theme-color($theme, background, card) - ); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return (); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + base: ( + autocomplete-container-shape: 4px, + autocomplete-container-elevation-shadow: elevation.get-box-shadow(8), + ), + color: ( + autocomplete-background-color: map.get($system, surface) + ), + typography: (), + density: (), ); } diff --git a/src/material/autocomplete/_m3-autocomplete.scss b/src/material/autocomplete/_m3-autocomplete.scss index 14adce4e997d..4a5272bbf3f8 100644 --- a/src/material/autocomplete/_m3-autocomplete.scss +++ b/src/material/autocomplete/_m3-autocomplete.scss @@ -1,21 +1,26 @@ @use 'sass:map'; @use '../core/style/elevation'; @use '../core/tokens/m3-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, autocomplete); +@use '../core/tokens/m3'; /// Generates custom tokens for the mat-autocomplete. /// @param {Map} $systems The MDC system tokens /// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values /// @param {Map} $token-slots Possible token slots /// @return {Map} A set of custom tokens for the mat-autocomplete -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); $tokens: ( - background-color: map.get($systems, md-sys-color, surface-container), - container-shape: map.get($systems, md-sys-shape, corner-extra-small), - container-elevation-shadow: - m3-utils.hardcode(elevation.get-box-shadow(2), $exclude-hardcoded), + base: ( + autocomplete-container-shape: map.get($system, corner-extra-small), + autocomplete-container-elevation-shadow: elevation.get-box-shadow(2), + ), + color: ( + autocomplete-background-color: map.get($system, surface-container), + ), + typography: (), + density: (), ); - @return m3-utils.namespace($prefix, $tokens, $token-slots); + + @return $tokens; } diff --git a/src/material/autocomplete/autocomplete.scss b/src/material/autocomplete/autocomplete.scss index df5dc9bafe9d..b3d3be79f849 100644 --- a/src/material/autocomplete/autocomplete.scss +++ b/src/material/autocomplete/autocomplete.scss @@ -1,9 +1,8 @@ @use '@angular/cdk'; -@use './m2-autocomplete'; +@use './m3-autocomplete'; @use '../core/tokens/token-utils'; -$token-prefix: m2-autocomplete.$prefix; -$token-slots: m2-autocomplete.get-token-slots(); +$fallbacks: m3-autocomplete.get-tokens(); // Even though we don't use the MDC styles, we need to keep the classes in the // DOM for the Gmat versions to work. We need to bump up the specificity here @@ -21,11 +20,9 @@ div.mat-mdc-autocomplete-panel { // panel which is required for scroll calculations (see #30974). position: relative; - @include token-utils.use-tokens($token-prefix, $token-slots) { - border-radius: token-utils.slot(container-shape); - box-shadow: token-utils.slot(container-elevation-shadow); - background-color: token-utils.slot(background-color); - } + border-radius: token-utils.slot(autocomplete-container-shape, $fallbacks); + box-shadow: token-utils.slot(autocomplete-container-elevation-shadow, $fallbacks); + background-color: token-utils.slot(autocomplete-background-color, $fallbacks); @include cdk.high-contrast { outline: solid 1px; diff --git a/src/material/autocomplete/autocomplete.spec.ts b/src/material/autocomplete/autocomplete.spec.ts index 2370dcd377a8..5284c4ff922d 100644 --- a/src/material/autocomplete/autocomplete.spec.ts +++ b/src/material/autocomplete/autocomplete.spec.ts @@ -1,6 +1,6 @@ -import {Directionality} from '@angular/cdk/bidi'; +import {Direction} from '@angular/cdk/bidi'; import {DOWN_ARROW, ENTER, ESCAPE, SPACE, TAB, UP_ARROW} from '@angular/cdk/keycodes'; -import {createCloseScrollStrategy, OverlayContainer, OverlayModule} from '@angular/cdk/overlay'; +import {OverlayContainer, OverlayModule, createCloseScrollStrategy} from '@angular/cdk/overlay'; import {_supportsShadowDom} from '@angular/cdk/platform'; import {ScrollDispatcher} from '@angular/cdk/scrolling'; import { @@ -10,6 +10,7 @@ import { dispatchFakeEvent, dispatchKeyboardEvent, dispatchMouseEvent, + provideFakeDirectionality, typeInElement, } from '@angular/cdk/testing/private'; import { @@ -25,6 +26,7 @@ import { ViewChild, ViewChildren, ViewEncapsulation, + signal, } from '@angular/core'; import { ComponentFixture, @@ -35,18 +37,18 @@ import { waitForAsync, } from '@angular/core/testing'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {AsyncPipe} from '@angular/common'; import {By} from '@angular/platform-browser'; -import {EMPTY, Observable, Subject, Subscription} from 'rxjs'; +import {Observable, Subject, Subscription} from 'rxjs'; import {map, startWith} from 'rxjs/operators'; -import {MATERIAL_ANIMATIONS, MatOption, MatOptionSelectionChange} from '../core'; -import {MatFormField, MatFormFieldModule} from '../form-field'; +import {MATERIAL_ANIMATIONS, MatOptgroup, MatOption, MatOptionSelectionChange} from '../core'; +import {MatFormField} from '../form-field'; import {MatInputModule} from '../input'; import { MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, MAT_AUTOCOMPLETE_SCROLL_STRATEGY, MatAutocomplete, MatAutocompleteDefaultOptions, - MatAutocompleteModule, MatAutocompleteOrigin, MatAutocompleteSelectedEvent, MatAutocompleteTrigger, @@ -59,23 +61,13 @@ describe('MatAutocomplete', () => { // Creates a test component fixture. function createComponent(component: Type, providers: Provider[] = []) { TestBed.configureTestingModule({ - imports: [ - MatAutocompleteModule, - MatFormFieldModule, - MatInputModule, - FormsModule, - ReactiveFormsModule, - OverlayModule, - ], providers: [ ...providers, {provide: MATERIAL_ANIMATIONS, useValue: {animationsDisabled: true}}, ], - declarations: [component], }); overlayContainerElement = TestBed.inject(OverlayContainer).getContainerElement(); - return TestBed.createComponent(component); } @@ -634,9 +626,7 @@ describe('MatAutocomplete', () => { })); it('should have the correct text direction in RTL', () => { - const rtlFixture = createComponent(SimpleAutocomplete, [ - {provide: Directionality, useFactory: () => ({value: 'rtl', change: EMPTY})}, - ]); + const rtlFixture = createComponent(SimpleAutocomplete, [provideFakeDirectionality('rtl')]); rtlFixture.detectChanges(); rtlFixture.componentInstance.trigger.openPanel(); @@ -649,10 +639,8 @@ describe('MatAutocomplete', () => { }); it('should update the panel direction if it changes for the trigger', () => { - const dirProvider = {value: 'rtl', change: EMPTY}; - const rtlFixture = createComponent(SimpleAutocomplete, [ - {provide: Directionality, useFactory: () => dirProvider}, - ]); + const dir = signal('rtl'); + const rtlFixture = createComponent(SimpleAutocomplete, [provideFakeDirectionality(dir)]); rtlFixture.detectChanges(); rtlFixture.componentInstance.trigger.openPanel(); @@ -666,7 +654,7 @@ describe('MatAutocomplete', () => { rtlFixture.componentInstance.trigger.closePanel(); rtlFixture.detectChanges(); - dirProvider.value = 'ltr'; + dir.set('ltr'); rtlFixture.componentInstance.trigger.openPanel(); rtlFixture.detectChanges(); @@ -4024,7 +4012,16 @@ const SIMPLE_AUTOCOMPLETE_TEMPLATE = ` `; -@Component({template: SIMPLE_AUTOCOMPLETE_TEMPLATE, standalone: false}) +@Component({ + template: SIMPLE_AUTOCOMPLETE_TEMPLATE, + imports: [ + MatAutocomplete, + MatAutocompleteTrigger, + MatOption, + MatInputModule, + ReactiveFormsModule, + ], +}) class SimpleAutocomplete implements OnDestroy { stateCtrl = new FormControl<{name: string; code: string} | string | null>(null); filteredStates: any[]; @@ -4083,27 +4080,40 @@ class SimpleAutocomplete implements OnDestroy { @Component({ template: SIMPLE_AUTOCOMPLETE_TEMPLATE, encapsulation: ViewEncapsulation.ShadowDom, - standalone: false, + imports: [ + MatAutocomplete, + MatAutocompleteTrigger, + MatOption, + MatInputModule, + ReactiveFormsModule, + ], }) class SimpleAutocompleteShadowDom extends SimpleAutocomplete {} @Component({ template: ` @if (isVisible) { - - - -} + + + + } @for (option of filteredOptions | async; track option) { - - {{option}} - -} + + {{option}} + + } `, - standalone: false, + imports: [ + MatAutocomplete, + MatAutocompleteTrigger, + MatOption, + MatInputModule, + ReactiveFormsModule, + AsyncPipe, + ], }) class NgIfAutocomplete { optionCtrl = new FormControl(''); @@ -4141,7 +4151,7 @@ class NgIfAutocomplete { } `, - standalone: false, + imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule], }) class AutocompleteWithoutForms { filteredStates: any[]; @@ -4171,7 +4181,7 @@ class AutocompleteWithoutForms { } `, - standalone: false, + imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule, FormsModule], }) class AutocompleteWithNgModel { filteredStates: any[]; @@ -4203,7 +4213,7 @@ class AutocompleteWithNgModel { } `, - standalone: false, + imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule, FormsModule], }) class AutocompleteWithNumbers { selectedNumber: number; @@ -4223,7 +4233,13 @@ class AutocompleteWithNumbers { } `, - standalone: false, + imports: [ + MatAutocomplete, + MatAutocompleteTrigger, + MatOption, + MatInputModule, + ReactiveFormsModule, + ], }) class AutocompleteWithOnPushDelay implements OnInit { @ViewChild(MatAutocompleteTrigger) trigger: MatAutocompleteTrigger; @@ -4246,7 +4262,7 @@ class AutocompleteWithOnPushDelay implements OnInit { } `, - standalone: false, + imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, ReactiveFormsModule, AsyncPipe], }) class AutocompleteWithNativeInput { optionCtrl = new FormControl(''); @@ -4270,7 +4286,7 @@ class AutocompleteWithNativeInput { @Component({ template: ``, - standalone: false, + imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, ReactiveFormsModule], }) class AutocompleteWithoutPanel { @ViewChild(MatAutocompleteTrigger) trigger: MatAutocompleteTrigger; @@ -4295,7 +4311,14 @@ class AutocompleteWithoutPanel { } `, - standalone: false, + imports: [ + MatAutocomplete, + MatAutocompleteTrigger, + MatOptgroup, + MatOption, + MatInputModule, + FormsModule, + ], }) class AutocompleteWithGroups { @ViewChild(MatAutocompleteTrigger) trigger: MatAutocompleteTrigger; @@ -4336,7 +4359,14 @@ class AutocompleteWithGroups { } `, - standalone: false, + imports: [ + MatAutocomplete, + MatAutocompleteTrigger, + MatOptgroup, + MatOption, + MatInputModule, + FormsModule, + ], }) class AutocompleteWithIndirectGroups extends AutocompleteWithGroups {} @@ -4354,7 +4384,7 @@ class AutocompleteWithIndirectGroups extends AutocompleteWithGroups {} } `, - standalone: false, + imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule, FormsModule], }) class AutocompleteWithSelectEvent { selectedState: string; @@ -4370,7 +4400,7 @@ class AutocompleteWithSelectEvent { `, - standalone: false, + imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, ReactiveFormsModule], }) class PlainAutocompleteInputWithFormControl { formControl = new FormControl(''); @@ -4388,7 +4418,7 @@ class PlainAutocompleteInputWithFormControl { } `, - standalone: false, + imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule, FormsModule], }) class AutocompleteWithNumberInputAndNgModel { selectedValue: number; @@ -4421,7 +4451,14 @@ class AutocompleteWithNumberInputAndNgModel { } `, - standalone: false, + imports: [ + MatAutocomplete, + MatAutocompleteTrigger, + MatAutocompleteOrigin, + MatOption, + MatInputModule, + FormsModule, + ], }) class AutocompleteWithDifferentOrigin { @ViewChild(MatAutocompleteTrigger) trigger: MatAutocompleteTrigger; @@ -4436,7 +4473,7 @@ class AutocompleteWithDifferentOrigin { `, - standalone: false, + imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, FormsModule], }) class AutocompleteWithNativeAutocompleteAttribute { value: string; @@ -4444,7 +4481,7 @@ class AutocompleteWithNativeAutocompleteAttribute { @Component({ template: '', - standalone: false, + imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption], }) class InputWithoutAutocompleteAndDisabled {} @@ -4460,7 +4497,7 @@ class InputWithoutAutocompleteAndDisabled {} } `, - standalone: false, + imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule], }) class AutocompleteWithActivatedEvent { states = ['California', 'West Virginia', 'Florida']; @@ -4472,7 +4509,6 @@ class AutocompleteWithActivatedEvent { } @Component({ - selector: 'autocomplete-inside-a-modal', template: ` `, - standalone: false, + imports: [ + MatAutocomplete, + MatAutocompleteTrigger, + MatOption, + MatInputModule, + ReactiveFormsModule, + OverlayModule, + ], }) class AutocompleteInsideAModal { foods = [ @@ -4508,7 +4551,6 @@ class AutocompleteInsideAModal { } @Component({ - selector: 'autocomplete-without-options', template: ` @@ -4517,7 +4559,7 @@ class AutocompleteInsideAModal { `, - standalone: false, + imports: [MatAutocomplete, MatAutocompleteTrigger, MatOption, MatInputModule], }) class AutocompleteWithoutOptions { @ViewChild(MatAutocompleteTrigger, {static: true}) trigger: MatAutocompleteTrigger; diff --git a/src/material/autocomplete/autocomplete.zone.spec.ts b/src/material/autocomplete/autocomplete.zone.spec.ts index 7db6661f9b77..67fec2070ad6 100644 --- a/src/material/autocomplete/autocomplete.zone.spec.ts +++ b/src/material/autocomplete/autocomplete.zone.spec.ts @@ -1,4 +1,3 @@ -import {OverlayModule} from '@angular/cdk/overlay'; import {dispatchFakeEvent} from '@angular/cdk/testing/private'; import { Component, @@ -12,10 +11,10 @@ import { provideZoneChangeDetection, } from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; -import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {Subscription} from 'rxjs'; import {MATERIAL_ANIMATIONS, MatOption} from '../core'; -import {MatFormField, MatFormFieldModule} from '../form-field'; +import {MatFormField} from '../form-field'; import {MatInputModule} from '../input'; import {MatAutocomplete} from './autocomplete'; import {MatAutocompleteTrigger} from './autocomplete-trigger'; @@ -25,20 +24,11 @@ describe('MatAutocomplete Zone.js integration', () => { // Creates a test component fixture. function createComponent(component: Type, providers: Provider[] = []) { TestBed.configureTestingModule({ - imports: [ - MatAutocompleteModule, - MatFormFieldModule, - MatInputModule, - FormsModule, - ReactiveFormsModule, - OverlayModule, - ], providers: [ provideZoneChangeDetection(), ...providers, {provide: MATERIAL_ANIMATIONS, useValue: {animationsDisabled: true}}, ], - declarations: [component], }); return TestBed.createComponent(component); @@ -91,41 +81,42 @@ describe('MatAutocomplete Zone.js integration', () => { })); }); -const SIMPLE_AUTOCOMPLETE_TEMPLATE = ` - - @if (hasLabel) { - State - } - - - - @for (state of filteredStates; track state) { - - {{ state.code }}: {{ state.name }} - - } - -`; - -@Component({template: SIMPLE_AUTOCOMPLETE_TEMPLATE, standalone: false}) +@Component({ + template: ` + + @if (hasLabel) { + State + } + + + + @for (state of filteredStates; track state) { + + {{ state.code }}: {{ state.name }} + + } + + `, + imports: [MatAutocompleteModule, MatInputModule, ReactiveFormsModule], +}) class SimpleAutocomplete implements OnDestroy { stateCtrl = new FormControl<{name: string; code: string} | string | null>(null); filteredStates: any[]; diff --git a/src/material/badge/BUILD.bazel b/src/material/badge/BUILD.bazel index b44cb37fab25..42c911bc9f0b 100644 --- a/src/material/badge/BUILD.bazel +++ b/src/material/badge/BUILD.bazel @@ -71,7 +71,6 @@ ng_project( ], assets = [":badge_css"], deps = [ - "//:node_modules/@angular/animations", "//:node_modules/@angular/common", "//:node_modules/@angular/core", "//:node_modules/@angular/platform-browser", diff --git a/src/material/badge/_badge-theme.scss b/src/material/badge/_badge-theme.scss index 5b99eca35535..ef5801f8fab9 100644 --- a/src/material/badge/_badge-theme.scss +++ b/src/material/badge/_badge-theme.scss @@ -1,56 +1,45 @@ -@use 'sass:color'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; +@use '../core/tokens/token-utils'; @use '../core/typography/typography'; @use './m2-badge'; -@use '../core/tokens/token-utils'; -@use '../core/style/sass-utils'; +@use './m3-badge'; +@use 'sass:color'; +@use 'sass:map'; /// Outputs base theme styles (styles not dependent on the color, typography, or density settings) /// for the mat-badge. /// @param {Map} $theme The theme to generate base styles for. @mixin base($theme) { + $tokens: map.get(m2-badge.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-badge.$prefix, - m2-badge.get-unthemable-tokens() - ); - } + $tokens: map.get(m3-badge.get-tokens($theme), base); } + + @include token-utils.values($tokens); } /// Outputs color theme styles for the mat-badge. /// @param {Map} $theme The theme to generate color styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the badge: primary, secondary, tertiary, -/// or error (If not specified, default error color will be used). -@mixin color($theme, $options...) { +/// @param {String} $color-variant The color variant to use for +/// the badge: primary, secondary, tertiary, or error (If not specified, +/// default error color will be used). +@mixin color($theme, $color-variant: null) { + $tokens: map.get(m2-badge.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color), $options...); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-badge.$prefix, - m2-badge.get-color-tokens($theme) - ); - } + $tokens: map.get(m3-badge.get-tokens($theme, $color-variant), color); + } + + @include token-utils.values($tokens); + @if inspection.get-theme-version($theme) != 1 { .mat-badge-accent { - @include token-utils.create-token-values-mixed( - m2-badge.$prefix, - m2-badge.private-get-color-palette-color-tokens($theme, accent) - ); + $tokens: m2-badge.private-get-color-palette-color-tokens($theme, secondary); + @include token-utils.values($tokens); } .mat-badge-warn { - @include token-utils.create-token-values-mixed( - m2-badge.$prefix, - m2-badge.private-get-color-palette-color-tokens($theme, warn) - ); + $tokens: m2-badge.private-get-color-palette-color-tokens($theme, error); + @include token-utils.values($tokens); } } } @@ -58,33 +47,31 @@ /// Outputs typography theme styles for the mat-badge. /// @param {Map} $theme The theme to generate typography styles for. @mixin typography($theme) { + $tokens: map.get(m2-badge.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-badge.$prefix, - m2-badge.get-typography-tokens($theme) - ); - } + $tokens: map.get(m3-badge.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } /// Outputs density theme styles for the mat-badge. /// @param {Map} $theme The theme to generate density styles for. @mixin density($theme) { + $tokens: map.get(m2-badge.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - // There are no M3 density tokens for this component - } @else { + $tokens: map.get(m3-badge.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-badge.$prefix, - tokens: m2-badge.get-token-slots(), + namespace: badge, + tokens: token-utils.get-overrides(m3-badge.get-tokens(), badge), ), ); } @@ -92,37 +79,28 @@ /// Outputs the CSS variable values for the given tokens. /// @param {Map} $tokens The token values to emit. @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } /// Outputs all (base, color, typography, and density) theme styles for the mat-badge. /// @param {Map} $theme The theme to generate styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the badge: primary, secondary, tertiary, -/// or error (If not specified, default error color will be used). -@mixin theme($theme, $options...) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-badge') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme), $options...); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } +/// @param {String} $color-variant The color variant to use for the component +@mixin theme($theme, $color-variant: null) { + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme, $color-variant); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } } } - -@mixin _theme-from-tokens($tokens, $options...) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - $mat-badge-tokens: token-utils.get-tokens-for($tokens, m2-badge.$prefix, $options...); - @include token-utils.create-token-values(m2-badge.$prefix, $mat-badge-tokens); -} diff --git a/src/material/badge/_m2-badge.scss b/src/material/badge/_m2-badge.scss index cdb2ee5e6344..226d8fb774df 100644 --- a/src/material/badge/_m2-badge.scss +++ b/src/material/badge/_m2-badge.scss @@ -1,110 +1,73 @@ -@use 'sass:meta'; @use 'sass:map'; @use 'sass:math'; -@use 'sass:color'; @use '../core/tokens/m2-utils'; -@use '../core/theming/inspection'; -@use '../core/style/sass-utils'; +@use '../core/tokens/m3-utils'; $_default-size: 22px; $_small-size: $_default-size - 6px; $_large-size: $_default-size + 6px; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, badge); - -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - $default-size: $_default-size; - $small-size: $_small-size; - $large-size: $_large-size; - - @return ( - container-shape: 50%, - container-size: unset, - small-size-container-size: unset, - large-size-container-size: unset, - - legacy-container-size: $default-size, - legacy-small-size-container-size: $small-size, - legacy-large-size-container-size: $large-size, - - container-offset: math.div($default-size, -2) 0, - small-size-container-offset: math.div($small-size, -2) 0, - large-size-container-offset: math.div($large-size, -2) 0, - - container-overlap-offset: math.div($default-size, -2), - small-size-container-overlap-offset: math.div($small-size, -2), - large-size-container-overlap-offset: math.div($large-size, -2), - - container-padding: 0, - small-size-container-padding: 0, - large-size-container-padding: 0, - ); -} +@function get-tokens($theme) { + $default-size: 22px; + $small-size: $default-size - 6px; + $large-size: $default-size + 6px; + $base-size: 12px; -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { + $system: m2-utils.get-system($theme); + $disabled: m3-utils.color-with-opacity(map.get($system, on-surface), 38%); + $disabled-container: m3-utils.color-with-opacity(map.get($system, on-surface), 12%); $primary-color-tokens: private-get-color-palette-color-tokens($theme, primary); - $app-background: inspection.get-theme-color($theme, background, background); - $disabled-background: inspection.get-theme-color($theme, foreground, disabled-button); - - // The disabled color usually has some kind of opacity, but because the badge is overlayed - // on top of something else, it won't look good if it's opaque. If it is a color *type*, - // we convert it into a solid color by taking the opacity from the rgba value and using - // the value to determine the percentage of the background to put into foreground when - // mixing the colors together. - @if (meta.type-of($disabled-background) == color and meta.type-of($app-background) == color) { - $badge-opacity: color.opacity($disabled-background); - $disabled-background: color.mix($app-background, - rgba($disabled-background, 1), (1 - $badge-opacity) * 100%); - } - - @return map.merge($primary-color-tokens, ( - disabled-state-background-color: $disabled-background, - disabled-state-text-color: inspection.get-theme-color($theme, foreground, disabled-text), - )); -} -// Generates the tokens used to theme the badge based on a palette. -@function private-get-color-palette-color-tokens($theme, $palette-name: primary) { @return ( - background-color: inspection.get-theme-color($theme, $palette-name), - text-color: inspection.get-theme-color($theme, $palette-name, default-contrast), + base: ( + badge-container-shape: 50%, + badge-container-size: unset, + badge-small-size-container-size: unset, + badge-large-size-container-size: unset, + + badge-legacy-container-size: $default-size, + badge-legacy-small-size-container-size: $small-size, + badge-legacy-large-size-container-size: $large-size, + + badge-container-offset: math.div($default-size, -2) 0, + badge-small-size-container-offset: math.div($small-size, -2) 0, + badge-large-size-container-offset: math.div($large-size, -2) 0, + + badge-container-overlap-offset: math.div($default-size, -2), + badge-small-size-container-overlap-offset: math.div($small-size, -2), + badge-large-size-container-overlap-offset: math.div($large-size, -2), + + badge-container-padding: 0, + badge-small-size-container-padding: 0, + badge-large-size-container-padding: 0, + ), + color: map.merge($primary-color-tokens, ( + badge-disabled-state-background-color: $disabled-container, + badge-disabled-state-text-color: $disabled, + )), + typography: ( + badge-text-font: map.get($system, body-medium-font), + badge-line-height: $_default-size, + badge-text-size: $base-size, + badge-text-weight: 600, + + badge-small-size-text-size: $base-size * 0.75, + badge-small-size-line-height: $_small-size, + + badge-large-size-text-size: $base-size * 2, + badge-large-size-line-height: $_large-size, + ), + density: (), ); -} +}; -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - $base-size: 12px; +// Generates the tokens used to theme the badge based on a palette. +@function private-get-color-palette-color-tokens($theme, $color-variant) { + $system: m2-utils.get-system($theme); + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); @return ( - text-font: inspection.get-theme-typography($theme, body-2, font-family), - line-height: $_default-size, - text-size: $base-size, - text-weight: 600, - - small-size-text-size: $base-size * 0.75, - small-size-line-height: $_small-size, - - large-size-text-size: $base-size * 2, - large-size-line-height: $_large-size, - ); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + badge-background-color: map.get($system, primary), + badge-text-color: map.get($system, on-primary), ); } diff --git a/src/material/badge/_m3-badge.scss b/src/material/badge/_m3-badge.scss index 108795797d3d..1ef9287cecf4 100644 --- a/src/material/badge/_m3-badge.scss +++ b/src/material/badge/_m3-badge.scss @@ -1,81 +1,52 @@ @use 'sass:map'; -@use '../core/style/sass-utils'; @use '../core/tokens/m3-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, badge); +@use '../core/tokens/m3'; /// Generates custom tokens for the mat-badge. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-badge -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: ( - background-color: map.get($systems, md-sys-color, error), - text-color: map.get($systems, md-sys-color, on-error), - disabled-state-background-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, error), - $alpha: 0.38), - disabled-state-text-color: map.get($systems, md-sys-color, on-error), - text-font: map.get($systems, md-sys-typescale, label-small-font), - text-size: map.get($systems, md-sys-typescale, label-small-size), - text-weight: map.get($systems, md-sys-typescale, label-small-weight), - small-size-text-size: m3-utils.hardcode(0, $exclude-hardcoded), - container-shape: map.get($systems, md-sys-shape, corner-full), - container-size: m3-utils.hardcode(16px, $exclude-hardcoded), - line-height: m3-utils.hardcode(16px, $exclude-hardcoded), - legacy-container-size: m3-utils.hardcode(unset, $exclude-hardcoded), - legacy-small-size-container-size: m3-utils.hardcode(unset, $exclude-hardcoded), - small-size-container-size: m3-utils.hardcode(6px, $exclude-hardcoded), - small-size-line-height: m3-utils.hardcode(6px, $exclude-hardcoded), - container-padding: m3-utils.hardcode(0 4px, $exclude-hardcoded), - small-size-container-padding: m3-utils.hardcode(0, $exclude-hardcoded), - container-offset: m3-utils.hardcode(-12px 0, $exclude-hardcoded), - small-size-container-offset: m3-utils.hardcode(-6px 0, $exclude-hardcoded), - container-overlap-offset: m3-utils.hardcode(-12px, $exclude-hardcoded), - small-size-container-overlap-offset: m3-utils.hardcode(-6px, $exclude-hardcoded), +@function get-tokens($theme: m3.$sys-theme, $color-variant: null) { + $system: m3-utils.get-system($theme); + @if $color-variant { + $system: m3-utils.replace-colors-with-variant($system, error, $color-variant); + } - // This size isn't in the M3 spec so we emit the same values as for `medium`. - large-size-container-size: m3-utils.hardcode(16px, $exclude-hardcoded), - large-size-line-height: m3-utils.hardcode(16px, $exclude-hardcoded), - large-size-container-offset: m3-utils.hardcode(-12px 0, $exclude-hardcoded), - large-size-container-overlap-offset: m3-utils.hardcode(-12px, $exclude-hardcoded), - large-size-text-size: map.get($systems, md-sys-typescale, label-small-size), - large-size-container-padding: m3-utils.hardcode(0 4px, $exclude-hardcoded), - legacy-large-size-container-size: m3-utils.hardcode(unset, $exclude-hardcoded), - ); - - $variant-tokens: ( - primary: ( - background-color: map.get($systems, md-sys-color, primary), - text-color: map.get($systems, md-sys-color, on-primary), - disabled-state-background-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, primary), - $alpha: 0.38, - ), - disabled-state-text-color: map.get($systems, md-sys-color, on-primary), + $tokens: ( + base: ( + badge-container-offset: -12px 0, + badge-container-overlap-offset: -12px, + badge-container-padding: 0 4px, + badge-container-shape: map.get($system, corner-full), + badge-container-size: 16px, + badge-large-size-container-offset: -12px 0, + badge-large-size-container-overlap-offset: -12px, + badge-large-size-container-padding: 0 4px, + badge-large-size-container-size: 16px, + badge-legacy-container-size: unset, + badge-legacy-large-size-container-size: unset, + badge-legacy-small-size-container-size: unset, + badge-small-size-container-offset: -6px 0, + badge-small-size-container-overlap-offset: -6px, + badge-small-size-container-padding: 0, + badge-small-size-container-size: 6px, ), - secondary: ( - background-color: map.get($systems, md-sys-color, secondary), - text-color: map.get($systems, md-sys-color, on-secondary), - disabled-state-background-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, secondary), - $alpha: 0.38, - ), - disabled-state-text-color: map.get($systems, md-sys-color, on-secondary), + color: ( + badge-background-color: map.get($system, error), + badge-disabled-state-background-color: + m3-utils.color-with-opacity(map.get($system, error), 38%), + badge-disabled-state-text-color: map.get($system, on-error), + badge-text-color: map.get($system, on-error), ), - tertiary: ( - background-color: map.get($systems, md-sys-color, tertiary), - text-color: map.get($systems, md-sys-color, on-tertiary), - disabled-state-background-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, tertiary), - $alpha: 0.38, - ), - disabled-state-text-color: map.get($systems, md-sys-color, on-tertiary), + typography: ( + badge-large-size-line-height: 16px, + badge-large-size-text-size: map.get($system, label-small-size), + badge-line-height: 16px, + badge-small-size-line-height: 6px, + badge-small-size-text-size: 0, + badge-text-font: map.get($system, label-small-font), + badge-text-size: map.get($system, label-small-size), + badge-text-weight: map.get($system, label-small-weight), ), - error: () // Default, no overrides needed + density: (), ); - @return m3-utils.namespace($prefix, ($tokens, $variant-tokens), $token-slots); + @return $tokens; } diff --git a/src/material/badge/badge.md b/src/material/badge/badge.md index c3263b2e94df..b4f4a4146f99 100644 --- a/src/material/badge/badge.md +++ b/src/material/badge/badge.md @@ -51,4 +51,4 @@ badge determines interactivity based on whether the host element is focusable. When applying a badge to a ``, it is important to know that `` is `aria-hidden="true"` by default. If the combination of icon and badge communicates meaningful information, always surface this information in another way. [See the guidance on indicator -icons for more information](https://material.angular.io/components/icon/overview#indicator-icons). +icons for more information](https://material.angular.dev/components/icon/overview#indicator-icons). diff --git a/src/material/badge/badge.scss b/src/material/badge/badge.scss index a05d5245bcb9..ee771a08aa7f 100644 --- a/src/material/badge/badge.scss +++ b/src/material/badge/badge.scss @@ -1,43 +1,40 @@ @use 'sass:color'; @use '@angular/cdk'; -@use './m2-badge'; +@use './m3-badge'; @use '../core/tokens/token-utils'; $default-size: 22px !default; $small-size: $default-size - 6; $large-size: $default-size + 6; -$token-prefix: m2-badge.$prefix; -$token-slots: m2-badge.get-token-slots(); +$fallbacks: m3-badge.get-tokens(); @mixin _badge-size($size) { - @include token-utils.use-tokens($token-prefix, $token-slots) { - $prefix: if($size == 'medium', '', $size + '-size-'); - $legacy-size-var-name: 'legacy-#{$prefix}container-size'; - $size-var-name: '#{$prefix}container-size'; - - .mat-badge-content { - // The M2 badge is implemented incorrectly because it uses `width` and `height` for its - // size which causes the text to be truncated. For M3 we want to fix this by emitting - // two declarations: - // * `legacy-container-size` token - targets width/height as in M2. In M3 the token is - // emitted as `unset`. - // * `container-size` token - In M2 the token is emitted as `unset` to preserve the legacy - // behavior while in M3 it targets `min-width` and `min-height` which allows the badge to - // grow with the content. - width: token-utils.slot($legacy-size-var-name); - height: token-utils.slot($legacy-size-var-name); - min-width: token-utils.slot($size-var-name); - min-height: token-utils.slot($size-var-name); - line-height: token-utils.slot('#{$prefix}line-height'); - padding: token-utils.slot('#{$prefix}container-padding'); - font-size: token-utils.slot('#{$prefix}text-size'); - margin: token-utils.slot('#{$prefix}container-offset'); - } - - &.mat-badge-overlap .mat-badge-content { - margin: token-utils.slot('#{$prefix}container-overlap-offset'); - } + $prefix: if($size == 'medium', '', $size + '-size-'); + $legacy-size-var-name: 'badge-legacy-#{$prefix}container-size'; + $size-var-name: 'badge-#{$prefix}container-size'; + + .mat-badge-content { + // The M2 badge is implemented incorrectly because it uses `width` and `height` for its + // size which causes the text to be truncated. For M3 we want to fix this by emitting + // two declarations: + // * `legacy-container-size` token - targets width/height as in M2. In M3 the token is + // emitted as `unset`. + // * `container-size` token - In M2 the token is emitted as `unset` to preserve the legacy + // behavior while in M3 it targets `min-width` and `min-height` which allows the badge to + // grow with the content. + width: token-utils.slot($legacy-size-var-name, $fallbacks); + height: token-utils.slot($legacy-size-var-name, $fallbacks); + min-width: token-utils.slot($size-var-name, $fallbacks); + min-height: token-utils.slot($size-var-name, $fallbacks); + line-height: token-utils.slot('badge-#{$prefix}line-height', $fallbacks); + padding: token-utils.slot('badge-#{$prefix}container-padding', $fallbacks); + font-size: token-utils.slot('badge-#{$prefix}text-size', $fallbacks); + margin: token-utils.slot('badge-#{$prefix}container-offset', $fallbacks); + } + + &.mat-badge-overlap .mat-badge-content { + margin: token-utils.slot('badge-#{$prefix}container-overlap-offset', $fallbacks); } } @@ -65,38 +62,36 @@ $token-slots: m2-badge.get-token-slots(); box-sizing: border-box; pointer-events: none; - @include token-utils.use-tokens($token-prefix, $token-slots) { - background-color: token-utils.slot(background-color); - color: token-utils.slot(text-color); - font-family: token-utils.slot(text-font); - font-weight: token-utils.slot(text-weight); - border-radius: token-utils.slot(container-shape); - - .mat-badge-above & { - bottom: 100%; - } - - .mat-badge-below & { - top: 100%; - } - - .mat-badge-before & { - right: 100%; - } - - [dir='rtl'] .mat-badge-before & { - right: auto; - left: 100%; - } - - .mat-badge-after & { - left: 100%; - } - - [dir='rtl'] .mat-badge-after & { - left: auto; - right: 100%; - } + background-color: token-utils.slot(badge-background-color, $fallbacks); + color: token-utils.slot(badge-text-color, $fallbacks); + font-family: token-utils.slot(badge-text-font, $fallbacks); + font-weight: token-utils.slot(badge-text-weight, $fallbacks); + border-radius: token-utils.slot(badge-container-shape, $fallbacks); + + .mat-badge-above & { + bottom: 100%; + } + + .mat-badge-below & { + top: 100%; + } + + .mat-badge-before & { + right: 100%; + } + + [dir='rtl'] .mat-badge-before & { + right: auto; + left: 100%; + } + + .mat-badge-after & { + left: 100%; + } + + [dir='rtl'] .mat-badge-after & { + left: auto; + right: 100%; } @include cdk.high-contrast { @@ -106,10 +101,8 @@ $token-slots: m2-badge.get-token-slots(); } .mat-badge-disabled .mat-badge-content { - @include token-utils.use-tokens($token-prefix, $token-slots) { - background-color: token-utils.slot(disabled-state-background-color); - color: token-utils.slot(disabled-state-text-color); - } + background-color: token-utils.slot(badge-disabled-state-background-color, $fallbacks); + color: token-utils.slot(badge-disabled-state-text-color, $fallbacks); } .mat-badge-hidden .mat-badge-content { diff --git a/src/material/badge/badge.ts b/src/material/badge/badge.ts index 5815d5670cad..2a1745f13560 100644 --- a/src/material/badge/badge.ts +++ b/src/material/badge/badge.ts @@ -82,10 +82,10 @@ export class MatBadge implements OnInit, OnDestroy { /** * Theme color of the badge. This API is supported in M2 themes only, it - * has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/badge/styling. + * has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/badge/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input('matBadgeColor') get color(): ThemePalette { @@ -167,7 +167,7 @@ export class MatBadge implements OnInit, OnDestroy { // Heads-up for developers to avoid putting matBadge on // as it is aria-hidden by default docs mention this at: - // https://material.angular.io/components/badge/overview#accessibility + // https://material.angular.dev/components/badge/overview#accessibility if ( tagName.toLowerCase() === 'mat-icon' && nativeElement.getAttribute('aria-hidden') === 'true' diff --git a/src/material/bottom-sheet/README.md b/src/material/bottom-sheet/README.md index ac1f4b38951a..d8a7d20fffa3 100644 --- a/src/material/bottom-sheet/README.md +++ b/src/material/bottom-sheet/README.md @@ -1 +1 @@ -Please see the official documentation at https://material.angular.io/components/component/bottom-sheet +Please see the official documentation at https://material.angular.dev/components/component/bottom-sheet diff --git a/src/material/bottom-sheet/_bottom-sheet-theme.scss b/src/material/bottom-sheet/_bottom-sheet-theme.scss index 3fc593944d06..2e887e713a12 100644 --- a/src/material/bottom-sheet/_bottom-sheet-theme.scss +++ b/src/material/bottom-sheet/_bottom-sheet-theme.scss @@ -1,99 +1,76 @@ @use 'sass:map'; @use '../core/typography/typography'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; -@use '../core/style/sass-utils'; @use '../core/tokens/token-utils'; @use './m2-bottom-sheet'; +@use './m3-bottom-sheet'; @mixin base($theme) { + $tokens: map.get(m2-bottom-sheet.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-bottom-sheet.$prefix, - m2-bottom-sheet.get-unthemable-tokens() - ); - } + $tokens: map.get(m3-bottom-sheet.get-tokens($theme), base); } + + @include token-utils.values($tokens); } @mixin color($theme) { + $tokens: map.get(m2-bottom-sheet.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-bottom-sheet.$prefix, - m2-bottom-sheet.get-color-tokens($theme) - ); - } + $tokens: map.get(m3-bottom-sheet.get-tokens($theme), color); } + + @include token-utils.values($tokens); } @mixin typography($theme) { + $tokens: map.get(m2-bottom-sheet.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-bottom-sheet.$prefix, - m2-bottom-sheet.get-typography-tokens($theme) - ); - } + $tokens: map.get(m3-bottom-sheet.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } @mixin density($theme) { + $tokens: map.get(m2-bottom-sheet.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - // There are no M3 density tokens for this component - } @else { + $tokens: map.get(m3-bottom-sheet.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-bottom-sheet.$prefix, - tokens: m2-bottom-sheet.get-token-slots(), + namespace: bottom-sheet, + tokens: token-utils.get-overrides(m3-bottom-sheet.get-tokens(), bottom-sheet) ), ); } @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } @mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-bottom-sheet') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - @include token-utils.create-token-values( - m2-bottom-sheet.$prefix, - map.get($tokens, m2-bottom-sheet.$prefix) - ); } } diff --git a/src/material/bottom-sheet/_m2-bottom-sheet.scss b/src/material/bottom-sheet/_m2-bottom-sheet.scss index 292b1e773aa8..f4a0889bae34 100644 --- a/src/material/bottom-sheet/_m2-bottom-sheet.scss +++ b/src/material/bottom-sheet/_m2-bottom-sheet.scss @@ -1,50 +1,24 @@ @use '../core/tokens/m2-utils'; -@use '../core/theming/inspection'; -@use '../core/style/sass-utils'; +@use 'sass:map'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, bottom-sheet); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { @return ( - // TODO: will be necessary for M3. - container-shape: 4px, - ); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - @return ( - container-text-color: inspection.get-theme-color($theme, foreground, text), - container-background-color: inspection.get-theme-color($theme, background, dialog), - ); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return ( - container-text-font: inspection.get-theme-typography($theme, body-2, font-family), - container-text-line-height: inspection.get-theme-typography($theme, body-2, line-height), - container-text-size: inspection.get-theme-typography($theme, body-2, font-size), - container-text-tracking: inspection.get-theme-typography($theme, body-2, letter-spacing), - container-text-weight: inspection.get-theme-typography($theme, body-2, font-weight), - ); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + base: ( + bottom-sheet-container-shape: 4px, + ), + color: ( + bottom-sheet-container-text-color: map.get($system, on-surface), + bottom-sheet-container-background-color: map.get($system, surface), + ), + typography: ( + bottom-sheet-container-text-font: map.get($system, body-medium-font), + bottom-sheet-container-text-line-height: map.get($system, body-medium-line-height), + bottom-sheet-container-text-size: map.get($system, body-medium-size), + bottom-sheet-container-text-tracking: map.get($system, body-medium-tracking), + bottom-sheet-container-text-weight: map.get($system, body-medium-weight), + ), + density: (), ); } diff --git a/src/material/bottom-sheet/_m3-bottom-sheet.scss b/src/material/bottom-sheet/_m3-bottom-sheet.scss index d628b04e19d9..87420eb865bd 100644 --- a/src/material/bottom-sheet/_m3-bottom-sheet.scss +++ b/src/material/bottom-sheet/_m3-bottom-sheet.scss @@ -1,24 +1,25 @@ @use 'sass:map'; -@use '../core/style/sass-utils'; @use '../core/tokens/m3-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, bottom-sheet); +@use '../core/tokens/m3'; /// Generates custom tokens for the mat-bottom-sheet. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-bottom-sheet -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: sass-utils.merge-all( - m3-utils.generate-typography-tokens($systems, container-text, body-large), - ( - container-shape: m3-utils.hardcode(28px, $exclude-hardcoded), - container-text-color: map.get($systems, md-sys-color, on-surface), - container-background-color: map.get($systems, md-sys-color, surface-container-low), +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); + @return ( + base: ( + bottom-sheet-container-shape: 28px, + ), + color: ( + bottom-sheet-container-text-color: map.get($system, on-surface), + bottom-sheet-container-background-color: map.get($system, surface-container-low), ), + typography: ( + bottom-sheet-container-text-font: map.get($system, body-large-font), + bottom-sheet-container-text-line-height: map.get($system, body-large-line-height), + bottom-sheet-container-text-size: map.get($system, body-large-size), + bottom-sheet-container-text-tracking: map.get($system, body-large-tracking), + bottom-sheet-container-text-weight: map.get($system, body-large-weight), + ), + density: (), ); - - @return m3-utils.namespace($prefix, $tokens, $token-slots); } diff --git a/src/material/bottom-sheet/bottom-sheet-container.scss b/src/material/bottom-sheet/bottom-sheet-container.scss index 4f47d87a5dce..8fd8c681664c 100644 --- a/src/material/bottom-sheet/bottom-sheet-container.scss +++ b/src/material/bottom-sheet/bottom-sheet-container.scss @@ -1,6 +1,6 @@ @use '@angular/cdk'; @use '../core/style/elevation'; -@use './m2-bottom-sheet'; +@use './m3-bottom-sheet'; @use '../core/tokens/token-utils'; // The bottom sheet minimum width on larger screen sizes is based @@ -10,8 +10,7 @@ $_width-increment: 64px; $container-vertical-padding: 8px !default; $container-horizontal-padding: 16px !default; -$token-prefix: m2-bottom-sheet.$prefix; -$token-slots: m2-bottom-sheet.get-token-slots(); +$fallbacks: m3-bottom-sheet.get-tokens(); @keyframes _mat-bottom-sheet-enter { from { @@ -48,15 +47,13 @@ $token-slots: m2-bottom-sheet.get-token-slots(); // elements (e.g. close buttons) inside the bottom sheet. position: relative; - @include token-utils.use-tokens($token-prefix, $token-slots) { - background: token-utils.slot(container-background-color); - color: token-utils.slot(container-text-color); - font-family: token-utils.slot(container-text-font); - font-size: token-utils.slot(container-text-size); - line-height: token-utils.slot(container-text-line-height); - font-weight: token-utils.slot(container-text-weight); - letter-spacing: token-utils.slot(container-text-tracking); - } + background: token-utils.slot(bottom-sheet-container-background-color, $fallbacks); + color: token-utils.slot(bottom-sheet-container-text-color, $fallbacks); + font-family: token-utils.slot(bottom-sheet-container-text-font, $fallbacks); + font-size: token-utils.slot(bottom-sheet-container-text-size, $fallbacks); + line-height: token-utils.slot(bottom-sheet-container-text-line-height, $fallbacks); + font-weight: token-utils.slot(bottom-sheet-container-text-weight, $fallbacks); + letter-spacing: token-utils.slot(bottom-sheet-container-text-tracking, $fallbacks); @include cdk.high-contrast { outline: 1px solid; @@ -77,10 +74,8 @@ $token-slots: m2-bottom-sheet.get-token-slots(); // Applies a border radius to the bottom sheet. Should only be applied when it's not full-screen. %_mat-bottom-sheet-container-border-radius { - @include token-utils.use-tokens($token-prefix, $token-slots) { - border-top-left-radius: token-utils.slot(container-shape); - border-top-right-radius: token-utils.slot(container-shape); - } + border-top-left-radius: token-utils.slot(bottom-sheet-container-shape, $fallbacks); + border-top-right-radius: token-utils.slot(bottom-sheet-container-shape, $fallbacks); } .mat-bottom-sheet-container-medium { diff --git a/src/material/button-toggle/README.md b/src/material/button-toggle/README.md index fb4499d1cda0..e596a6654baf 100644 --- a/src/material/button-toggle/README.md +++ b/src/material/button-toggle/README.md @@ -1 +1 @@ -Please see the official documentation at https://material.angular.io/components/component/button-toggle \ No newline at end of file +Please see the official documentation at https://material.angular.dev/components/component/button-toggle diff --git a/src/material/button-toggle/_button-toggle-theme.scss b/src/material/button-toggle/_button-toggle-theme.scss index 6ea99840bf34..dc5512d2f49a 100644 --- a/src/material/button-toggle/_button-toggle-theme.scss +++ b/src/material/button-toggle/_button-toggle-theme.scss @@ -1,85 +1,63 @@ -@use '../core/style/sass-utils'; @use '../core/theming/inspection'; -@use '../core/theming/theming'; -@use '../core/theming/validation'; @use '../core/tokens/token-utils'; @use '../core/typography/typography'; @use './m2-button-toggle'; @use './m3-button-toggle'; +@use 'sass:map'; /// Outputs base theme styles (styles not dependent on the color, typography, or density settings) /// for the mat-button-toggle. /// @param {Map} $theme The theme to generate base styles for. @mixin base($theme) { + $tokens: map.get(m2-button-toggle.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-button-toggle.$prefix, - m2-button-toggle.get-unthemable-tokens() - ); - } + $tokens: map.get(m3-button-toggle.get-tokens($theme), base); } + + @include token-utils.values($tokens); } /// Outputs color theme styles for the mat-button-toggle. /// @param {Map} $theme The theme to generate color styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the button toggle: primary, secondary, tertiary, -/// or error (If not specified, default secondary color will be used). -@mixin color($theme, $options...) { +/// @param {String} $color-variant: The color variant to use for the button toggle: primary, +/// secondary, tertiary, or error (If not specified, default secondary color will be used). +@mixin color($theme, $color-variant: null) { + $tokens: map.get(m2-button-toggle.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color), $options...); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-button-toggle.$prefix, - m2-button-toggle.get-color-tokens($theme) - ); - } + $tokens: map.get(m3-button-toggle.get-tokens($theme, $color-variant), color); } + + @include token-utils.values($tokens); } /// Outputs typography theme styles for the mat-button-toggle. /// @param {Map} $theme The theme to generate typography styles for. @mixin typography($theme) { + $tokens: map.get(m2-button-toggle.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-button-toggle.$prefix, - m2-button-toggle.get-typography-tokens($theme) - ); - } + $tokens: map.get(m3-button-toggle.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } /// Outputs density theme styles for the mat-button-toggle. /// @param {Map} $theme The theme to generate density styles for. @mixin density($theme) { + $tokens: map.get(m2-button-toggle.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - @include token-utils.create-token-values( - m3-button-toggle.$prefix, m3-button-toggle.get-density-tokens($theme)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-button-toggle.$prefix, - m2-button-toggle.get-density-tokens($theme) - ); - } + $tokens: map.get(m3-button-toggle.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { - $standard-tokens: m2-button-toggle.get-token-slots(); - @return ( ( - namespace: m2-button-toggle.$prefix, - tokens: $standard-tokens, + namespace: button-toggle, + tokens: token-utils.get-overrides(m3-button-toggle.get-tokens(), button-toggle) ), ); } @@ -87,47 +65,29 @@ /// Outputs the CSS variable values for the given tokens. /// @param {Map} $tokens The token values to emit. @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } /// Outputs all (base, color, typography, and density) theme styles for the mat-button-toggle. /// @param {Map} $theme The theme to generate styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the button toggle: primary, secondary, tertiary, -/// or error (If not specified, default secondary color will be used). -@mixin theme($theme, $options...) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-button-toggle') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme), $options...); - @if inspection.theme-has($theme, density) { - @include density($theme); - } - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } +/// @param {String} $color-variant: The color variant to use for the button toggle: primary, +/// secondary, tertiary, or error (If not specified, default secondary color will be used). +@mixin theme($theme, $color-variant: null) { + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme, $color-variant); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } } } - -@mixin _theme-from-tokens($tokens, $options...) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - $mat-standard-button-toggle-tokens: token-utils.get-tokens-for( - $tokens, - m2-button-toggle.$prefix, - $options... - ); - @include token-utils.create-token-values( - m2-button-toggle.$prefix, - $mat-standard-button-toggle-tokens - ); -} diff --git a/src/material/button-toggle/_m2-button-toggle.scss b/src/material/button-toggle/_m2-button-toggle.scss index 77ab67956945..e826eb874ffb 100644 --- a/src/material/button-toggle/_m2-button-toggle.scss +++ b/src/material/button-toggle/_m2-button-toggle.scss @@ -1,108 +1,68 @@ @use 'sass:map'; -@use 'sass:meta'; -@use '../core/tokens/m2-utils'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; -@use '../core/style/sass-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, button-toggle); - -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - @return ( - focus-state-layer-opacity: 0.12, - hover-state-layer-opacity: 0.04, - legacy-focus-state-layer-opacity: 1, - legacy-height: 36px, - legacy-shape: 2px, - shape: 4px, - ); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - $theme-divider-color: inspection.get-theme-color($theme, foreground, divider); - - // By default the theme usually has an rgba color for the dividers, which can - // stack up with the background of a button toggle. This can cause the border - // of a selected toggle to look different from an unselected one. We use a solid - // color to ensure that the border always stays the same. - $divider-color: if( - meta.type-of($theme-divider-color) == color, - theming.private-rgba-to-hex( - $theme-divider-color, inspection.get-theme-color($theme, background, card)), - $theme-divider-color); - - @return ( - background-color: inspection.get-theme-color($theme, background, card), - disabled-selected-state-background-color: - inspection.get-theme-color($theme, background, selected-disabled-button), - disabled-selected-state-text-color: inspection.get-theme-color($theme, foreground, text), - disabled-state-background-color: inspection.get-theme-color($theme, background, card), - disabled-state-text-color: inspection.get-theme-color($theme, foreground, disabled-button), - divider-color: $divider-color, - legacy-disabled-selected-state-background-color: - inspection.get-theme-color($theme, background, selected-disabled-button), - legacy-disabled-state-background-color: - inspection.get-theme-color($theme, background, disabled-button-toggle), - legacy-disabled-state-text-color: - inspection.get-theme-color($theme, foreground, disabled-button), - legacy-selected-state-background-color: - inspection.get-theme-color($theme, background, selected-button), - legacy-selected-state-text-color: - inspection.get-theme-color($theme, foreground, secondary-text), - legacy-state-layer-color: inspection.get-theme-color($theme, background, focused-button), - legacy-text-color: inspection.get-theme-color($theme, foreground, hint-text), - selected-state-background-color: - inspection.get-theme-color($theme, background, selected-button), - selected-state-text-color: inspection.get-theme-color($theme, foreground, text), - state-layer-color: sass-utils.safe-color-change( - inspection.get-theme-color($theme, background, focused-button), $alpha: 1), - text-color: inspection.get-theme-color($theme, foreground, text), - ); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return ( - label-text-font: inspection.get-theme-typography($theme, body-1, font-family), - label-text-line-height: inspection.get-theme-typography($theme, body-1, line-height), - label-text-size: inspection.get-theme-typography($theme, body-1, font-size), - label-text-tracking: inspection.get-theme-typography($theme, body-1, letter-spacing), - label-text-weight: inspection.get-theme-typography($theme, body-1, font-weight), - legacy-label-text-font: inspection.get-theme-typography($theme, body-1, font-family), - legacy-label-text-line-height: inspection.get-theme-typography($theme, body-1, line-height), - legacy-label-text-size: inspection.get-theme-typography($theme, body-1, font-size), - legacy-label-text-tracking: inspection.get-theme-typography($theme, body-1, letter-spacing), - legacy-label-text-weight: inspection.get-theme-typography($theme, body-1, font-weight), - ); -} +@use '../core/tokens/m2-utils'; +@use '../core/tokens/m3-utils'; -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $density-scale: theming.clamp-density(inspection.get-theme-density($theme), -4); - $size-scale: ( - 0: 48px, - -1: 44px, - -2: 40px, - -3: 36px, - -4: 24px, - ); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); + $density-scale: theming.clamp-density(map.get($system, density-scale), -4); @return ( - height: map.get($size-scale, $density-scale) - ); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + base: ( + button-toggle-focus-state-layer-opacity: map.get($system, focus-state-layer-opacity), + button-toggle-hover-state-layer-opacity: map.get($system, hover-state-layer-opacity), + button-toggle-legacy-focus-state-layer-opacity: 1, + button-toggle-legacy-height: 36px, + button-toggle-legacy-shape: 2px, + button-toggle-shape: 4px, + ), + color: ( + button-toggle-background-color: map.get($system, surface), + button-toggle-disabled-selected-state-background-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 12%), + button-toggle-disabled-selected-state-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + button-toggle-disabled-state-background-color: map.get($system, surface), + button-toggle-disabled-state-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + button-toggle-divider-color: map.get($system, outline), + button-toggle-legacy-disabled-selected-state-background-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 12%), + button-toggle-legacy-disabled-state-background-color: map.get($system, surface), + button-toggle-legacy-disabled-state-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + button-toggle-legacy-selected-state-background-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 12%), + button-toggle-legacy-selected-state-text-color: map.get($system, on-surface), + button-toggle-legacy-state-layer-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 12%), + button-toggle-legacy-text-color: map.get($system, on-surface), + button-toggle-selected-state-background-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 12%), + button-toggle-selected-state-text-color: map.get($system, on-surface), + button-toggle-state-layer-color: map.get($system, on-surface), + button-toggle-text-color: map.get($system, on-surface), + ), + typography: ( + button-toggle-label-text-font: map.get($system, body-large-font), + button-toggle-label-text-line-height: map.get($system, body-large-line-height), + button-toggle-label-text-size: map.get($system, body-large-size), + button-toggle-label-text-tracking: map.get($system, body-large-tracking), + button-toggle-label-text-weight: map.get($system, body-large-weight), + button-toggle-legacy-label-text-font: map.get($system, body-large-font), + button-toggle-legacy-label-text-line-height: map.get($system, body-large-line-height), + button-toggle-legacy-label-text-size: map.get($system, body-large-size), + button-toggle-legacy-label-text-tracking: map.get($system, body-large-tracking), + button-toggle-legacy-label-text-weight: map.get($system, body-large-weight), + ), + density: ( + button-toggle-height: map.get(( + 0: 48px, + -1: 44px, + -2: 40px, + -3: 36px, + -4: 24px, + ), $density-scale) + ), ); } diff --git a/src/material/button-toggle/_m3-button-toggle.scss b/src/material/button-toggle/_m3-button-toggle.scss index 935455ef4abb..d7190903d501 100644 --- a/src/material/button-toggle/_m3-button-toggle.scss +++ b/src/material/button-toggle/_m3-button-toggle.scss @@ -1,73 +1,72 @@ @use 'sass:map'; @use 'sass:list'; -@use '../core/style/sass-utils'; @use '../core/tokens/m3-utils'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, button-toggle); +@use '../core/tokens/m3'; /// Generates custom tokens for the mat-button-toggle. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-button-toggle -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: sass-utils.merge-all( - m3-utils.generate-typography-tokens($systems, label-text, label-large), - ( - shape: map.get($systems, md-sys-shape, corner-full), - hover-state-layer-opacity: map.get($systems, md-sys-state, hover-state-layer-opacity), - focus-state-layer-opacity: map.get($systems, md-sys-state, focus-state-layer-opacity), - text-color: map.get($systems, md-sys-color, on-surface), - background-color: m3-utils.hardcode(transparent, $exclude-hardcoded), - state-layer-color: map.get($systems, md-sys-color, on-surface), - selected-state-background-color: map.get($systems, md-sys-color, secondary-container), - selected-state-text-color: map.get($systems, md-sys-color, on-secondary-container), - disabled-state-text-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: 0.38, - ), - disabled-state-background-color: - m3-utils.hardcode(transparent, $exclude-hardcoded), - disabled-selected-state-text-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: 0.38, - ), - disabled-selected-state-background-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: 0.12, - ), - divider-color: map.get($systems, md-sys-color, outline), - )); +@function get-tokens($theme: m3.$sys-theme, $color-variant: null) { + $system: m3-utils.get-system($theme); + @if $color-variant { + $system: m3-utils.replace-colors-with-variant($system, secondary, $color-variant); + } - $color-variants: ( - // Color variants: - primary: ( - selected-state-background-color: map.get($systems, md-sys-color, primary-container), - selected-state-text-color: map.get($systems, md-sys-color, on-primary-container), + $tokens: ( + base: ( + button-toggle-focus-state-layer-opacity: map.get($system, focus-state-layer-opacity), + button-toggle-hover-state-layer-opacity: map.get($system, hover-state-layer-opacity), + button-toggle-shape: map.get($system, corner-extra-large), + button-toggle-legacy-shape: null, + button-toggle-legacy-selected-state-text-color: null, + button-toggle-legacy-focus-state-layer-opacity: null, + button-toggle-legacy-height: null, + ), + typography: ( + button-toggle-label-text-font: map.get($system, label-large-font), + button-toggle-label-text-line-height: map.get($system, label-large-line-height), + button-toggle-label-text-size: map.get($system, label-large-size), + button-toggle-label-text-tracking: map.get($system, label-large-tracking), + button-toggle-label-text-weight: map.get($system, label-large-weight), + button-toggle-legacy-label-text-font: null, + button-toggle-legacy-label-text-line-height: null, + button-toggle-legacy-label-text-size: null, + button-toggle-legacy-label-text-tracking: null, + button-toggle-legacy-label-text-weight: null, ), - secondary: (), // Default, no overrides needed - tertiary: ( - selected-state-background-color: map.get($systems, md-sys-color, tertiary-container), - selected-state-text-color: map.get($systems, md-sys-color, on-tertiary-container), + color: ( + button-toggle-background-color: transparent, + button-toggle-disabled-selected-state-background-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 12%), + button-toggle-disabled-selected-state-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + button-toggle-disabled-state-background-color: transparent, + button-toggle-disabled-state-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + button-toggle-divider-color: map.get($system, outline), + button-toggle-selected-state-background-color: map.get($system, secondary-container), + button-toggle-selected-state-text-color: map.get($system, on-secondary-container), + button-toggle-state-layer-color: map.get($system, on-surface), + button-toggle-text-color: map.get($system, on-surface), + button-toggle-legacy-disabled-selected-state-background-color: null, + button-toggle-legacy-disabled-state-background-color: null, + button-toggle-legacy-disabled-state-text-color: null, + button-toggle-legacy-selected-state-background-color: null, + button-toggle-legacy-selected-state-text-color: null, + button-toggle-legacy-state-layer-color: null, + button-toggle-legacy-text-color: null, ), - error: ( - selected-state-background-color: map.get($systems, md-sys-color, error-container), - selected-state-text-color: map.get($systems, md-sys-color, on-error-container), - ) + density: get-density-tokens(map.get($system, density-scale)), ); - @return m3-utils.namespace($prefix, ($tokens, $color-variants), $token-slots); + @return $tokens; } // Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $scale: theming.clamp-density(inspection.get-theme-density($theme), -4); +@function get-density-tokens($scale) { + $scale: theming.clamp-density(scale, -4); $index: ($scale * -1) + 1; @return ( - height: list.nth((40px, 40px, 40px, 36px, 24px), $index), + button-toggle-height: list.nth((40px, 40px, 40px, 36px, 24px), $index), ); } diff --git a/src/material/button-toggle/button-toggle.scss b/src/material/button-toggle/button-toggle.scss index 2ffd3a6b0b12..9245d028ca53 100644 --- a/src/material/button-toggle/button-toggle.scss +++ b/src/material/button-toggle/button-toggle.scss @@ -4,7 +4,7 @@ @use '../core/style/layout-common'; @use '../core/style/elevation'; @use '../core/tokens/token-utils'; -@use './m2-button-toggle'; +@use './m3-button-toggle'; $standard-padding: 12px !default; $legacy-padding: 16px !default; @@ -17,361 +17,365 @@ $legacy-height: 36px !default; $standard-border-radius: 4px !default; $legacy-border-radius: 2px !default; -@include token-utils.use-tokens(m2-button-toggle.$prefix, m2-button-toggle.get-token-slots()) { - .mat-button-toggle-standalone, - .mat-button-toggle-group { - position: relative; - display: inline-flex; - flex-direction: row; - white-space: nowrap; - overflow: hidden; - -webkit-tap-highlight-color: transparent; - border-radius: token-utils.slot(legacy-shape); +$fallbacks: m3-button-toggle.get-tokens(); - // Fixes the ripples not being clipped to the border radius on Safari. - transform: translateZ(0); +.mat-button-toggle-standalone, +.mat-button-toggle-group { + position: relative; + display: inline-flex; + flex-direction: row; + white-space: nowrap; + overflow: hidden; + -webkit-tap-highlight-color: transparent; + border-radius: token-utils.slot(button-toggle-legacy-shape, $fallbacks); - @include elevation.overridable-elevation(2); + // Fixes the ripples not being clipped to the border radius on Safari. + transform: translateZ(0); - @include cdk.high-contrast { - outline: solid 1px; - } - } - - .mat-button-toggle-standalone.mat-button-toggle-appearance-standard, - .mat-button-toggle-group-appearance-standard { - border-radius: token-utils.slot(shape); - border: solid 1px token-utils.slot(divider-color); + @include elevation.overridable-elevation(2); - .mat-pseudo-checkbox { - --mat-pseudo-checkbox-minimal-selected-checkmark-color: #{ - token-utils.slot(selected-state-text-color)}; - } + @include cdk.high-contrast { + outline: solid 1px; + } +} - &:not([class*='mat-elevation-z']) { - box-shadow: none; - } +.mat-button-toggle-standalone.mat-button-toggle-appearance-standard, +.mat-button-toggle-group-appearance-standard { + border-radius: token-utils.slot(button-toggle-shape, $fallbacks); + border: solid 1px token-utils.slot(button-toggle-divider-color, $fallbacks); - @include cdk.high-contrast { - outline: 0; - } + .mat-pseudo-checkbox { + --mat-pseudo-checkbox-minimal-selected-checkmark-color: #{ + token-utils.slot(button-toggle-selected-state-text-color, $fallbacks)}; } - .mat-button-toggle-vertical { - flex-direction: column; + &:not([class*='mat-elevation-z']) { + box-shadow: none; + } - .mat-button-toggle-label-content { - // Vertical button toggles shouldn't be an inline-block, because the toggles should - // fill the available width in the group. - display: block; - } + @include cdk.high-contrast { + outline: 0; } +} - .mat-button-toggle { - white-space: nowrap; - position: relative; - color: token-utils.slot(legacy-text-color); - font-family: token-utils.slot(legacy-label-text-font); - font-size: token-utils.slot(legacy-label-text-size); - line-height: token-utils.slot(legacy-label-text-line-height); - font-weight: token-utils.slot(legacy-label-text-weight); - letter-spacing: token-utils.slot(legacy-label-text-tracking); +.mat-button-toggle-vertical { + flex-direction: column; - --mat-pseudo-checkbox-minimal-selected-checkmark-color: #{ - token-utils.slot(legacy-selected-state-text-color)}; + .mat-button-toggle-label-content { + // Vertical button toggles shouldn't be an inline-block, because the toggles should + // fill the available width in the group. + display: block; + } +} - &.cdk-keyboard-focused .mat-button-toggle-focus-overlay { - opacity: token-utils.slot(legacy-focus-state-layer-opacity); - } +.mat-button-toggle { + white-space: nowrap; + position: relative; + color: token-utils.slot(button-toggle-legacy-text-color, $fallbacks); + font-family: token-utils.slot(button-toggle-legacy-label-text-font, $fallbacks); + font-size: token-utils.slot(button-toggle-legacy-label-text-size, $fallbacks); + line-height: token-utils.slot(button-toggle-legacy-label-text-line-height, $fallbacks); + font-weight: token-utils.slot(button-toggle-legacy-label-text-weight, $fallbacks); + letter-spacing: token-utils.slot(button-toggle-legacy-label-text-tracking, $fallbacks); + + --mat-pseudo-checkbox-minimal-selected-checkmark-color: #{ + token-utils.slot(button-toggle-legacy-selected-state-text-color, $fallbacks)}; + + &.cdk-keyboard-focused .mat-button-toggle-focus-overlay { + opacity: token-utils.slot(button-toggle-legacy-focus-state-layer-opacity, $fallbacks); + } - // Fixes SVG icons that get thrown off because of the `vertical-align` on the parent. - .mat-icon svg { - vertical-align: top; - } + // Fixes SVG icons that get thrown off because of the `vertical-align` on the parent. + .mat-icon svg { + vertical-align: top; } +} - .mat-button-toggle-checkbox-wrapper { - display: inline-block; - justify-content: flex-start; - align-items: center; - width: 0; - height: $_checkmark-size; - line-height: $_checkmark-size; - overflow: hidden; - box-sizing: border-box; - position: absolute; - top: 50%; - left: $legacy-padding; - - // Uses a 3d transform, because otherwise Safari has some some of rendering - // artifact that adds a small gap between the two parts of the checkmark. - transform: translate3d(0, -50%, 0); - - [dir='rtl'] & { - left: auto; - right: $legacy-padding; - } +.mat-button-toggle-checkbox-wrapper { + display: inline-block; + justify-content: flex-start; + align-items: center; + width: 0; + height: $_checkmark-size; + line-height: $_checkmark-size; + overflow: hidden; + box-sizing: border-box; + position: absolute; + top: 50%; + left: $legacy-padding; + + // Uses a 3d transform, because otherwise Safari has some some of rendering + // artifact that adds a small gap between the two parts of the checkmark. + transform: translate3d(0, -50%, 0); + + [dir='rtl'] & { + left: auto; + right: $legacy-padding; + } - .mat-button-toggle-appearance-standard & { - left: $standard-padding; - } + .mat-button-toggle-appearance-standard & { + left: $standard-padding; + } - [dir='rtl'] .mat-button-toggle-appearance-standard & { - left: auto; - right: $standard-padding; - } + [dir='rtl'] .mat-button-toggle-appearance-standard & { + left: auto; + right: $standard-padding; + } - .mat-button-toggle-checked & { - width: $_checkmark-size; - } + .mat-button-toggle-checked & { + width: $_checkmark-size; + } - .mat-button-toggle-animations-enabled & { - transition: width $_checkmark-transition; - } + .mat-button-toggle-animations-enabled & { + transition: width $_checkmark-transition; + } - // Disable the transition in vertical mode since it looks weird. - // There should be a limited amount of usages anyway. - .mat-button-toggle-vertical & { - transition: none; - } + // Disable the transition in vertical mode since it looks weird. + // There should be a limited amount of usages anyway. + .mat-button-toggle-vertical & { + transition: none; } +} - .mat-button-toggle-checked { - color: token-utils.slot(legacy-selected-state-text-color); - background-color: token-utils.slot(legacy-selected-state-background-color); +.mat-button-toggle-checked { + color: token-utils.slot(button-toggle-legacy-selected-state-text-color, $fallbacks); + background-color: token-utils.slot( + button-toggle-legacy-selected-state-background-color, $fallbacks); +} + +.mat-button-toggle-disabled { + pointer-events: none; + color: token-utils.slot(button-toggle-legacy-disabled-state-text-color, $fallbacks); + background-color: token-utils.slot( + button-toggle-legacy-disabled-state-background-color, $fallbacks); + --mat-pseudo-checkbox-minimal-disabled-selected-checkmark-color: #{ + token-utils.slot(button-toggle-legacy-disabled-state-text-color, $fallbacks)}; + + &.mat-button-toggle-checked { + background-color: token-utils.slot( + button-toggle-legacy-disabled-selected-state-background-color, $fallbacks); } +} - .mat-button-toggle-disabled { - pointer-events: none; - color: token-utils.slot(legacy-disabled-state-text-color); - background-color: token-utils.slot(legacy-disabled-state-background-color); - --mat-pseudo-checkbox-minimal-disabled-selected-checkmark-color: #{ - token-utils.slot(legacy-disabled-state-text-color)}; +.mat-button-toggle-disabled-interactive { + pointer-events: auto; +} - &.mat-button-toggle-checked { - background-color: token-utils.slot(legacy-disabled-selected-state-background-color); - } +.mat-button-toggle-appearance-standard { + $divider-color: token-utils.slot(button-toggle-divider-color, $fallbacks); + color: token-utils.slot(button-toggle-text-color, $fallbacks); + background-color: token-utils.slot(button-toggle-background-color, $fallbacks); + font-family: token-utils.slot(button-toggle-label-text-font, $fallbacks); + font-size: token-utils.slot(button-toggle-label-text-size, $fallbacks); + line-height: token-utils.slot(button-toggle-label-text-line-height, $fallbacks); + font-weight: token-utils.slot(button-toggle-label-text-weight, $fallbacks); + letter-spacing: token-utils.slot(button-toggle-label-text-tracking, $fallbacks); + + .mat-button-toggle-group-appearance-standard & + & { + border-left: solid 1px $divider-color; } - .mat-button-toggle-disabled-interactive { - pointer-events: auto; + [dir='rtl'] .mat-button-toggle-group-appearance-standard & + & { + border-left: none; + border-right: solid 1px $divider-color; } - .mat-button-toggle-appearance-standard { - $divider-color: token-utils.slot(divider-color); - color: token-utils.slot(text-color); - background-color: token-utils.slot(background-color); - font-family: token-utils.slot(label-text-font); - font-size: token-utils.slot(label-text-size); - line-height: token-utils.slot(label-text-line-height); - font-weight: token-utils.slot(label-text-weight); - letter-spacing: token-utils.slot(label-text-tracking); + .mat-button-toggle-group-appearance-standard.mat-button-toggle-vertical & + & { + border-left: none; + border-right: none; + border-top: solid 1px $divider-color; + } - .mat-button-toggle-group-appearance-standard & + & { - border-left: solid 1px $divider-color; - } + &.mat-button-toggle-checked { + color: token-utils.slot(button-toggle-selected-state-text-color, $fallbacks); + background-color: token-utils.slot(button-toggle-selected-state-background-color, $fallbacks); + } - [dir='rtl'] .mat-button-toggle-group-appearance-standard & + & { - border-left: none; - border-right: solid 1px $divider-color; - } + &.mat-button-toggle-disabled { + color: token-utils.slot(button-toggle-disabled-state-text-color, $fallbacks); + background-color: token-utils.slot(button-toggle-disabled-state-background-color, $fallbacks); - .mat-button-toggle-group-appearance-standard.mat-button-toggle-vertical & + & { - border-left: none; - border-right: none; - border-top: solid 1px $divider-color; + .mat-pseudo-checkbox { + --mat-pseudo-checkbox-minimal-disabled-selected-checkmark-color: #{ + token-utils.slot(button-toggle-disabled-selected-state-text-color, $fallbacks)}; } &.mat-button-toggle-checked { - color: token-utils.slot(selected-state-text-color); - background-color: token-utils.slot(selected-state-background-color); + color: token-utils.slot(button-toggle-disabled-selected-state-text-color, $fallbacks); + background-color: token-utils.slot( + button-toggle-disabled-selected-state-background-color, $fallbacks); } + } - &.mat-button-toggle-disabled { - color: token-utils.slot(disabled-state-text-color); - background-color: token-utils.slot(disabled-state-background-color); - - .mat-pseudo-checkbox { - --mat-pseudo-checkbox-minimal-disabled-selected-checkmark-color: #{ - token-utils.slot(disabled-selected-state-text-color)}; - } + .mat-button-toggle-focus-overlay { + background-color: token-utils.slot(button-toggle-state-layer-color, $fallbacks); + } - &.mat-button-toggle-checked { - color: token-utils.slot(disabled-selected-state-text-color); - background-color: token-utils.slot(disabled-selected-state-background-color); - } - } + &:hover .mat-button-toggle-focus-overlay { + opacity: token-utils.slot(button-toggle-hover-state-layer-opacity, $fallbacks); + } - .mat-button-toggle-focus-overlay { - background-color: token-utils.slot(state-layer-color); - } + // Similar to components like the checkbox, slide-toggle and radio, we cannot show the focus + // overlay for `.cdk-program-focused` because mouse clicks on the `, - standalone: false, + imports: [MatChipGrid, MatChipRow, MatChipInput, MatFormField, MatLabel], }) class FormFieldChipGrid { chips = ['Chip 0', 'Chip 1', 'Chip 2']; @@ -1123,7 +1119,7 @@ class FormFieldChipGrid { (matChipInputTokenEnd)="add($event)"/> `, - standalone: false, + imports: [MatChipGrid, MatChipRow, MatChipInput, MatFormField, MatLabel, ReactiveFormsModule], }) class InputChipGrid { foods: any[] = [ @@ -1170,20 +1166,30 @@ class InputChipGrid { @Component({ template: ` -
    - - - @for (food of foods; track food) { - {{food.viewValue}} - } - - - Please select a chip, or type to add a new chip - Should have value - -
    +
    + + + @for (food of foods; track food) { + {{food.viewValue}} + } + + + Please select a chip, or type to add a new chip + Should have value + +
    `, - standalone: false, + imports: [ + MatChipGrid, + MatChipRow, + MatChipInput, + MatFormField, + MatHint, + MatError, + MatLabel, + ReactiveFormsModule, + FormsModule, + ], }) class ChipGridWithFormErrorMessages { foods: any[] = [ @@ -1219,7 +1225,7 @@ class ChipGridWithFormErrorMessages { `, - standalone: false, + imports: [MatChipGrid, MatChipRow, MatChipInput, MatFormField, MatChipRemove], }) class ChipGridWithRemove { chips = [0, 1, 2, 3, 4]; diff --git a/src/material/chips/chip-icons.ts b/src/material/chips/chip-icons.ts index 46ab0f2dfe21..bc0a71b9d436 100644 --- a/src/material/chips/chip-icons.ts +++ b/src/material/chips/chip-icons.ts @@ -9,7 +9,7 @@ import {ENTER, SPACE} from '@angular/cdk/keycodes'; import {Directive} from '@angular/core'; import {MatChipAction} from './chip-action'; -import {MAT_CHIP_AVATAR, MAT_CHIP_REMOVE, MAT_CHIP_TRAILING_ICON} from './tokens'; +import {MAT_CHIP_AVATAR, MAT_CHIP_EDIT, MAT_CHIP_REMOVE, MAT_CHIP_TRAILING_ICON} from './tokens'; /** Avatar image within a chip. */ @Directive({ @@ -42,6 +42,56 @@ export class MatChipTrailingIcon extends MatChipAction { override _isPrimary = false; } +/** + * Directive to edit the parent chip when the leading action icon is clicked or + * when the ENTER key is pressed on it. + * + * Recommended for use with the Material Design "edit" icon + * available at https://material.io/icons/#ic_edit. + * + * Example: + * + * ``` + * + * + * + * ``` + */ + +@Directive({ + selector: '[matChipEdit]', + host: { + 'class': + 'mat-mdc-chip-edit mat-mdc-chip-avatar mat-focus-indicator ' + + 'mdc-evolution-chip__icon mdc-evolution-chip__icon--primary', + 'role': 'button', + '[attr.aria-hidden]': 'null', + }, + providers: [{provide: MAT_CHIP_EDIT, useExisting: MatChipEdit}], +}) +export class MatChipEdit extends MatChipAction { + override _isPrimary = false; + override _isLeading = true; + + override _handleClick(event: MouseEvent): void { + if (!this.disabled) { + event.stopPropagation(); + event.preventDefault(); + this._parentChip._edit(); + } + } + + override _handleKeydown(event: KeyboardEvent) { + if ((event.keyCode === ENTER || event.keyCode === SPACE) && !this.disabled) { + event.stopPropagation(); + event.preventDefault(); + this._parentChip._edit(); + } + } +} + /** * Directive to remove the parent chip when the trailing icon is clicked or * when the ENTER key is pressed on it. diff --git a/src/material/chips/chip-input.spec.ts b/src/material/chips/chip-input.spec.ts index efc62a96fd40..7bc043c2e321 100644 --- a/src/material/chips/chip-input.spec.ts +++ b/src/material/chips/chip-input.spec.ts @@ -1,25 +1,23 @@ -import {Directionality} from '@angular/cdk/bidi'; import {COMMA, ENTER, TAB} from '@angular/cdk/keycodes'; -import {PlatformModule} from '@angular/cdk/platform'; import { createKeyboardEvent, - dispatchKeyboardEvent, dispatchEvent, + dispatchKeyboardEvent, + provideFakeDirectionality, } from '@angular/cdk/testing/private'; import {Component, DebugElement, ViewChild} from '@angular/core'; -import {ComponentFixture, TestBed, fakeAsync, flush, waitForAsync} from '@angular/core/testing'; -import {MatFormFieldModule} from '../form-field'; +import {ComponentFixture, fakeAsync, flush, TestBed, waitForAsync} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; -import {Subject} from 'rxjs'; +import {MATERIAL_ANIMATIONS} from '../core'; +import {MatFormField} from '../form-field'; import { MAT_CHIPS_DEFAULT_OPTIONS, MatChipGrid, MatChipInput, MatChipInputEvent, + MatChipRow, MatChipsDefaultOptions, - MatChipsModule, } from './index'; -import {MATERIAL_ANIMATIONS} from '../core'; describe('MatChipInput', () => { let fixture: ComponentFixture; @@ -27,24 +25,13 @@ describe('MatChipInput', () => { let inputDebugElement: DebugElement; let inputNativeElement: HTMLInputElement; let chipInputDirective: MatChipInput; - let dir = 'ltr'; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [PlatformModule, MatChipsModule, MatFormFieldModule], providers: [ - { - provide: Directionality, - useFactory: () => { - return { - value: dir.toLowerCase(), - change: new Subject(), - }; - }, - }, + provideFakeDirectionality('ltr'), {provide: MATERIAL_ANIMATIONS, useValue: {animationsDisabled: true}}, ], - declarations: [TestChipInput], }); })); @@ -249,8 +236,6 @@ describe('MatChipInput', () => { fixture.destroy(); TestBed.resetTestingModule().configureTestingModule({ - imports: [MatChipsModule, MatFormFieldModule, PlatformModule], - declarations: [TestChipInput], providers: [ { provide: MAT_CHIPS_DEFAULT_OPTIONS, @@ -335,7 +320,7 @@ describe('MatChipInput', () => { `, - standalone: false, + imports: [MatFormField, MatChipGrid, MatChipRow, MatChipInput], }) class TestChipInput { @ViewChild(MatChipGrid) chipGridInstance: MatChipGrid; diff --git a/src/material/chips/chip-listbox.spec.ts b/src/material/chips/chip-listbox.spec.ts index 6a0f4d610fbd..89b7d9172198 100644 --- a/src/material/chips/chip-listbox.spec.ts +++ b/src/material/chips/chip-listbox.spec.ts @@ -1,25 +1,28 @@ -import {Direction, Directionality} from '@angular/cdk/bidi'; +import {Direction} from '@angular/cdk/bidi'; import {END, HOME, LEFT_ARROW, RIGHT_ARROW, SPACE, TAB} from '@angular/cdk/keycodes'; import { dispatchFakeEvent, dispatchKeyboardEvent, patchElementFocus, + provideFakeDirectionality, } from '@angular/cdk/testing/private'; import { Component, DebugElement, - EventEmitter, QueryList, + signal, Type, ViewChild, ViewChildren, + WritableSignal, } from '@angular/core'; -import {ComponentFixture, TestBed, fakeAsync, flush, tick} from '@angular/core/testing'; +import {AsyncPipe} from '@angular/common'; +import {ComponentFixture, fakeAsync, flush, TestBed, tick} from '@angular/core/testing'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {By} from '@angular/platform-browser'; -import {MatChipListbox, MatChipOption, MatChipsModule} from './index'; import {asyncScheduler, BehaviorSubject, Observable} from 'rxjs'; import {observeOn} from 'rxjs/operators'; +import {MatChipListbox, MatChipOption} from './index'; describe('MatChipListbox', () => { let fixture: ComponentFixture; @@ -28,7 +31,7 @@ describe('MatChipListbox', () => { let chipListboxInstance: MatChipListbox; let testComponent: StandardChipListbox; let chips: QueryList; - let directionality: {value: Direction; change: EventEmitter}; + let directionality: WritableSignal; let primaryActions: NodeListOf; describe('StandardChipList', () => { @@ -424,8 +427,7 @@ describe('MatChipListbox', () => { expect(document.activeElement).toBe(primaryActions[1]); - directionality.value = 'rtl'; - directionality.change.next('rtl'); + directionality.set('rtl'); fixture.detectChanges(); dispatchKeyboardEvent(primaryActions[1], 'keydown', RIGHT_ARROW); @@ -926,15 +928,10 @@ describe('MatChipListbox', () => { direction: Direction = 'ltr', beforeInitialChangeDetection?: (fixture: ComponentFixture) => void, ): ComponentFixture { - directionality = { - value: direction, - change: new EventEmitter(), - }; + directionality = signal(direction); TestBed.configureTestingModule({ - imports: [FormsModule, ReactiveFormsModule, MatChipsModule], - providers: [{provide: Directionality, useValue: directionality}], - declarations: [component], + providers: [provideFakeDirectionality(directionality)], }); fixture = TestBed.createComponent(component); @@ -963,7 +960,7 @@ describe('MatChipListbox', () => { } `, - standalone: false, + imports: [MatChipListbox, MatChipOption], }) class StandardChipListbox { name: string = 'Test'; @@ -977,16 +974,16 @@ class StandardChipListbox { @Component({ template: ` - - @for (food of foods; track food) { - - {{ food.viewValue }} - - } - + + @for (food of foods; track food) { + + {{ food.viewValue }} + + } + `, - standalone: false, + imports: [MatChipListbox, MatChipOption, ReactiveFormsModule], }) class BasicChipListbox { foods: any[] = [ @@ -1010,17 +1007,17 @@ class BasicChipListbox { @Component({ template: ` - - @for (food of foods; track food) { - - {{ food.viewValue }} - - } - + + @for (food of foods; track food) { + + {{ food.viewValue }} + + } + `, - standalone: false, + imports: [MatChipListbox, MatChipOption, ReactiveFormsModule], }) class MultiSelectionChipListbox { foods: any[] = [ @@ -1044,13 +1041,13 @@ class MultiSelectionChipListbox { @Component({ template: ` - - - {{ chip }} - - + + @for (chip of chips$ | async; track $index) { + {{ chip }} + } + `, - standalone: false, + imports: [MatChipListbox, MatChipOption, AsyncPipe, ReactiveFormsModule], }) class AsyncMultiSelectionChipListbox { private _chipsSubject = new BehaviorSubject(['tutorial-1', 'tutorial-2', 'tutorial-3']); @@ -1066,13 +1063,13 @@ class AsyncMultiSelectionChipListbox { @Component({ template: ` - - @for (food of foods; track food) { - {{ food.viewValue }} - } - + + @for (food of foods; track food) { + {{ food.viewValue }} + } + `, - standalone: false, + imports: [MatChipListbox, MatChipOption, ReactiveFormsModule], }) class FalsyValueChipListbox { foods: any[] = [ @@ -1093,7 +1090,7 @@ class FalsyValueChipListbox { } `, - standalone: false, + imports: [MatChipListbox, MatChipOption], }) class SelectedChipListbox { foods: any[] = [ @@ -1106,16 +1103,16 @@ class SelectedChipListbox { @Component({ template: ` - - @for (food of foods; track food) { - - {{ food.viewValue }} - - } - + + @for (food of foods; track food) { + + {{ food.viewValue }} + + } + `, - standalone: false, + imports: [MatChipListbox, MatChipOption, ReactiveFormsModule], }) class FalsyBasicChipListbox { foods: any[] = [ @@ -1146,7 +1143,7 @@ class FalsyBasicChipListbox { `, - standalone: false, + imports: [MatChipListbox, MatChipOption, FormsModule], }) class IndividuallyDisabledChipInsideForm { @ViewChild(MatChipOption) chip: MatChipOption; diff --git a/src/material/chips/chip-option.spec.ts b/src/material/chips/chip-option.spec.ts index 582482acff81..70338f81d215 100644 --- a/src/material/chips/chip-option.spec.ts +++ b/src/material/chips/chip-option.spec.ts @@ -1,19 +1,21 @@ -import {Directionality} from '@angular/cdk/bidi'; import {ENTER, SPACE} from '@angular/cdk/keycodes'; -import {dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing/private'; +import { + dispatchFakeEvent, + dispatchKeyboardEvent, + provideFakeDirectionality, +} from '@angular/cdk/testing/private'; import {Component, DebugElement, ViewChild} from '@angular/core'; import {ComponentFixture, TestBed, fakeAsync, flush, waitForAsync} from '@angular/core/testing'; -import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '../core'; import {By} from '@angular/platform-browser'; -import {Subject} from 'rxjs'; +import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '../core'; import { MAT_CHIPS_DEFAULT_OPTIONS, + MatChipAvatar, MatChipEvent, MatChipListbox, MatChipOption, MatChipSelectionChange, MatChipsDefaultOptions, - MatChipsModule, } from './index'; describe('Option Chips', () => { @@ -23,7 +25,6 @@ describe('Option Chips', () => { let primaryAction: HTMLElement; let chipInstance: MatChipOption; let globalRippleOptions: RippleGlobalOptions; - let dir = 'ltr'; let hideSingleSelectionIndicator: boolean | undefined; @@ -35,19 +36,11 @@ describe('Option Chips', () => { }; TestBed.configureTestingModule({ - imports: [MatChipsModule], providers: [ {provide: MAT_RIPPLE_GLOBAL_OPTIONS, useFactory: () => globalRippleOptions}, - { - provide: Directionality, - useFactory: () => ({ - value: dir, - change: new Subject(), - }), - }, + provideFakeDirectionality('ltr'), {provide: MAT_CHIPS_DEFAULT_OPTIONS, useFactory: () => defaultOptions}, ], - declarations: [SingleChip], }); })); @@ -424,7 +417,7 @@ describe('Option Chips', () => {
    } `, - standalone: false, + imports: [MatChipListbox, MatChipOption, MatChipAvatar], }) class SingleChip { @ViewChild(MatChipListbox) chipList: MatChipListbox; diff --git a/src/material/chips/chip-row.html b/src/material/chips/chip-row.html index f2f5b0af5190..47f53c59b303 100644 --- a/src/material/chips/chip-row.html +++ b/src/material/chips/chip-row.html @@ -2,6 +2,11 @@ } +@if (_hasLeadingActionIcon()) { + + + +} { let chipNativeElement: HTMLElement; let chipInstance: MatChipRow; - let dir = 'ltr'; - beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [MatChipsModule, SingleChip], - providers: [ - { - provide: Directionality, - useFactory: () => ({ - value: dir, - change: new Subject(), - }), - }, - ], + providers: [provideFakeDirectionality('ltr')], }); })); @@ -245,6 +235,90 @@ describe('Row Chips', () => { fixture.detectChanges(); expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeTruthy(); }); + + it('should not begin editing on single click', () => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + dispatchMouseEvent(chipNativeElement, 'click'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + }); + + it('should begin editing on single click when focused', fakeAsync(() => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + chipNativeElement.focus(); + + // Need to also simulate the mousedown as that sets the already focused flag. + dispatchMouseEvent(chipNativeElement, 'mousedown'); + dispatchMouseEvent(chipNativeElement, 'click'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeTruthy(); + })); + + describe('when disabled', () => { + beforeEach(() => { + testComponent.disabled = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + }); + + it('should not begin editing on double click', () => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + dispatchFakeEvent(chipNativeElement, 'dblclick'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + }); + + it('should not begin editing on ENTER', () => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + dispatchKeyboardEvent(chipNativeElement, 'keydown', ENTER); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + }); + + it('should not begin editing on single click when focused', fakeAsync(() => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + chipNativeElement.focus(); + + // Need to also simulate the mousedown as that sets the already focused flag. + dispatchMouseEvent(chipNativeElement, 'mousedown'); + dispatchMouseEvent(chipNativeElement, 'click'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + })); + }); + + describe('when not editable', () => { + beforeEach(() => { + testComponent.editable = false; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + }); + + it('should not begin editing on double click', () => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + dispatchFakeEvent(chipNativeElement, 'dblclick'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + }); + + it('should not begin editing on ENTER', () => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + dispatchKeyboardEvent(chipNativeElement, 'keydown', ENTER); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + }); + + it('should not begin editing on single click when focused', fakeAsync(() => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + chipNativeElement.focus(); + + // Need to also simulate the mousedown as that sets the already focused flag. + dispatchMouseEvent(chipNativeElement, 'mousedown'); + dispatchMouseEvent(chipNativeElement, 'click'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + })); + }); }); describe('editing behavior', () => { @@ -362,6 +436,21 @@ describe('Row Chips', () => { })); }); + describe('with edit icon', () => { + beforeEach(async () => { + testComponent.showEditIcon = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + }); + + it('should begin editing on edit click', () => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + dispatchFakeEvent(chipNativeElement.querySelector('.mat-mdc-chip-edit')!, 'click'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeTruthy(); + }); + }); + describe('a11y', () => { it('should apply `ariaLabel` and `ariaDesciption` to the primary gridcell', () => { fixture.componentInstance.ariaLabel = 'chip name'; @@ -414,6 +503,9 @@ describe('Row Chips', () => { (destroyed)="chipDestroy($event)" (removed)="chipRemove($event)" (edited)="chipEdit($event)" [aria-label]="ariaLabel" [aria-description]="ariaDescription"> + @if (showEditIcon) { + + } {{name}} @if (useCustomEditInput) { @@ -435,6 +527,7 @@ class SingleChip { removable: boolean = true; shouldShow: boolean = true; editable: boolean = false; + showEditIcon: boolean = false; useCustomEditInput: boolean = true; ariaLabel: string | null = null; ariaDescription: string | null = null; diff --git a/src/material/chips/chip-row.ts b/src/material/chips/chip-row.ts index 8325255a5d41..f7db3d950ff2 100644 --- a/src/material/chips/chip-row.ts +++ b/src/material/chips/chip-row.ts @@ -46,6 +46,7 @@ export interface MatChipEditedEvent extends MatChipEvent { '[class.mat-mdc-chip-editing]': '_isEditing', '[class.mat-mdc-chip-editable]': 'editable', '[class.mdc-evolution-chip--disabled]': 'disabled', + '[class.mdc-evolution-chip--with-leading-action]': '_hasLeadingActionIcon()', '[class.mdc-evolution-chip--with-trailing-action]': '_hasTrailingIcon()', '[class.mdc-evolution-chip--with-primary-graphic]': 'leadingIcon', '[class.mdc-evolution-chip--with-primary-icon]': 'leadingIcon', @@ -60,6 +61,7 @@ export interface MatChipEditedEvent extends MatChipEvent { '[attr.aria-description]': 'null', '[attr.role]': 'role', '(focus)': '_handleFocus()', + '(click)': '_handleClick($event)', '(dblclick)': '_handleDoubleclick($event)', }, providers: [ @@ -92,6 +94,15 @@ export class MatChipRow extends MatChip implements AfterViewInit { /** The projected chip edit input. */ @ContentChild(MatChipEditInput) contentEditInput?: MatChipEditInput; + /** + * Set on a mousedown when the chip is already focused via mouse or keyboard. + * + * This allows us to ensure chip is already focused when deciding whether to enter the + * edit mode on a subsequent click. Otherwise, the chip appears focused when handling the + * first click event. + */ + private _alreadyFocused = false; + _isEditing = false; constructor(...args: unknown[]); @@ -104,9 +115,27 @@ export class MatChipRow extends MatChip implements AfterViewInit { if (this._isEditing && !this._editStartPending) { this._onEditFinish(); } + this._alreadyFocused = false; }); } + override ngAfterViewInit() { + super.ngAfterViewInit(); + + // Sets _alreadyFocused (ahead of click) when chip already has focus. + this._ngZone.runOutsideAngular(() => { + this._elementRef.nativeElement.addEventListener( + 'mousedown', + () => (this._alreadyFocused = this._hasFocus()), + ); + }); + } + + protected _hasLeadingActionIcon() { + // The leading action (edit) icon is hidden while editing. + return !this._isEditing && !!this.editIcon; + } + override _hasTrailingIcon() { // The trailing icon is hidden while editing. return !this._isEditing && super._hasTrailingIcon(); @@ -135,16 +164,34 @@ export class MatChipRow extends MatChip implements AfterViewInit { } } + _handleClick(event: MouseEvent) { + if (!this.disabled && this.editable && !this._isEditing && this._alreadyFocused) { + // Ensure click event not picked up unintentionally by other listeners, as + // once editing starts, the source element is detached from DOM. + event.preventDefault(); + event.stopPropagation(); + this._startEditing(event); + } + } + _handleDoubleclick(event: MouseEvent) { if (!this.disabled && this.editable) { this._startEditing(event); } } - private _startEditing(event: Event) { + override _edit(): void { + // markForCheck necessary for edit input to be rendered + this._changeDetectorRef.markForCheck(); + this._startEditing(); + } + + private _startEditing(event?: Event) { if ( !this.primaryAction || - (this.removeIcon && this._getSourceAction(event.target as Node) === this.removeIcon) + (this.removeIcon && + !!event && + this._getSourceAction(event.target as Node) === this.removeIcon) ) { return; } @@ -158,7 +205,9 @@ export class MatChipRow extends MatChip implements AfterViewInit { afterNextRender( () => { this._getEditInput().initialize(value); - this._editStartPending = false; + + // Necessary when using edit icon to prevent edit from aborting + setTimeout(() => this._ngZone.run(() => (this._editStartPending = false))); }, {injector: this._injector}, ); diff --git a/src/material/chips/chip.scss b/src/material/chips/chip.scss index d29ac049f32d..27b9640c9dd1 100644 --- a/src/material/chips/chip.scss +++ b/src/material/chips/chip.scss @@ -3,7 +3,7 @@ @use '../core/style/layout-common'; @use '../core/style/vendor-prefixes'; @use '../core/tokens/token-utils'; -@use './m2-chip'; +@use './m3-chip'; $_checkmark-size: 20px; $_trailing-icon-size: 18px; @@ -13,8 +13,7 @@ $_trailing-action-padding: 8px; $_avatar-leading-padding: 4px; $_avatar-trailing-padding: 8px; -$token-prefix: m2-chip.$prefix; -$token-slots: m2-chip.get-token-slots(); +$fallbacks: m3-chip.get-tokens(); .mdc-evolution-chip, .mdc-evolution-chip__cell, @@ -77,52 +76,50 @@ $token-slots: m2-chip.get-token-slots(); white-space: inherit; overflow-x: hidden; - @include token-utils.use-tokens($token-prefix, $token-slots) { - .mat-mdc-standard-chip &::before { - border-width: token-utils.slot(outline-width); - border-radius: token-utils.slot(container-shape-radius); - box-sizing: border-box; - content: ''; - height: 100%; - left: 0; - position: absolute; - pointer-events: none; - top: 0; - width: 100%; - z-index: 1; - border-style: solid; - } + .mat-mdc-standard-chip &::before { + border-width: token-utils.slot(chip-outline-width, $fallbacks); + border-radius: token-utils.slot(chip-container-shape-radius, $fallbacks); + box-sizing: border-box; + content: ''; + height: 100%; + left: 0; + position: absolute; + pointer-events: none; + top: 0; + width: 100%; + z-index: 1; + border-style: solid; + } - .mat-mdc-standard-chip & { - padding-left: $_action-padding; - padding-right: $_action-padding; - } + .mat-mdc-standard-chip & { + padding-left: $_action-padding; + padding-right: $_action-padding; + } - .mat-mdc-standard-chip.mdc-evolution-chip--with-primary-graphic & { - padding-left: 0; - padding-right: $_action-padding; - } + .mat-mdc-standard-chip.mdc-evolution-chip--with-primary-graphic & { + padding-left: 0; + padding-right: $_action-padding; + } - [dir='rtl'] .mat-mdc-standard-chip.mdc-evolution-chip--with-primary-graphic & { - padding-left: $_action-padding; - padding-right: 0; - } + [dir='rtl'] .mat-mdc-standard-chip.mdc-evolution-chip--with-primary-graphic & { + padding-left: $_action-padding; + padding-right: 0; + } - .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) &::before { - border-color: token-utils.slot(outline-color); - } + .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) &::before { + border-color: token-utils.slot(chip-outline-color, $fallbacks); + } - &:not(.mdc-evolution-chip__action--presentational):not(.mdc-ripple-upgraded):focus::before { - border-color: token-utils.slot(focus-outline-color); - } + &:not(.mdc-evolution-chip__action--presentational):not(.mdc-ripple-upgraded):focus::before { + border-color: token-utils.slot(chip-focus-outline-color, $fallbacks); + } - .mat-mdc-standard-chip.mdc-evolution-chip--disabled &::before { - border-color: token-utils.slot(disabled-outline-color); - } + .mat-mdc-standard-chip.mdc-evolution-chip--disabled &::before { + border-color: token-utils.slot(chip-disabled-outline-color, $fallbacks); + } - .mat-mdc-standard-chip.mdc-evolution-chip--selected &::before { - border-width: token-utils.slot(flat-selected-outline-width); - } + .mat-mdc-standard-chip.mdc-evolution-chip--selected &::before { + border-width: token-utils.slot(chip-flat-selected-outline-width, $fallbacks); } // Keeps basic listbox chips looking consistent with the other variations. Listbox chips don't @@ -133,8 +130,19 @@ $token-slots: m2-chip.get-token-slots(); // Moved out into variables, because the selectors are too long. $with-graphic: '.mdc-evolution-chip--with-primary-graphic'; + $with-leading: '.mdc-evolution-chip--with-leading-action'; $with-trailing: '.mdc-evolution-chip--with-trailing-action'; + .mat-mdc-standard-chip#{$with-leading} & { + padding-left: 0; + padding-right: $_action-padding; + } + + [dir='rtl'] .mat-mdc-standard-chip#{$with-leading} & { + padding-left: $_action-padding; + padding-right: 0; + } + .mat-mdc-standard-chip#{$with-trailing} & { padding-left: $_action-padding; padding-right: 0; @@ -145,6 +153,11 @@ $token-slots: m2-chip.get-token-slots(); padding-right: $_action-padding; } + .mat-mdc-standard-chip#{$with-leading}#{$with-trailing} & { + padding-left: 0; + padding-right: 0; + } + .mat-mdc-standard-chip#{$with-graphic}#{$with-trailing} & { padding-left: 0; padding-right: 0; @@ -176,18 +189,16 @@ $token-slots: m2-chip.get-token-slots(); } } -.mdc-evolution-chip__action--trailing { +.mdc-evolution-chip__action--secondary { position: relative; overflow: visible; - @include token-utils.use-tokens($token-prefix, $token-slots) { - .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) & { - color: token-utils.slot(with-trailing-icon-trailing-icon-color); - } + .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) & { + color: token-utils.slot(chip-with-trailing-icon-trailing-icon-color, $fallbacks); + } - .mat-mdc-standard-chip.mdc-evolution-chip--disabled & { - color: token-utils.slot(with-trailing-icon-disabled-trailing-icon-color); - } + .mat-mdc-standard-chip.mdc-evolution-chip--disabled & { + color: token-utils.slot(chip-with-trailing-icon-disabled-trailing-icon-color, $fallbacks); } // Moved out into variables, because the selectors are too long. @@ -204,7 +215,6 @@ $token-slots: m2-chip.get-token-slots(); padding-right: $_trailing-action-padding; } - .mdc-evolution-chip--with-avatar#{$with-graphic}#{$with-trailing} & { padding-left: $_avatar-trailing-padding; padding-right: $_avatar-trailing-padding; @@ -223,27 +233,25 @@ $token-slots: m2-chip.get-token-slots(); text-overflow: ellipsis; overflow: hidden; - @include token-utils.use-tokens($token-prefix, $token-slots) { - .mat-mdc-standard-chip & { - font-family: token-utils.slot(label-text-font); - line-height: token-utils.slot(label-text-line-height); - font-size: token-utils.slot(label-text-size); - font-weight: token-utils.slot(label-text-weight); - letter-spacing: token-utils.slot(label-text-tracking); - } + .mat-mdc-standard-chip & { + font-family: token-utils.slot(chip-label-text-font, $fallbacks); + line-height: token-utils.slot(chip-label-text-line-height, $fallbacks); + font-size: token-utils.slot(chip-label-text-size, $fallbacks); + font-weight: token-utils.slot(chip-label-text-weight, $fallbacks); + letter-spacing: token-utils.slot(chip-label-text-tracking, $fallbacks); + } - .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) & { - color: token-utils.slot(label-text-color); - } + .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) & { + color: token-utils.slot(chip-label-text-color, $fallbacks); + } - .mat-mdc-standard-chip.mdc-evolution-chip--selected:not(.mdc-evolution-chip--disabled) & { - color: token-utils.slot(selected-label-text-color); - } + .mat-mdc-standard-chip.mdc-evolution-chip--selected:not(.mdc-evolution-chip--disabled) & { + color: token-utils.slot(chip-selected-label-text-color, $fallbacks); + } - .mat-mdc-standard-chip.mdc-evolution-chip--disabled &, - .mat-mdc-standard-chip.mdc-evolution-chip--selected.mdc-evolution-chip--disabled & { - color: token-utils.slot(disabled-label-text-color); - } + .mat-mdc-standard-chip.mdc-evolution-chip--disabled &, + .mat-mdc-standard-chip.mdc-evolution-chip--selected.mdc-evolution-chip--disabled & { + color: token-utils.slot(chip-disabled-label-text-color, $fallbacks); } } @@ -256,12 +264,10 @@ $token-slots: m2-chip.get-token-slots(); position: relative; flex: 1 0 auto; - @include token-utils.use-tokens($token-prefix, $token-slots) { - .mat-mdc-standard-chip & { - width: token-utils.slot(with-avatar-avatar-size); - height: token-utils.slot(with-avatar-avatar-size); - font-size: token-utils.slot(with-avatar-avatar-size); - } + .mat-mdc-standard-chip & { + width: token-utils.slot(chip-with-avatar-avatar-size, $fallbacks); + height: token-utils.slot(chip-with-avatar-avatar-size, $fallbacks); + font-size: token-utils.slot(chip-with-avatar-avatar-size, $fallbacks); } .mdc-evolution-chip--selecting & { @@ -271,6 +277,7 @@ $token-slots: m2-chip.get-token-slots(); // Moved out into variables, because the selectors are too long. $with-icon: '.mdc-evolution-chip--with-primary-icon'; $with-graphic: '.mdc-evolution-chip--with-primary-graphic'; + $with-leading: '.mdc-evolution-chip--with-leading-action'; $with-trailing: '.mdc-evolution-chip--with-trailing-action'; .mdc-evolution-chip--selectable:not(.mdc-evolution-chip--selected):not(#{$with-icon}) & { @@ -306,6 +313,10 @@ $token-slots: m2-chip.get-token-slots(); padding-left: $_avatar-trailing-padding; padding-right: $_avatar-leading-padding; } + + .mdc-evolution-chip--with-avatar#{$with-graphic}#{$with-leading} & { + padding-left: 0; + } } .mdc-evolution-chip__checkmark { @@ -316,14 +327,12 @@ $token-slots: m2-chip.get-token-slots(); height: $_checkmark-size; width: $_checkmark-size; - @include token-utils.use-tokens($token-prefix, $token-slots) { - .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) & { - color: token-utils.slot(with-icon-selected-icon-color); - } + .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) & { + color: token-utils.slot(chip-with-icon-selected-icon-color, $fallbacks); + } - .mat-mdc-standard-chip.mdc-evolution-chip--disabled & { - color: token-utils.slot(with-icon-disabled-icon-color); - } + .mat-mdc-standard-chip.mdc-evolution-chip--disabled & { + color: token-utils.slot(chip-with-icon-disabled-icon-color, $fallbacks); } .mdc-evolution-chip--selecting & { @@ -371,47 +380,40 @@ $token-slots: m2-chip.get-token-slots(); font-size: $_trailing-icon-size; } - $disabled-icon-opacity: null; - @include token-utils.use-tokens($token-prefix, $token-slots) { - $disabled-icon-opacity: - token-utils.slot(with-trailing-icon-disabled-trailing-icon-opacity); - } + $disabled-icon-opacity: + token-utils.slot(chip-with-trailing-icon-disabled-trailing-icon-opacity, $fallbacks); // If the trailing icon is a chip-remove button, we have to factor in the trailing action // opacity as well as the disabled opacity. .mdc-evolution-chip--disabled &.mat-mdc-chip-remove { - @include token-utils.use-tokens($token-prefix, $token-slots) { - $action-opacity: token-utils.slot(trailing-action-opacity); - opacity: calc(#{$action-opacity} * #{$disabled-icon-opacity}); - - &:focus { - $action-focus-opacity: token-utils.slot(trailing-action-focus-opacity); - opacity: calc(#{$action-focus-opacity} * #{$disabled-icon-opacity}); - } + $action-opacity: token-utils.slot(chip-trailing-action-opacity, $fallbacks); + opacity: calc(#{$action-opacity} * #{$disabled-icon-opacity}); + + &:focus { + $action-focus-opacity: token-utils.slot(chip-trailing-action-focus-opacity, $fallbacks); + opacity: calc(#{$action-focus-opacity} * #{$disabled-icon-opacity}); } } } .mat-mdc-standard-chip { - @include token-utils.use-tokens($token-prefix, $token-slots) { - border-radius: token-utils.slot(container-shape-radius); - height: token-utils.slot(container-height); + border-radius: token-utils.slot(chip-container-shape-radius, $fallbacks); + height: token-utils.slot(chip-container-height, $fallbacks); - &:not(.mdc-evolution-chip--disabled) { - background-color: token-utils.slot(elevated-container-color); - } + &:not(.mdc-evolution-chip--disabled) { + background-color: token-utils.slot(chip-elevated-container-color, $fallbacks); + } - &.mdc-evolution-chip--disabled { - background-color: token-utils.slot(elevated-disabled-container-color); - } + &.mdc-evolution-chip--disabled { + background-color: token-utils.slot(chip-elevated-disabled-container-color, $fallbacks); + } - &.mdc-evolution-chip--selected:not(.mdc-evolution-chip--disabled) { - background-color: token-utils.slot(elevated-selected-container-color); - } + &.mdc-evolution-chip--selected:not(.mdc-evolution-chip--disabled) { + background-color: token-utils.slot(chip-elevated-selected-container-color, $fallbacks); + } - &.mdc-evolution-chip--selected.mdc-evolution-chip--disabled { - background-color: token-utils.slot(flat-disabled-selected-container-color); - } + &.mdc-evolution-chip--selected.mdc-evolution-chip--disabled { + background-color: token-utils.slot(chip-flat-disabled-selected-container-color, $fallbacks); } @include cdk.high-contrast { @@ -420,25 +422,23 @@ $token-slots: m2-chip.get-token-slots(); } .mdc-evolution-chip__icon--primary { - @include token-utils.use-tokens($token-prefix, $token-slots) { - .mat-mdc-standard-chip & { - border-radius: token-utils.slot(with-avatar-avatar-shape-radius); - width: token-utils.slot(with-icon-icon-size); - height: token-utils.slot(with-icon-icon-size); - font-size: token-utils.slot(with-icon-icon-size); - } + .mat-mdc-standard-chip & { + border-radius: token-utils.slot(chip-with-avatar-avatar-shape-radius, $fallbacks); + width: token-utils.slot(chip-with-icon-icon-size, $fallbacks); + height: token-utils.slot(chip-with-icon-icon-size, $fallbacks); + font-size: token-utils.slot(chip-with-icon-icon-size, $fallbacks); + } - .mdc-evolution-chip--selected & { - opacity: 0; - } + .mdc-evolution-chip--selected & { + opacity: 0; + } - .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) & { - color: token-utils.slot(with-icon-icon-color); - } + .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) & { + color: token-utils.slot(chip-with-icon-icon-color, $fallbacks); + } - .mat-mdc-standard-chip.mdc-evolution-chip--disabled & { - color: token-utils.slot(with-icon-disabled-icon-color); - } + .mat-mdc-standard-chip.mdc-evolution-chip--disabled & { + color: token-utils.slot(chip-with-icon-disabled-icon-color, $fallbacks); } } @@ -448,110 +448,100 @@ $token-slots: m2-chip.get-token-slots(); // that we don't re-implement ourselves below. // TODO(crisbeto): with some future refactors we may be able to clean this up. .mat-mdc-chip-highlighted { - @include token-utils.use-tokens($token-prefix, $token-slots) { - $highlighted-remapped-tokens: ( - with-icon-icon-color: with-icon-selected-icon-color, - elevated-container-color: elevated-selected-container-color, - label-text-color: selected-label-text-color, - outline-width: flat-selected-outline-width, - ); - - @each $selected, $base in $highlighted-remapped-tokens { - #{token-utils.get-token-variable-name($selected)}: token-utils.slot($base); - } - } + --mat-chip-with-icon-icon-color: #{token-utils.slot(chip-with-icon-selected-icon-color, + $fallbacks)}; + --mat-chip-elevated-container-color: #{token-utils.slot(chip-elevated-selected-container-color, + $fallbacks)}; + --mat-chip-label-text-color: #{token-utils.slot(chip-selected-label-text-color, $fallbacks)}; + --mat-chip-outline-width: #{token-utils.slot(chip-flat-selected-outline-width, $fallbacks)}; } // Add additional slots for the MDC chip tokens, needed in Angular Material. -@include token-utils.use-tokens($token-prefix, $token-slots) { - .mat-mdc-chip-focus-overlay { - background: token-utils.slot(focus-state-layer-color); - - .mat-mdc-chip-selected &, - .mat-mdc-chip-highlighted & { - background: token-utils.slot(selected-focus-state-layer-color); - } - - .mat-mdc-chip:hover & { - background: token-utils.slot(hover-state-layer-color); - opacity: token-utils.slot(hover-state-layer-opacity); - } - - .mat-mdc-chip-selected:hover, - .mat-mdc-chip-highlighted:hover & { - background: token-utils.slot(selected-hover-state-layer-color); - opacity: token-utils.slot(selected-hover-state-layer-opacity); - } +.mat-mdc-chip-focus-overlay { + background: token-utils.slot(chip-focus-state-layer-color, $fallbacks); - .mat-mdc-chip.cdk-focused & { - background: token-utils.slot(focus-state-layer-color); - opacity: token-utils.slot(focus-state-layer-opacity); - } + .mat-mdc-chip-selected &, + .mat-mdc-chip-highlighted & { + background: token-utils.slot(chip-selected-focus-state-layer-color, $fallbacks); + } - .mat-mdc-chip-selected.cdk-focused &, - .mat-mdc-chip-highlighted.cdk-focused & { - background: token-utils.slot(selected-focus-state-layer-color); - opacity: token-utils.slot(selected-focus-state-layer-opacity); - } + .mat-mdc-chip:hover & { + background: token-utils.slot(chip-hover-state-layer-color, $fallbacks); + opacity: token-utils.slot(chip-hover-state-layer-opacity, $fallbacks); } - .mdc-evolution-chip--disabled:not(.mdc-evolution-chip--selected) .mat-mdc-chip-avatar { - opacity: token-utils.slot(with-avatar-disabled-avatar-opacity); + .mat-mdc-chip-selected:hover, + .mat-mdc-chip-highlighted:hover & { + background: token-utils.slot(chip-selected-hover-state-layer-color, $fallbacks); + opacity: token-utils.slot(chip-selected-hover-state-layer-opacity, $fallbacks); } - .mdc-evolution-chip--disabled .mdc-evolution-chip__icon--trailing { - opacity: token-utils.slot(with-trailing-icon-disabled-trailing-icon-opacity); + .mat-mdc-chip.cdk-focused & { + background: token-utils.slot(chip-focus-state-layer-color, $fallbacks); + opacity: token-utils.slot(chip-focus-state-layer-opacity, $fallbacks); } - .mdc-evolution-chip--disabled.mdc-evolution-chip--selected .mdc-evolution-chip__checkmark { - opacity: token-utils.slot(with-icon-disabled-icon-opacity); + .mat-mdc-chip-selected.cdk-focused &, + .mat-mdc-chip-highlighted.cdk-focused & { + background: token-utils.slot(chip-selected-focus-state-layer-color, $fallbacks); + opacity: token-utils.slot(chip-selected-focus-state-layer-opacity, $fallbacks); } } -@include token-utils.use-tokens($token-prefix, $token-slots) { - // Historically, MDC did not support disabled chips, so we needed our own disabled styles. - // Now that MDC supports disabled styles, we should switch to using theirs. - .mat-mdc-standard-chip { - &.mdc-evolution-chip--disabled { - opacity: token-utils.slot(disabled-container-opacity); - } +.mdc-evolution-chip--disabled:not(.mdc-evolution-chip--selected) .mat-mdc-chip-avatar { + opacity: token-utils.slot(chip-with-avatar-disabled-avatar-opacity, $fallbacks); +} - &.mdc-evolution-chip--selected, - &.mat-mdc-chip-highlighted { - .mdc-evolution-chip__icon--trailing { - color: token-utils.slot(selected-trailing-icon-color); - } +.mdc-evolution-chip--disabled .mdc-evolution-chip__icon--trailing { + opacity: token-utils.slot(chip-with-trailing-icon-disabled-trailing-icon-opacity, $fallbacks); +} - &.mdc-evolution-chip--disabled .mdc-evolution-chip__icon--trailing { - color: token-utils.slot(selected-disabled-trailing-icon-color); - } - } - } +.mdc-evolution-chip--disabled.mdc-evolution-chip--selected .mdc-evolution-chip__checkmark { + opacity: token-utils.slot(chip-with-icon-disabled-icon-opacity, $fallbacks); +} - .mat-mdc-chip-remove { - opacity: token-utils.slot(trailing-action-opacity); +// Historically, MDC did not support disabled chips, so we needed our own disabled styles. +// Now that MDC supports disabled styles, we should switch to using theirs. +.mat-mdc-standard-chip { + &.mdc-evolution-chip--disabled { + opacity: token-utils.slot(chip-disabled-container-opacity, $fallbacks); + } - &:focus { - opacity: token-utils.slot(trailing-action-focus-opacity); + &.mdc-evolution-chip--selected, + &.mat-mdc-chip-highlighted { + .mdc-evolution-chip__icon--trailing { + color: token-utils.slot(chip-selected-trailing-icon-color, $fallbacks); } - &::after { - background-color: token-utils.slot(trailing-action-state-layer-color); + &.mdc-evolution-chip--disabled .mdc-evolution-chip__icon--trailing { + color: token-utils.slot(chip-selected-disabled-trailing-icon-color, $fallbacks); } + } +} - &:hover::after { - opacity: token-utils.slot(trailing-action-hover-state-layer-opacity); - } +.mat-mdc-chip-edit, .mat-mdc-chip-remove { + opacity: token-utils.slot(chip-trailing-action-opacity, $fallbacks); - &:focus::after { - opacity: token-utils.slot(trailing-action-focus-state-layer-opacity); - } + &:focus { + opacity: token-utils.slot(chip-trailing-action-focus-opacity, $fallbacks); } - .mat-mdc-chip-selected .mat-mdc-chip-remove::after, - .mat-mdc-chip-highlighted .mat-mdc-chip-remove::after { - background-color: token-utils.slot(selected-trailing-action-state-layer-color); + &::after { + background-color: token-utils.slot(chip-trailing-action-state-layer-color, $fallbacks); + } + + &:hover::after { + opacity: token-utils.slot(chip-trailing-action-hover-state-layer-opacity, $fallbacks); } + + &:focus::after { + opacity: token-utils.slot(chip-trailing-action-focus-state-layer-opacity, $fallbacks); + } +} + +.mat-mdc-chip-selected .mat-mdc-chip-remove::after, +.mat-mdc-chip-highlighted .mat-mdc-chip-remove::after { + background-color: token-utils.slot(chip-selected-trailing-action-state-layer-color, $fallbacks); } .mat-mdc-standard-chip { @@ -680,7 +670,7 @@ $token-slots: m2-chip.get-token-slots(); } } -.mat-mdc-chip-remove { +.mat-mdc-chip-edit, .mat-mdc-chip-remove { &::before { $default-border-width: focus-indicators-private.$default-border-width; $offset: var(--mat-focus-indicator-border-width, #{$default-border-width}); @@ -744,6 +734,6 @@ $token-slots: m2-chip.get-token-slots(); // Prevents icon from being cut off when text spacing is increased. // .mat-mdc-chip-remove selector necessary for remove button with icon. // Fixes b/250063405. -.mdc-evolution-chip__icon, .mat-mdc-chip-remove .mat-icon { +.mdc-evolution-chip__icon, .mat-mdc-chip-edit .mat-icon, .mat-mdc-chip-remove .mat-icon { min-height: fit-content; } diff --git a/src/material/chips/chip.spec.ts b/src/material/chips/chip.spec.ts index 35b54129637a..b9748576396d 100644 --- a/src/material/chips/chip.spec.ts +++ b/src/material/chips/chip.spec.ts @@ -1,8 +1,7 @@ -import {Directionality} from '@angular/cdk/bidi'; +import {provideFakeDirectionality} from '@angular/cdk/testing/private'; import {Component, DebugElement, ViewChild} from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; -import {Subject} from 'rxjs'; import {MatChip, MatChipEvent, MatChipSet, MatChipsModule} from './index'; describe('MatChip', () => { @@ -11,8 +10,6 @@ describe('MatChip', () => { let chipNativeElement: HTMLElement; let chipInstance: MatChip; - let dir = 'ltr'; - beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -22,15 +19,7 @@ describe('MatChip', () => { BasicChipWithStaticTabindex, BasicChipWithBoundTabindex, ], - providers: [ - { - provide: Directionality, - useFactory: () => ({ - value: dir, - change: new Subject(), - }), - }, - ], + providers: [provideFakeDirectionality('ltr')], }); })); diff --git a/src/material/chips/chip.ts b/src/material/chips/chip.ts index cfce927bc3fe..85e2cde30552 100644 --- a/src/material/chips/chip.ts +++ b/src/material/chips/chip.ts @@ -44,8 +44,14 @@ import { } from '../core'; import {Subject, Subscription, merge} from 'rxjs'; import {MatChipAction} from './chip-action'; -import {MatChipAvatar, MatChipRemove, MatChipTrailingIcon} from './chip-icons'; -import {MAT_CHIP, MAT_CHIP_AVATAR, MAT_CHIP_REMOVE, MAT_CHIP_TRAILING_ICON} from './tokens'; +import {MatChipAvatar, MatChipEdit, MatChipRemove, MatChipTrailingIcon} from './chip-icons'; +import { + MAT_CHIP, + MAT_CHIP_AVATAR, + MAT_CHIP_EDIT, + MAT_CHIP_REMOVE, + MAT_CHIP_TRAILING_ICON, +} from './tokens'; /** Represents an event fired on an individual `mat-chip`. */ export interface MatChipEvent { @@ -133,6 +139,10 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck @ContentChildren(MAT_CHIP_TRAILING_ICON, {descendants: true}) protected _allTrailingIcons: QueryList; + /** All edit icons present in the chip. */ + @ContentChildren(MAT_CHIP_EDIT, {descendants: true}) + protected _allEditIcons: QueryList; + /** All remove icons present in the chip. */ @ContentChildren(MAT_CHIP_REMOVE, {descendants: true}) protected _allRemoveIcons: QueryList; @@ -180,10 +190,10 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck // TODO: should be typed as `ThemePalette` but internal apps pass in arbitrary strings. /** * Theme color of the chip. This API is supported in M2 themes only, it has no - * effect in M3 themes. For color customization in M3, see https://material.angular.io/components/chips/styling. + * effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/chips/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() color?: string | null; @@ -225,6 +235,9 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck /** The chip's leading icon. */ @ContentChild(MAT_CHIP_AVATAR) leadingIcon: MatChipAvatar; + /** The chip's leading edit icon. */ + @ContentChild(MAT_CHIP_EDIT) editIcon: MatChipEdit; + /** The chip's trailing icon. */ @ContentChild(MAT_CHIP_TRAILING_ICON) trailingIcon: MatChipTrailingIcon; @@ -279,6 +292,7 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck this._actionChanges = merge( this._allLeadingIcons.changes, this._allTrailingIcons.changes, + this._allEditIcons.changes, this._allRemoveIcons.changes, ).subscribe(() => this._changeDetectorRef.markForCheck()); } @@ -358,6 +372,10 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck _getActions(): MatChipAction[] { const result: MatChipAction[] = []; + if (this.editIcon) { + result.push(this.editIcon); + } + if (this.primaryAction) { result.push(this.primaryAction); } @@ -378,6 +396,11 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck // Empty here, but is overwritten in child classes. } + /** Handles interactions with the edit action of the chip. */ + _edit(event: Event) { + // Empty here, but is overwritten in child classes. + } + /** Starts the focus monitoring process on the chip. */ private _monitorFocus() { this._focusMonitor.monitor(this._elementRef, true).subscribe(origin => { diff --git a/src/material/chips/module.ts b/src/material/chips/module.ts index 36771d4f9a41..105e6e88d16e 100644 --- a/src/material/chips/module.ts +++ b/src/material/chips/module.ts @@ -13,7 +13,7 @@ import {MatChip} from './chip'; import {MAT_CHIPS_DEFAULT_OPTIONS, MatChipsDefaultOptions} from './tokens'; import {MatChipEditInput} from './chip-edit-input'; import {MatChipGrid} from './chip-grid'; -import {MatChipAvatar, MatChipRemove, MatChipTrailingIcon} from './chip-icons'; +import {MatChipAvatar, MatChipEdit, MatChipRemove, MatChipTrailingIcon} from './chip-icons'; import {MatChipInput} from './chip-input'; import {MatChipListbox} from './chip-listbox'; import {MatChipRow} from './chip-row'; @@ -24,6 +24,7 @@ import {MatChipAction} from './chip-action'; const CHIP_DECLARATIONS = [ MatChip, MatChipAvatar, + MatChipEdit, MatChipEditInput, MatChipGrid, MatChipInput, diff --git a/src/material/chips/testing/chip-edit-harness.ts b/src/material/chips/testing/chip-edit-harness.ts new file mode 100644 index 000000000000..dacebe95f5cd --- /dev/null +++ b/src/material/chips/testing/chip-edit-harness.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + ComponentHarness, + ComponentHarnessConstructor, + HarnessPredicate, +} from '@angular/cdk/testing'; +import {ChipEditHarnessFilters} from './chip-harness-filters'; + +/** Harness for interacting with a standard Material chip edit button in tests. */ +export class MatChipEditHarness extends ComponentHarness { + static hostSelector = '.mat-mdc-chip-edit'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a chip edit with specific + * attributes. + * @param options Options for filtering which input instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ + static with( + this: ComponentHarnessConstructor, + options: ChipEditHarnessFilters = {}, + ): HarnessPredicate { + return new HarnessPredicate(this, options); + } + + /** Clicks the edit button. */ + async click(): Promise { + return (await this.host()).click(); + } +} diff --git a/src/material/chips/testing/chip-harness-filters.ts b/src/material/chips/testing/chip-harness-filters.ts index 27a95806ebe3..d42e3c6a803a 100644 --- a/src/material/chips/testing/chip-harness-filters.ts +++ b/src/material/chips/testing/chip-harness-filters.ts @@ -43,6 +43,8 @@ export interface ChipRowHarnessFilters extends ChipHarnessFilters {} export interface ChipSetHarnessFilters extends BaseHarnessFilters {} +export interface ChipEditHarnessFilters extends BaseHarnessFilters {} + export interface ChipRemoveHarnessFilters extends BaseHarnessFilters {} export interface ChipAvatarHarnessFilters extends BaseHarnessFilters {} diff --git a/src/material/chips/testing/chip-harness.ts b/src/material/chips/testing/chip-harness.ts index 36fa99e5dedd..4b7c5133c70f 100644 --- a/src/material/chips/testing/chip-harness.ts +++ b/src/material/chips/testing/chip-harness.ts @@ -15,9 +15,11 @@ import { import {MatChipAvatarHarness} from './chip-avatar-harness'; import { ChipAvatarHarnessFilters, + ChipEditHarnessFilters, ChipHarnessFilters, ChipRemoveHarnessFilters, } from './chip-harness-filters'; +import {MatChipEditHarness} from './chip-edit-harness'; import {MatChipRemoveHarness} from './chip-remove-harness'; /** Harness for interacting with a mat-chip in tests. */ @@ -62,6 +64,14 @@ export class MatChipHarness extends ContentContainerComponentHarness { await hostEl.sendKeys(TestKey.DELETE); } + /** + * Gets the edit button inside of a chip. + * @param filter Optionally filters which chips are included. + */ + async geEditButton(filter: ChipEditHarnessFilters = {}): Promise { + return this.locatorFor(MatChipEditHarness.with(filter))(); + } + /** * Gets the remove button inside of a chip. * @param filter Optionally filters which chips are included. diff --git a/src/material/chips/testing/public-api.ts b/src/material/chips/testing/public-api.ts index 49398cf712f8..222ff8ce2414 100644 --- a/src/material/chips/testing/public-api.ts +++ b/src/material/chips/testing/public-api.ts @@ -7,6 +7,7 @@ */ export * from './chip-avatar-harness'; +export * from './chip-edit-harness'; export * from './chip-harness'; export * from './chip-harness-filters'; export * from './chip-input-harness'; diff --git a/src/material/chips/tokens.ts b/src/material/chips/tokens.ts index 9c90f0253a0d..a95ed2c0b132 100644 --- a/src/material/chips/tokens.ts +++ b/src/material/chips/tokens.ts @@ -46,6 +46,13 @@ export const MAT_CHIP_AVATAR = new InjectionToken('MatChipAvatar'); */ export const MAT_CHIP_TRAILING_ICON = new InjectionToken('MatChipTrailingIcon'); +/** + * Injection token that can be used to reference instances of `MatChipEdit`. It serves as + * alternative token to the actual `MatChipEdit` class which could cause unnecessary + * retention of the class and its directive metadata. + */ +export const MAT_CHIP_EDIT = new InjectionToken('MatChipEdit'); + /** * Injection token that can be used to reference instances of `MatChipRemove`. It serves as * alternative token to the actual `MatChipRemove` class which could cause unnecessary diff --git a/src/material/core/BUILD.bazel b/src/material/core/BUILD.bazel index 464cf8261e5e..9324bbaaf9cc 100644 --- a/src/material/core/BUILD.bazel +++ b/src/material/core/BUILD.bazel @@ -86,6 +86,7 @@ sass_library( "_core-theme.scss", ], deps = [ + ":m2", "//src/material/core:ripple_sass_theme", "//src/material/core/option:theme", "//src/material/core/selection/pseudo-checkbox:sass_theme", diff --git a/src/material/core/_core-theme.scss b/src/material/core/_core-theme.scss index aace526523e1..89b27a7cb69a 100644 --- a/src/material/core/_core-theme.scss +++ b/src/material/core/_core-theme.scss @@ -1,142 +1,125 @@ -@use './theming/theming'; @use './theming/inspection'; -@use './theming/validation'; @use './ripple/ripple-theme'; @use './option/option-theme'; @use './option/optgroup-theme'; @use './selection/pseudo-checkbox/pseudo-checkbox-theme'; -@use './style/sass-utils'; @use './typography/typography'; @use './tokens/token-utils'; @use './m2-app'; -@use 'ripple/m2-ripple'; -@use 'option/m2-option'; -@use 'option/m2-optgroup'; -@use 'selection/pseudo-checkbox/m2-pseudo-checkbox'; +@use './m3-app'; +@use 'ripple/m3-ripple'; +@use 'option/m3-option'; +@use 'option/m3-optgroup'; +@use 'selection/pseudo-checkbox/m3-pseudo-checkbox'; +@use 'sass:map'; @mixin base($theme) { + $tokens: map.get(m2-app.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - @include ripple-theme.base($theme); - @include option-theme.base($theme); - @include optgroup-theme.base($theme); - @include pseudo-checkbox-theme.base($theme); - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-app.$prefix, m2-app.get-unthemable-tokens()); - } + $tokens: map.get(m3-app.get-tokens($theme), base); } + + @include token-utils.values($tokens); + + @include ripple-theme.base($theme); + @include option-theme.base($theme); + @include optgroup-theme.base($theme); + @include pseudo-checkbox-theme.base($theme); } @mixin color($theme) { + $tokens: map.get(m2-app.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); - } @else { - @include ripple-theme.color($theme); - @include option-theme.color($theme); - @include optgroup-theme.color($theme); - @include pseudo-checkbox-theme.color($theme); - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-app.$prefix, m2-app.get-color-tokens($theme)); - } + $tokens: map.get(m3-app.get-tokens($theme), color); } + + @include token-utils.values($tokens); + + @include ripple-theme.color($theme); + @include option-theme.color($theme); + @include optgroup-theme.color($theme); + @include pseudo-checkbox-theme.color($theme); } @mixin typography($theme) { + $tokens: map.get(m2-app.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include option-theme.typography($theme); - @include optgroup-theme.typography($theme); - @include pseudo-checkbox-theme.typography($theme); - @include ripple-theme.typography($theme); + $tokens: map.get(m3-app.get-tokens($theme), typography); } + + @include token-utils.values($tokens); + + @include ripple-theme.typography($theme); + @include option-theme.typography($theme); + @include optgroup-theme.typography($theme); + @include pseudo-checkbox-theme.typography($theme); } @mixin density($theme) { + $tokens: map.get(m2-app.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - // There are no M3 density tokens for this component - } @else { - @include option-theme.density($theme); - @include optgroup-theme.density($theme); - @include pseudo-checkbox-theme.density($theme); - @include ripple-theme.density($theme); + $tokens: map.get(m3-app.get-tokens($theme), density); } + + @include token-utils.values($tokens); + + @include ripple-theme.density($theme); + @include option-theme.density($theme); + @include optgroup-theme.density($theme); + @include pseudo-checkbox-theme.density($theme); } @function _define-overrides() { - $app-tokens: m2-app.get-token-slots(); - $ripple-tokens: m2-ripple.get-token-slots(); - $option-tokens: m2-option.get-token-slots(); - $optgroup-tokens: m2-optgroup.get-token-slots(); - $pseudo-checkbox-tokens: m2-pseudo-checkbox.get-token-slots(); - @return ( - (namespace: m2-app.$prefix, tokens: $app-tokens, prefix: 'app-'), - (namespace: m2-ripple.$prefix, tokens: $ripple-tokens, prefix: 'ripple-'), - (namespace: m2-option.$prefix, tokens: $option-tokens, prefix: 'option-'), - (namespace: m2-optgroup.$prefix, tokens: $optgroup-tokens, prefix: 'optgroup-'), ( - namespace: m2-pseudo-checkbox.$prefix, - tokens: m2-pseudo-checkbox.get-token-slots(), + namespace: app, + tokens: token-utils.get-overrides(m3-app.get-tokens(), app), + prefix: 'app-' + ), + ( + namespace: ripple, + tokens: token-utils.get-overrides(m3-ripple.get-tokens(), ripple), + prefix: 'ripple-' + ), + ( + namespace: option, + tokens: token-utils.get-overrides(m3-option.get-tokens(), option), + prefix: 'option-' + ), + ( + namespace: optgroup, + tokens: token-utils.get-overrides(m3-optgroup.get-tokens(), optgroup), + prefix: 'optgroup-' + ), + ( + namespace: pseudo-checkbox, + tokens: token-utils.get-overrides(m3-pseudo-checkbox.get-tokens(), pseudo-checkbox), prefix: 'pseudo-checkbox-' ), ); } @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } // Mixin that renders all of the core styles that depend on the theme. -@mixin theme($theme, $options...) { - // Wrap the sub-theme includes in the duplicate theme styles mixin. This ensures that - // there won't be multiple warnings. e.g. if `mat-core-theme` reports a warning, then - // the imported themes (such as `mat-ripple-theme`) should not report again. - @include theming.private-check-duplicate-theme-styles($theme, 'mat-core') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme), $options...); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } +@mixin theme($theme) { + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } } } - -@mixin _theme-from-tokens($tokens, $options...) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - $mat-app-tokens: token-utils.get-tokens-for($tokens, m2-app.$prefix, $options...); - $mat-ripple-tokens: token-utils.get-tokens-for($tokens, m2-ripple.$prefix, $options...); - $mat-option-tokens: token-utils.get-tokens-for($tokens, m2-option.$prefix, $options...); - $mat-optgroup-tokens: token-utils.get-tokens-for( - $tokens, - m2-optgroup.$prefix, - $options... - ); - $mat-pseudo-checkbox-tokens: token-utils.get-tokens-for( - $tokens, - m2-pseudo-checkbox.$prefix, - $options... - ); - - @include token-utils.create-token-values(m2-app.$prefix, $mat-app-tokens); - @include token-utils.create-token-values(m2-ripple.$prefix, $mat-ripple-tokens); - @include token-utils.create-token-values(m2-option.$prefix, $mat-option-tokens); - @include token-utils.create-token-values(m2-optgroup.$prefix, $mat-optgroup-tokens); - @include token-utils.create-token-values( - m2-pseudo-checkbox.$prefix, - $mat-pseudo-checkbox-tokens - ); -} diff --git a/src/material/core/_core.scss b/src/material/core/_core.scss index c864699c7308..8cf15459bc40 100644 --- a/src/material/core/_core.scss +++ b/src/material/core/_core.scss @@ -1,7 +1,9 @@ -@use './m2-app'; +@use './m3-app'; @use './tokens/token-utils'; @use './style/elevation'; +$fallbacks: m3-app.get-tokens(); + /// @deprecated This mixin is a no-op and is going to be removed in v21. @mixin core() {} @@ -20,11 +22,9 @@ // user's content isn't inside of a `mat-sidenav-container`. @at-root { // Note: we need to emit fallback values here to avoid errors in internal builds. - @include token-utils.use-tokens(m2-app.$prefix, m2-app.get-token-slots()) { - .mat-app-background { - background-color: token-utils.slot(background-color, transparent); - color: token-utils.slot(text-color, inherit); - } + .mat-app-background { + background-color: token-utils.slot(app-background-color, $fallbacks, transparent); + color: token-utils.slot(app-text-color, $fallbacks, inherit); } } } @@ -35,18 +35,15 @@ // Material Design specification. @mixin elevation-classes() { @at-root { - @include token-utils.use-tokens(m2-app.$prefix, m2-app.get-token-slots()) { - // Provides external CSS classes for each elevation value. Each CSS class is formatted as - // `mat-elevation-z$z-value` where `$z-value` corresponds to the z-space to which the element - // is elevated. - @for $z-value from 0 through 24 { - $selector: elevation.$prefix + $z-value; - // We need the `mat-mdc-elevation-specific`, because some MDC mixins - // come with elevation baked in and we don't have a way of removing it. - .#{$selector}, .mat-mdc-elevation-specific.#{$selector} { - box-shadow: token-utils.slot('elevation-shadow-level-#{$z-value}', - none); - } + // Provides external CSS classes for each elevation value. Each CSS class is formatted as + // `mat-elevation-z$z-value` where `$z-value` corresponds to the z-space to which the element + // is elevated. + @for $z-value from 0 through 24 { + $selector: elevation.$prefix + $z-value; + // We need the `mat-mdc-elevation-specific`, because some MDC mixins + // come with elevation baked in and we don't have a way of removing it. + .#{$selector}, .mat-mdc-elevation-specific.#{$selector} { + box-shadow: token-utils.slot('app-elevation-shadow-level-#{$z-value}', $fallbacks, none); } } } diff --git a/src/material/core/_m2-app.scss b/src/material/core/_m2-app.scss index d942b2cfcd3b..ea21f00bee97 100644 --- a/src/material/core/_m2-app.scss +++ b/src/material/core/_m2-app.scss @@ -1,53 +1,24 @@ @use 'sass:map'; -@use './tokens/m2-utils'; -@use './theming/inspection'; -@use './style/sass-utils'; @use './style/elevation'; +@use '../core/tokens/m2-utils'; +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, app); - -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - @return (); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - $tokens: ( - background-color: inspection.get-theme-color($theme, background, background), - text-color: inspection.get-theme-color($theme, foreground, text), + $color-tokens: ( + app-background-color: map.get($system, background), + app-text-color: map.get($system, on-surface) ); - @for $zValue from 0 through 24 { - $elevation-color: inspection.get-theme-color($theme, foreground, elevation); - $shadow: elevation.get-box-shadow($zValue, - if($elevation-color == null, elevation.$color, $elevation-color)); - $tokens: map.set($tokens, 'elevation-shadow-level-#{$zValue}', $shadow); + $color-tokens: map.set($color-tokens, 'app-elevation-shadow-level-#{$zValue}', + elevation.get-box-shadow($zValue, map.get($system, shadow))); } - @return $tokens; -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return (); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + @return ( + base: (), + color: $color-tokens, + typography: (), + density: (), ); } + diff --git a/src/material/core/_m3-app.scss b/src/material/core/_m3-app.scss index b070ad582ec4..c29b33d2f9bf 100644 --- a/src/material/core/_m3-app.scss +++ b/src/material/core/_m3-app.scss @@ -1,31 +1,44 @@ @use 'sass:map'; -@use 'sass:meta'; @use './style/elevation'; @use './tokens/m3-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, app); +@use '../core/tokens/m3'; /// Generates custom tokens for the app. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the app -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: ( - background-color: map.get($systems, md-sys-color, background), - text-color: map.get($systems, md-sys-color, on-background), - ); +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); - // If the shadow-color is an actual color, convert it to a box-shadow value. Otherwise - // use the token value as itself (e,g. as a CSS var name). - $shadow-color: map.get($systems, md-sys-color, shadow); - @if (meta.type-of($shadow-color) == color) { - @for $zValue from 0 through 24 { - $shadow: elevation.get-box-shadow($zValue, $shadow-color); - $tokens: map.set($tokens, 'elevation-shadow-level-#{$zValue}', $shadow); - } - } - - @return m3-utils.namespace($prefix, $tokens, $token-slots); + @return ( + base: (), + color: ( + app-background-color: map.get($system, background), + app-text-color: map.get($system, on-background), + app-elevation-shadow-level-0: elevation.get-box-shadow(0, map.get($system, shadow)), + app-elevation-shadow-level-1: elevation.get-box-shadow(1, map.get($system, shadow)), + app-elevation-shadow-level-2: elevation.get-box-shadow(2, map.get($system, shadow)), + app-elevation-shadow-level-3: elevation.get-box-shadow(3, map.get($system, shadow)), + app-elevation-shadow-level-4: elevation.get-box-shadow(4, map.get($system, shadow)), + app-elevation-shadow-level-5: elevation.get-box-shadow(5, map.get($system, shadow)), + app-elevation-shadow-level-6: elevation.get-box-shadow(6, map.get($system, shadow)), + app-elevation-shadow-level-7: elevation.get-box-shadow(7, map.get($system, shadow)), + app-elevation-shadow-level-8: elevation.get-box-shadow(8, map.get($system, shadow)), + app-elevation-shadow-level-9: elevation.get-box-shadow(9, map.get($system, shadow)), + app-elevation-shadow-level-10: elevation.get-box-shadow(10, map.get($system, shadow)), + app-elevation-shadow-level-11: elevation.get-box-shadow(11, map.get($system, shadow)), + app-elevation-shadow-level-12: elevation.get-box-shadow(12, map.get($system, shadow)), + app-elevation-shadow-level-13: elevation.get-box-shadow(13, map.get($system, shadow)), + app-elevation-shadow-level-14: elevation.get-box-shadow(14, map.get($system, shadow)), + app-elevation-shadow-level-15: elevation.get-box-shadow(15, map.get($system, shadow)), + app-elevation-shadow-level-16: elevation.get-box-shadow(16, map.get($system, shadow)), + app-elevation-shadow-level-17: elevation.get-box-shadow(17, map.get($system, shadow)), + app-elevation-shadow-level-18: elevation.get-box-shadow(18, map.get($system, shadow)), + app-elevation-shadow-level-19: elevation.get-box-shadow(19, map.get($system, shadow)), + app-elevation-shadow-level-20: elevation.get-box-shadow(20, map.get($system, shadow)), + app-elevation-shadow-level-21: elevation.get-box-shadow(21, map.get($system, shadow)), + app-elevation-shadow-level-22: elevation.get-box-shadow(22, map.get($system, shadow)), + app-elevation-shadow-level-23: elevation.get-box-shadow(23, map.get($system, shadow)), + app-elevation-shadow-level-24: elevation.get-box-shadow(24, map.get($system, shadow)), + ), + typography: (), + density: (), + ); } diff --git a/src/material/core/color/_all-color.scss b/src/material/core/color/_all-color.scss index 79241610ea54..ce75b859c505 100644 --- a/src/material/core/color/_all-color.scss +++ b/src/material/core/color/_all-color.scss @@ -7,7 +7,7 @@ @error 'No color configuration specified.'; } - @include all-theme.all-component-themes( + @include all-theme.all-component-colors( inspection.theme-remove($theme, base, typography, density)); } diff --git a/src/material/core/focus-indicators/_private.scss b/src/material/core/focus-indicators/_private.scss index 3370f75d348f..82732ebbc876 100644 --- a/src/material/core/focus-indicators/_private.scss +++ b/src/material/core/focus-indicators/_private.scss @@ -2,7 +2,6 @@ @use 'sass:meta'; @use '@angular/cdk'; @use '../style/layout-common'; -@use '../theming/theming'; @use '../theming/inspection'; // Private sass variables that will be used as reference throughout component stylesheets. @@ -104,10 +103,8 @@ $default-border-radius: 4px; @include _customize-focus-indicators((border-color: $theme-or-color)); } @else { - @include theming.private-check-duplicate-theme-styles($theme-or-color, 'mat-focus-indicators') { - @if inspection.theme-has($theme-or-color, color) { - @include strong-focus-indicators-color($theme-or-color); - } + @if inspection.theme-has($theme-or-color, color) { + @include strong-focus-indicators-color($theme-or-color); } } } diff --git a/src/material/core/m2/BUILD.bazel b/src/material/core/m2/BUILD.bazel index 063941b0165d..881f735a7751 100644 --- a/src/material/core/m2/BUILD.bazel +++ b/src/material/core/m2/BUILD.bazel @@ -6,10 +6,32 @@ sass_library( name = "m2_sass", srcs = [ "_index.scss", + ], + deps = [ + ":palette", + ":theming", + ], +) + +sass_library( + name = "theming", + srcs = [ + "_theming.scss", + "_typography.scss", + "_typography-utils.scss", + ], + deps = [ + "//src/material/core/theming", + "//src/material/core/tokens/m2", + ], +) + +sass_library( + name = "palette", + srcs = [ "_palette.scss", "_theming.scss", "_typography.scss", "_typography-utils.scss", ], - deps = ["//src/material/core/theming"], ) diff --git a/src/material/core/m2/_theming.scss b/src/material/core/m2/_theming.scss index 0f6eda8c0b9a..96d234c8e05f 100644 --- a/src/material/core/m2/_theming.scss +++ b/src/material/core/m2/_theming.scss @@ -3,6 +3,7 @@ @use 'sass:meta'; @use 'sass:color'; @use '../theming/theming'; +@use '../tokens/m2'; @use './palette'; /// Extracts a color from a palette or throws an error if it doesn't exist. @@ -85,14 +86,16 @@ // might be inferred as numbers by Sass. Casting them to string fixes the map lookup. $color: if(map.has-key($palette, $hue), map.get($palette, $hue), map.get($palette, $hue + '')); - @if (meta.type-of($color) != color) { - // If the $color resolved to something different from a color (e.g. a CSS variable), - // we can't apply the opacity anyway so we return the value as is, otherwise Sass can - // throw an error or output something invalid. + @if ($opacity == null) { @return $color; } - @return rgba($color, if($opacity == null, color.opacity($color), $opacity)); + @if (meta.type-of($color) != color) { + $opacity: ($opacity * 100) + '%'; + @return #{color-mix(in srgb, #{$color} #{$opacity}, transparent)}; + } + + @return rgba($color, $opacity); } // Validates the specified theme by ensuring that the optional color config defines @@ -111,39 +114,52 @@ @return $theme; } -// Creates a light-themed color configuration from the specified -// primary, accent and warn palettes. -@function _mat-create-light-color-config($primary, $accent, $warn: null) { - @return ( +@function _define-system($primary, $accent, $warn, $is-dark, $typography, $density-scale) { + $palettes: ( primary: $primary, accent: $accent, - warn: if($warn != null, $warn, define-palette(palette.$red-palette)), - is-dark: false, - foreground: palette.$light-theme-foreground-palette, - background: palette.$light-theme-background-palette + warn: $warn, ); + + $sys-color: if( + $is-dark, + m2.md-sys-color-values-dark($palettes), + m2.md-sys-color-values-light($palettes)); + $sys-state: m2.md-sys-state-values(); + $sys-typography: m2.md-sys-typescale-values($typography); + + $system: (density-scale: $density-scale); + @each $map in ($sys-color, $sys-state, $sys-typography) { + $system: map.merge($system, $map); + } + + @return $system; } -// Creates a dark-themed color configuration from the specified +// Creates a color configuration from the specified // primary, accent and warn palettes. -@function _mat-create-dark-color-config($primary, $accent, $warn: null) { +@function _mat-create-color-config($primary, $accent, $warn: null, $is-dark) { + $warn: if($warn != null, $warn, define-palette(palette.$red-palette)); + $foreground: + if($is-dark, palette.$dark-theme-foreground-palette, palette.$light-theme-foreground-palette); + $background: + if($is-dark, palette.$dark-theme-background-palette, palette.$light-theme-background-palette); + @return ( primary: $primary, accent: $accent, - warn: if($warn != null, $warn, define-palette(palette.$red-palette)), - is-dark: true, - foreground: palette.$dark-theme-foreground-palette, - background: palette.$dark-theme-background-palette + warn: $warn, + is-dark: $is-dark, + foreground: $foreground, + background: $background, ); } -// TODO: Remove legacy API and rename `$primary` below to `$config`. Currently it cannot be renamed -// as it would break existing apps that set the parameter by name. - -/// Creates a container object for a light theme to be given to individual component theme mixins. +/// Creates a container object for a theme to be given to individual component theme mixins. /// @param {Map} $primary The theme configuration object. /// @returns {Map} A complete Angular Material theme map. -@function define-light-theme($primary, $accent: null, $warn: define-palette(palette.$red-palette)) { +@function _define-theme( + $primary, $accent: null, $warn: define-palette(palette.$red-palette), $is-dark) { // This function creates a container object for the individual component theme mixins. Consumers // can construct such an object by calling this function, or by building the object manually. // There are two possible ways to invoke this function in order to create such an object: @@ -160,13 +176,23 @@ @if $accent != null { @warn theming.$private-legacy-theme-warning; $theme: _mat-validate-theme( - ( - _is-legacy-theme: true, - color: _mat-create-light-color-config($primary, $accent, $warn), - ) + ( + _is-legacy-theme: true, + color: _mat-create-color-config($primary, $accent, $warn, $is-dark), + ) ); - @return _internalize-theme(theming.private-create-backwards-compatibility-theme($theme)); + $theme: _internalize-theme(theming.private-create-backwards-compatibility-theme($theme)); + $system: _define-system( + $primary: $primary, + $accent: $accent, + $warn: $warn, + $is-dark: $is-dark, + $typography: (), + $density-scale: 0 + ); + $theme: map.set($theme, _mat-system, $system); + @return $theme; } // If the map pattern is used (1), we just pass-through the configurations for individual // parts of the theming system, but update the `color` configuration if set. As explained @@ -178,15 +204,35 @@ $accent: map.get($color-settings, accent); $warn: map.get($color-settings, warn); $result: map.merge( - $result, - ( - color: _mat-create-light-color-config($primary, $accent, $warn), - ) + $result, + ( + color: _mat-create-color-config($primary, $accent, $warn, $is-dark), + ) ); } - @return _internalize-theme( - theming.private-create-backwards-compatibility-theme(_mat-validate-theme($result)) + $theme: _internalize-theme( + theming.private-create-backwards-compatibility-theme(_mat-validate-theme($result)) + ); + $system: _define-system( + $primary: map.get($result, primary) or map.get($result, color, primary) or (), + $accent: map.get($result, accent) or map.get($result, color, accent) or (), + $warn: map.get($result, warn) or map.get($result, color, warn) or (), + $is-dark: $is-dark, + $typography: map.get($result, typography) or (), + $density-scale: map.get($result, density), ); + $theme: map.set($theme, _mat-system, $system); + @return $theme; +} + +// TODO: Remove legacy API and rename `$primary` below to `$config`. Currently it cannot be renamed +// as it would break existing apps that set the parameter by name. + +/// Creates a container object for a light theme to be given to individual component theme mixins. +/// @param {Map} $primary The theme configuration object. +/// @returns {Map} A complete Angular Material theme map. +@function define-light-theme($primary, $accent: null, $warn: define-palette(palette.$red-palette)) { + @return _define-theme($primary, $accent, $warn, false); } // TODO: Remove legacy API and rename below `$primary` to `$config`. Currently it cannot be renamed @@ -196,48 +242,7 @@ /// @param {Map} $primary The theme configuration object. /// @returns {Map} A complete Angular Material theme map. @function define-dark-theme($primary, $accent: null, $warn: define-palette(palette.$red-palette)) { - // This function creates a container object for the individual component theme mixins. Consumers - // can construct such an object by calling this function, or by building the object manually. - // There are two possible ways to invoke this function in order to create such an object: - // - // (1) Passing in a map that holds optional configurations for individual parts of the - // theming system. For `color` configurations, the function only expects the palettes - // for `primary` and `accent` (and optionally `warn`). The function will expand the - // shorthand into an actual configuration that can be consumed in `-color` mixins. - // (2) Legacy pattern: Passing in the palettes as parameters. This is not as flexible - // as passing in a configuration map because only the `color` system can be configured. - // - // If the legacy pattern is used, we generate a container object only with a dark-themed - // configuration for the `color` theming part. - @if $accent != null { - @warn theming.$private-legacy-theme-warning; - $theme: _mat-validate-theme( - ( - _is-legacy-theme: true, - color: _mat-create-dark-color-config($primary, $accent, $warn), - ) - ); - @return _internalize-theme(theming.private-create-backwards-compatibility-theme($theme)); - } - // If the map pattern is used (1), we just pass-through the configurations for individual - // parts of the theming system, but update the `color` configuration if set. As explained - // above, the color shorthand will be expanded to an actual dark-themed color configuration. - $result: $primary; - @if map.get($primary, color) { - $color-settings: map.get($primary, color); - $primary: map.get($color-settings, primary); - $accent: map.get($color-settings, accent); - $warn: map.get($color-settings, warn); - $result: map.merge( - $result, - ( - color: _mat-create-dark-color-config($primary, $accent, $warn), - ) - ); - } - @return _internalize-theme( - theming.private-create-backwards-compatibility-theme(_mat-validate-theme($result)) - ); + @return _define-theme($primary, $accent, $warn, true); } /// Gets the color configuration from the given theme or configuration. diff --git a/src/material/core/m2/_typography.scss b/src/material/core/m2/_typography.scss index 9aace3396e1a..bddab7225311 100644 --- a/src/material/core/m2/_typography.scss +++ b/src/material/core/m2/_typography.scss @@ -178,7 +178,7 @@ $_mdc-levels: ( /// @param {Map} $input Configuration for the "input" typographic level. /// @returns {Map} A typography config for the application. /// -/// @deprecated Use `mat.m2-define-typography-config` instead. See https://v17.material.angular.io/guide/mdc-migration for information about migrating. +/// @deprecated Use `mat.m2-define-typography-config` instead. See https://v17.material.angular.dev/guide/mdc-migration for information about migrating. /// @breaking-change 17.0.0 @function define-legacy-typography-config( $font-family: 'Roboto, "Helvetica Neue", sans-serif', diff --git a/src/material/core/option/BUILD.bazel b/src/material/core/option/BUILD.bazel index 7313d8ef22c7..2f6117f4547e 100644 --- a/src/material/core/option/BUILD.bazel +++ b/src/material/core/option/BUILD.bazel @@ -35,6 +35,7 @@ sass_library( "_option-theme.scss", ], deps = [ + ":m2", "//src/material/core/style:sass_utils", "//src/material/core/theming", "//src/material/core/theming:_inspection", diff --git a/src/material/core/option/_m2-optgroup.scss b/src/material/core/option/_m2-optgroup.scss index 94693ca52576..f8caf91dd36c 100644 --- a/src/material/core/option/_m2-optgroup.scss +++ b/src/material/core/option/_m2-optgroup.scss @@ -1,46 +1,21 @@ -@use '../style/sass-utils'; -@use '../theming/inspection'; @use '../tokens/m2-utils'; +@use 'sass:map'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, optgroup); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - @return (); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - @return ( - label-text-color: inspection.get-theme-color($theme, foreground, text), - ); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { @return ( - label-text-font: inspection.get-theme-typography($theme, body-1, font-family), - label-text-line-height: inspection.get-theme-typography($theme, body-1, line-height), - label-text-size: inspection.get-theme-typography($theme, body-1, font-size), - label-text-tracking: inspection.get-theme-typography($theme, body-1, letter-spacing), - label-text-weight: inspection.get-theme-typography($theme, body-1, font-weight) - ); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + base: (), + color: ( + optgroup-label-text-color: map.get($system, on-surface), + ), + typography: ( + optgroup-label-text-font: map.get($system, body-large-font), + optgroup-label-text-line-height: map.get($system, body-large-line-height), + optgroup-label-text-size: map.get($system, body-large-size), + optgroup-label-text-tracking: map.get($system, body-large-tracking), + optgroup-label-text-weight: map.get($system, body-large-weight) + ), + density: (), ); } diff --git a/src/material/core/option/_m2-option.scss b/src/material/core/option/_m2-option.scss index 1ce5f3e88f07..11414eb6dd5b 100644 --- a/src/material/core/option/_m2-option.scss +++ b/src/material/core/option/_m2-option.scss @@ -1,54 +1,38 @@ +@use 'sass:map'; @use '../tokens/m2-utils'; -@use '../theming/inspection'; -@use '../style/sass-utils'; +@use '../tokens/m3-utils'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, option); - -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - @return (); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme, $palette-name: primary) { - $is-dark: inspection.get-theme-type($theme) == dark; - $active-state-layer-color: inspection.get-theme-color($theme, foreground, base, - if($is-dark, 0.08, 0.04)); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); @return ( - selected-state-label-text-color: inspection.get-theme-color($theme, $palette-name), - label-text-color: inspection.get-theme-color($theme, foreground, text), - hover-state-layer-color: $active-state-layer-color, - focus-state-layer-color: $active-state-layer-color, - selected-state-layer-color: $active-state-layer-color, + base: (), + color: private-get-color-palette-color-tokens($theme, primary), + typography: ( + option-label-text-font: map.get($system, body-large-font), + option-label-text-line-height: map.get($system, body-large-line-height), + option-label-text-size: map.get($system, body-large-size), + option-label-text-tracking: map.get($system, body-large-tracking), + option-label-text-weight: map.get($system, body-large-weight) + ), + density: (), ); } -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { +// Tokens that can be configured through Angular Material's color theming API. +@function private-get-color-palette-color-tokens($theme, $color-variant) { + $system: m2-utils.get-system($theme); + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); + @return ( - label-text-font: inspection.get-theme-typography($theme, body-1, font-family), - label-text-line-height: inspection.get-theme-typography($theme, body-1, line-height), - label-text-size: inspection.get-theme-typography($theme, body-1, font-size), - label-text-tracking: inspection.get-theme-typography($theme, body-1, letter-spacing), - label-text-weight: inspection.get-theme-typography($theme, body-1, font-weight) + option-selected-state-label-text-color: map.get($system, primary), + option-label-text-color: map.get($system, on-surface), + option-hover-state-layer-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, hover-state-layer-opacity)), + option-focus-state-layer-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, focus-state-layer-opacity)), + option-selected-state-layer-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, pressed-state-layer-opacity)), ); } -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) - ); -} diff --git a/src/material/core/option/_m3-optgroup.scss b/src/material/core/option/_m3-optgroup.scss index 60c7466d3fe0..b787e9cd8259 100644 --- a/src/material/core/option/_m3-optgroup.scss +++ b/src/material/core/option/_m3-optgroup.scss @@ -1,22 +1,25 @@ @use 'sass:map'; -@use '../style/sass-utils'; @use '../tokens/m3-utils'; +@use '../tokens/m3'; // The prefix used to generate the fully qualified name for tokens in this file. $prefix: (mat, optgroup); /// Generates custom tokens for the mat-optgroup. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-optgroup -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: sass-utils.merge-all( - m3-utils.generate-typography-tokens($systems, label-text, title-small), - ( - label-text-color: map.get($systems, md-sys-color, on-surface-variant), - ) +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); + @return ( + base: (), + color: ( + optgroup-label-text-color: map.get($system, on-surface-variant), + ), + typography: ( + optgroup-label-text-font: map.get($system, title-small-font), + optgroup-label-text-line-height: map.get($system, title-small-line-height), + optgroup-label-text-size: map.get($system, title-small-size), + optgroup-label-text-tracking: map.get($system, title-small-tracking), + optgroup-label-text-weight: map.get($system, title-small-weight), + ), + density: (), ); - - @return m3-utils.namespace($prefix, $tokens, $token-slots); } diff --git a/src/material/core/option/_m3-option.scss b/src/material/core/option/_m3-option.scss index 11a9c385235b..b7eae506d8e8 100644 --- a/src/material/core/option/_m3-option.scss +++ b/src/material/core/option/_m3-option.scss @@ -1,57 +1,37 @@ @use 'sass:map'; -@use '../style/sass-utils'; @use '../tokens/m3-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, option); +@use '../tokens/m3'; /// Generates custom tokens for the mat-option. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-option -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: ( - selected-state-label-text-color: map.get($systems, md-sys-color, on-secondary-container), - label-text-color: map.get($systems, md-sys-color, on-surface), - hover-state-layer-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: map.get($systems, md-sys-state, hover-state-layer-opacity) - ), - focus-state-layer-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: map.get($systems, md-sys-state, focus-state-layer-opacity) - ), - selected-state-layer-color: map.get($systems, md-sys-color, secondary-container), +@function get-tokens($theme: m3.$sys-theme, $color-variant: null) { + $system: m3-utils.get-system($theme); + @if $color-variant { + $system: m3-utils.replace-colors-with-variant($system, secondary, $color-variant); + } - // According to the spec the options have to be `label-large` in all typography - // dimensions, however this is inconsistent with the designs and with MDC's - // own implementation. This appears to be a bug in the spec, because MDC overrides - // the font size and weight to be `body-large` (see b/261838263). We make the same - // override here so the label looks correct. - label-text-size: map.get($systems, md-sys-typescale, body-large-size), - label-text-weight: map.get($systems, md-sys-typescale, body-large-weight), - label-text-font: map.get($systems, md-sys-typescale, label-large-font), - label-text-line-height: map.get($systems, md-sys-typescale, label-large-line-height), - label-text-tracking: map.get($systems, md-sys-typescale, label-large-tracking), - ); - - $variant-tokens: ( - // Color variants: - primary: ( - selected-state-label-text-color: map.get($systems, md-sys-color, on-primary-container), - selected-state-layer-color: map.get($systems, md-sys-color, primary-container), + // According to the spec the options have to be `label-large` in all typography + // dimensions, however this is inconsistent with the designs and with MDC's + // own implementation. This appears to be a bug in the spec, because MDC overrides + // the font size and weight to be `body-large` (see b/261838263). We make the same + // override here so the label looks correct. + @return ( + base: (), + color: ( + option-focus-state-layer-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, focus-state-layer-opacity)), + option-hover-state-layer-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, hover-state-layer-opacity)), + option-label-text-color: map.get($system, on-surface), + option-selected-state-label-text-color: map.get($system, on-secondary-container), + option-selected-state-layer-color: map.get($system, secondary-container), ), - secondary: (), // Default, no overrides needed - tertiary: ( - selected-state-label-text-color: map.get($systems, md-sys-color, on-tertiary-container), - selected-state-layer-color: map.get($systems, md-sys-color, tertiary-container), + typography: ( + option-label-text-size: map.get($system, body-large-size), + option-label-text-weight: map.get($system, body-large-weight), + option-label-text-font: map.get($system, label-large-font), + option-label-text-line-height: map.get($system, label-large-line-height), + option-label-text-tracking: map.get($system, label-large-tracking), ), - error: ( - selected-state-label-text-color: map.get($systems, md-sys-color, on-error-container), - selected-state-layer-color: map.get($systems, md-sys-color, error-container), - ) + density: (), ); - - @return m3-utils.namespace($prefix, ($tokens, $variant-tokens), $token-slots); } diff --git a/src/material/core/option/_optgroup-theme.scss b/src/material/core/option/_optgroup-theme.scss index f0ae5befefe3..98c885f9790e 100644 --- a/src/material/core/option/_optgroup-theme.scss +++ b/src/material/core/option/_optgroup-theme.scss @@ -1,89 +1,76 @@ @use 'sass:map'; @use './m2-optgroup'; +@use './m3-optgroup'; @use '../tokens/token-utils'; -@use '../style/sass-utils'; -@use '../theming/theming'; -@use '../theming/validation'; @use '../theming/inspection'; @use '../typography/typography'; @mixin base($theme) { + $tokens: map.get(m2-optgroup.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { + $tokens: map.get(m3-optgroup.get-tokens($theme), base); } + + @include token-utils.values($tokens); } @mixin color($theme) { + $tokens: map.get(m2-optgroup.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-optgroup.$prefix, m2-optgroup.get-color-tokens($theme)); - } + $tokens: map.get(m3-optgroup.get-tokens($theme), color); } + + @include token-utils.values($tokens); } @mixin typography($theme) { + $tokens: map.get(m2-optgroup.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-optgroup.$prefix, m2-optgroup.get-typography-tokens($theme)); - } + $tokens: map.get(m3-optgroup.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } @mixin density($theme) { + $tokens: map.get(m2-optgroup.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - // There are no M3 density tokens for this component - } @else { + $tokens: map.get(m3-optgroup.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-optgroup.$prefix, - tokens: m2-optgroup.get-token-slots(), + namespace: optgroup, + tokens: token-utils.get-overrides(m3-optgroup.get-tokens(), optgroup) ), ); } @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } @mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-optgroup') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - @include token-utils.create-token-values( - m2-optgroup.$prefix, - map.get($tokens, m2-optgroup.$prefix) - ); } } diff --git a/src/material/core/option/_option-theme.scss b/src/material/core/option/_option-theme.scss index edc3250c6ef4..064fa5bdf313 100644 --- a/src/material/core/option/_option-theme.scss +++ b/src/material/core/option/_option-theme.scss @@ -1,43 +1,42 @@ @use './m2-option'; +@use './m3-option'; @use '../tokens/token-utils'; -@use '../style/sass-utils'; -@use '../theming/theming'; @use '../theming/inspection'; -@use '../theming/validation'; @use '../typography/typography'; +@use 'sass:map'; /// Outputs base theme styles (styles not dependent on the color, typography, or density settings) /// for the mat-option. /// @param {Map} $theme The theme to generate base styles for. @mixin base($theme) { + $tokens: map.get(m2-option.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { + $tokens: map.get(m3-option.get-tokens($theme), base); } + + @include token-utils.values($tokens); } /// Outputs color theme styles for the mat-option. /// @param {Map} $theme The theme to generate color styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the selected option: primary, secondary, -/// tertiary, or error (If not specified, default secondary color will be used). -@mixin color($theme, $options...) { +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin color($theme, $color-variant: null) { + $tokens: map.get(m2-option.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color), $options...); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-option.$prefix, m2-option.get-color-tokens($theme)); - } + $tokens: map.get(m3-option.get-tokens($theme, $color-variant), color); + } + @include token-utils.values($tokens); + + @if inspection.get-theme-version($theme) != 1 { .mat-accent { - @include token-utils.create-token-values-mixed( - m2-option.$prefix, m2-option.get-color-tokens($theme, accent)); + $tokens: m2-option.private-get-color-palette-color-tokens($theme, secondary); + @include token-utils.values($tokens); } .mat-warn { - @include token-utils.create-token-values-mixed( - m2-option.$prefix, m2-option.get-color-tokens($theme, warn)); + $tokens: m2-option.private-get-color-palette-color-tokens($theme, error); + @include token-utils.values($tokens); } } } @@ -45,31 +44,31 @@ /// Outputs typography theme styles for the mat-option. /// @param {Map} $theme The theme to generate typography styles for. @mixin typography($theme) { + $tokens: map.get(m2-option.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-option.$prefix, m2-option.get-typography-tokens($theme)); - } + $tokens: map.get(m3-option.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } /// Outputs density theme styles for the mat-option. /// @param {Map} $theme The theme to generate density styles for. @mixin density($theme) { + $tokens: map.get(m2-option.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - // There are no M3 density tokens for this component - } @else { + $tokens: map.get(m3-option.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-option.$prefix, - tokens: m2-option.get-token-slots(), + namespace: option, + tokens: token-utils.get-overrides(m3-option.get-tokens(), option) ), ); } @@ -77,37 +76,28 @@ /// Outputs the CSS variable values for the given tokens. /// @param {Map} $tokens The token values to emit. @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } /// Outputs all (base, color, typography, and density) theme styles for the mat-option. /// @param {Map} $theme The theme to generate styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the selected option: primary, secondary, -/// tertiary, or error (If not specified, default secondary color will be used). -@mixin theme($theme, $options...) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-option') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme), $options...); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin theme($theme, $color-variant: null) { + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme, $color-variant); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } } } - -@mixin _theme-from-tokens($tokens, $options...) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - $mat-option-tokens: token-utils.get-tokens-for($tokens, m2-option.$prefix, $options...); - @include token-utils.create-token-values(m2-option.$prefix, $mat-option-tokens); -} diff --git a/src/material/core/option/optgroup.scss b/src/material/core/option/optgroup.scss index 548ac49bd347..6233fcb2a7f0 100644 --- a/src/material/core/option/optgroup.scss +++ b/src/material/core/option/optgroup.scss @@ -1,16 +1,16 @@ -@use './m2-optgroup'; +@use './m3-optgroup'; @use '../tokens/token-utils'; +$fallbacks: m3-optgroup.get-tokens(); + .mat-mdc-optgroup { // These tokens are set on the root option group to make them easier to override. - @include token-utils.use-tokens(m2-optgroup.$prefix, m2-optgroup.get-token-slots()) { - color: token-utils.slot(label-text-color); - font-family: token-utils.slot(label-text-font); - line-height: token-utils.slot(label-text-line-height); - font-size: token-utils.slot(label-text-size); - letter-spacing: token-utils.slot(label-text-tracking); - font-weight: token-utils.slot(label-text-weight); - } + color: token-utils.slot(optgroup-label-text-color, $fallbacks); + font-family: token-utils.slot(optgroup-label-text-font, $fallbacks); + line-height: token-utils.slot(optgroup-label-text-line-height, $fallbacks); + font-size: token-utils.slot(optgroup-label-text-size, $fallbacks); + letter-spacing: token-utils.slot(optgroup-label-text-tracking, $fallbacks); + font-weight: token-utils.slot(optgroup-label-text-weight, $fallbacks); } .mat-mdc-optgroup-label { diff --git a/src/material/core/option/option.scss b/src/material/core/option/option.scss index 67a39fa6be8d..af80549ebed5 100644 --- a/src/material/core/option/option.scss +++ b/src/material/core/option/option.scss @@ -1,11 +1,12 @@ @use '@angular/cdk'; - -@use './m2-option'; -@use '../../list/m2-list'; +@use './m3-option'; +@use '../../list/m3-list'; @use '../tokens/token-utils'; @use '../style/vendor-prefixes'; @use '../style/layout-common'; +$fallbacks: m3-option.get-tokens(); + $_side-padding: 16px; .mat-mdc-option { @@ -21,40 +22,38 @@ $_side-padding: 16px; cursor: pointer; -webkit-tap-highlight-color: transparent; - @include token-utils.use-tokens(m2-option.$prefix, m2-option.get-token-slots()) { - color: token-utils.slot(label-text-color); - font-family: token-utils.slot(label-text-font); - line-height: token-utils.slot(label-text-line-height); - font-size: token-utils.slot(label-text-size); - letter-spacing: token-utils.slot(label-text-tracking); - font-weight: token-utils.slot(label-text-weight); - - // Increase specificity to override styles from list theme. - &:hover:not(.mdc-list-item--disabled) { - background-color: token-utils.slot(hover-state-layer-color); - } + color: token-utils.slot(option-label-text-color, $fallbacks); + font-family: token-utils.slot(option-label-text-font, $fallbacks); + line-height: token-utils.slot(option-label-text-line-height, $fallbacks); + font-size: token-utils.slot(option-label-text-size, $fallbacks); + letter-spacing: token-utils.slot(option-label-text-tracking, $fallbacks); + font-weight: token-utils.slot(option-label-text-weight, $fallbacks); - &:focus.mdc-list-item, - &.mat-mdc-option-active.mdc-list-item { - background-color: token-utils.slot(focus-state-layer-color); - outline: 0; - } + // Increase specificity to override styles from list theme. + &:hover:not(.mdc-list-item--disabled) { + background-color: token-utils.slot(option-hover-state-layer-color, $fallbacks); + } - &.mdc-list-item--selected:not(.mdc-list-item--disabled) { - // We don't change the background & text color in multiple mode since - // it has the checkbox to show the selected state. - &:not(.mat-mdc-option-multiple) { - background-color: token-utils.slot(selected-state-layer-color); - .mdc-list-item__primary-text { - color: token-utils.slot(selected-state-label-text-color); - } + &:focus.mdc-list-item, + &.mat-mdc-option-active.mdc-list-item { + background-color: token-utils.slot(option-focus-state-layer-color, $fallbacks); + outline: 0; + } + + &.mdc-list-item--selected:not(.mdc-list-item--disabled) { + // We don't change the background & text color in multiple mode since + // it has the checkbox to show the selected state. + &:not(.mat-mdc-option-multiple) { + background-color: token-utils.slot(option-selected-state-layer-color, $fallbacks); + .mdc-list-item__primary-text { + color: token-utils.slot(option-selected-state-label-text-color, $fallbacks); } } + } - .mat-pseudo-checkbox { - --mat-pseudo-checkbox-minimal-selected-checkmark-color: #{ - token-utils.slot(selected-state-label-text-color)}; - } + .mat-pseudo-checkbox { + --mat-pseudo-checkbox-minimal-selected-checkmark-color: #{ + token-utils.slot(option-selected-state-label-text-color, $fallbacks)}; } &.mdc-list-item { @@ -180,16 +179,15 @@ $_side-padding: 16px; } } +$list-fallbacks: m3-list.get-tokens(); + .mat-mdc-option-multiple { // Multi-select options in the selected state aren't supposed to change their background color, // because the checkbox already indicates that they're selected. This happened to work in M2, // due to `list-item-selected-container-color` being the same as `list-item-container-color`, // but that's no longer the case in M3. This overrides ensures that the appearance is consistent. - @include token-utils.use-tokens(m2-list.$prefix, m2-list.get-token-slots()) { - $selected-token: token-utils.get-token-variable-name(list-item-selected-container-color); - $base-token: token-utils.slot(list-item-container-color, $fallback: transparent); - #{$selected-token}: $base-token; - } + --mat-list-list-item-selected-container-color: #{ + token-utils.slot(list-list-item-container-color, $list-fallbacks, $fallback: transparent)}; } // For options, render the focus indicator when the class .mat-mdc-option-active is present. diff --git a/src/material/core/option/option.ts b/src/material/core/option/option.ts index 1edd226780ed..f28e15291cb8 100644 --- a/src/material/core/option/option.ts +++ b/src/material/core/option/option.ts @@ -25,6 +25,7 @@ import { inject, isSignal, Signal, + signal, } from '@angular/core'; import {Subject} from 'rxjs'; import {MAT_OPTGROUP, MatOptgroup} from './optgroup'; @@ -87,7 +88,6 @@ export class MatOption implements FocusableOption, AfterViewChecked, On private _signalDisableRipple = false; private _selected = false; private _active = false; - private _disabled = false; private _mostRecentViewValue = ''; /** Whether the wrapping component is in multiple selection mode. */ @@ -109,11 +109,12 @@ export class MatOption implements FocusableOption, AfterViewChecked, On /** Whether the option is disabled. */ @Input({transform: booleanAttribute}) get disabled(): boolean { - return (this.group && this.group.disabled) || this._disabled; + return (this.group && this.group.disabled) || this._disabled(); } set disabled(value: boolean) { - this._disabled = value; + this._disabled.set(value); } + private _disabled = signal(false); /** Whether ripples for the option are disabled. */ get disableRipple(): boolean { diff --git a/src/material/core/ripple/_m2-ripple.scss b/src/material/core/ripple/_m2-ripple.scss index bf783d148b73..61ea2aec4799 100644 --- a/src/material/core/ripple/_m2-ripple.scss +++ b/src/material/core/ripple/_m2-ripple.scss @@ -1,49 +1,17 @@ -@use 'sass:meta'; +@use 'sass:map'; @use '../tokens/m2-utils'; -@use '../theming/inspection'; -@use '../style/sass-utils'; +@use '../tokens/m3-utils'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, ripple); - -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - @return (); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - $is-dark: inspection.get-theme-type($theme) == dark; - $base: inspection.get-theme-color($theme, foreground, base); - - // If the base is a color *type* we can use it directly in the `rgba` call below. - // If it's anything else (e.g. a CSS variable) we fall back to using static colors - // since we don't have a way of adjusting the opacity. - $color: if(meta.type-of($base) == color, $base, if($is-dark, #fff, #000)); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); @return ( - color: rgba($color, 0.1), - ); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return (); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + base: (), + color: ( + ripple-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, pressed-state-layer-opacity)), + ), + typography: (), + density: (), ); } diff --git a/src/material/core/ripple/_m3-ripple.scss b/src/material/core/ripple/_m3-ripple.scss index 67e95dc691ee..2a3949c98b70 100644 --- a/src/material/core/ripple/_m3-ripple.scss +++ b/src/material/core/ripple/_m3-ripple.scss @@ -1,19 +1,17 @@ @use 'sass:map'; -@use '../style/sass-utils'; @use '../tokens/m3-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, ripple); +@use '../tokens/m3'; /// Generates custom tokens for the mat-ripple. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-ripple -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: ( - color: sass-utils.safe-color-change(map.get($systems, md-sys-color, on-surface), $alpha: 0.1), - ); +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); - @return m3-utils.namespace($prefix, $tokens, $token-slots); + @return ( + base: (), + color: ( + ripple-color: m3-utils.color-with-opacity(map.get($system, on-surface), 10%), + ), + typography: (), + density: (), + ); } diff --git a/src/material/core/ripple/_ripple-theme.scss b/src/material/core/ripple/_ripple-theme.scss index b9def167b960..f69a7f0cc34f 100644 --- a/src/material/core/ripple/_ripple-theme.scss +++ b/src/material/core/ripple/_ripple-theme.scss @@ -1,98 +1,75 @@ @use 'sass:map'; @use './m2-ripple'; +@use './m3-ripple'; @use '../tokens/token-utils'; -@use '../style/sass-utils'; -@use '../theming/theming'; @use '../theming/inspection'; -@use '../theming/validation'; @mixin base($theme) { + $tokens: map.get(m2-ripple.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { + $tokens: map.get(m3-ripple.get-tokens($theme), base); } + + @include token-utils.values($tokens); } @mixin color($theme) { + $tokens: map.get(m2-ripple.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-ripple.$prefix, - m2-ripple.get-color-tokens($theme) - ); - } + $tokens: map.get(m3-ripple.get-tokens($theme), color); } + + @include token-utils.values($tokens); } @mixin typography($theme) { + $tokens: map.get(m2-ripple.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-ripple.$prefix, - m2-ripple.get-typography-tokens($theme) - ); - } + $tokens: map.get(m3-ripple.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } @mixin density($theme) { + $tokens: map.get(m2-ripple.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - // There are no M3 density tokens for this component - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-ripple.$prefix, - m2-ripple.get-density-tokens($theme) - ); - } + $tokens: map.get(m3-ripple.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-ripple.$prefix, - tokens: m2-ripple.get-token-slots(), + namespace: ripple, + tokens: token-utils.get-overrides(m3-ripple.get-tokens(), ripple) ), ); } @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } @mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-ripple') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - @include token-utils.create-token-values( - m2-ripple.$prefix, - map.get($tokens, m2-ripple.$prefix) - ); } } diff --git a/src/material/core/ripple/_ripple.scss b/src/material/core/ripple/_ripple.scss index 54e12ec439f4..103ff5877e6d 100644 --- a/src/material/core/ripple/_ripple.scss +++ b/src/material/core/ripple/_ripple.scss @@ -1,7 +1,9 @@ @use '@angular/cdk'; -@use './m2-ripple'; +@use './m3-ripple'; @use '../tokens/token-utils'; +$fallbacks: m3-ripple.get-tokens(); + @mixin ripple() { // The host element of an mat-ripple directive should always have a position of "absolute" or // "relative" so that the ripples inside are correctly positioned relatively to the container. @@ -37,10 +39,8 @@ // the ripples aren't clipped when inside the shadow DOM (see #24028). transform: scale3d(0, 0, 0); - @include token-utils.use-tokens(m2-ripple.$prefix, m2-ripple.get-token-slots()) { - // We have to emit a fallback value here, because some internal builds depend on it. - background-color: token-utils.slot(color, $fallback: rgba(#000, 0.1)); - } + // We have to emit a fallback value here, because some internal builds depend on it. + background-color: token-utils.slot(ripple-color, $fallbacks, $fallback: rgba(#000, 0.1)); // In high contrast mode the ripple is opaque, causing it to obstruct the content. @include cdk.high-contrast { diff --git a/src/material/core/selection/pseudo-checkbox/BUILD.bazel b/src/material/core/selection/pseudo-checkbox/BUILD.bazel index 7fa6756d4d92..744a69374472 100644 --- a/src/material/core/selection/pseudo-checkbox/BUILD.bazel +++ b/src/material/core/selection/pseudo-checkbox/BUILD.bazel @@ -48,6 +48,7 @@ sass_library( "_pseudo-checkbox-theme.scss", ], deps = [ + ":m2", "//src/material/core/style:sass_utils", "//src/material/core/theming", "//src/material/core/theming:_inspection", diff --git a/src/material/core/selection/pseudo-checkbox/_m2-pseudo-checkbox.scss b/src/material/core/selection/pseudo-checkbox/_m2-pseudo-checkbox.scss index d52c28906fef..7d40a631cbe7 100644 --- a/src/material/core/selection/pseudo-checkbox/_m2-pseudo-checkbox.scss +++ b/src/material/core/selection/pseudo-checkbox/_m2-pseudo-checkbox.scss @@ -1,51 +1,32 @@ +@use 'sass:map'; @use '../../tokens/m2-utils'; -@use '../../theming/inspection'; -@use '../../style/sass-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, pseudo-checkbox); - -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - @return (); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme, $palette-name: accent) { - $is-dark: inspection.get-theme-type($theme) == dark; - $disabled-color: if($is-dark, #686868, #b0b0b0); - $checkmark-color: inspection.get-theme-color($theme, background, background); +@use '../../tokens/m3-utils'; +@function get-tokens($theme) { @return ( - full-selected-icon-color: inspection.get-theme-color($theme, $palette-name), - full-selected-checkmark-color: $checkmark-color, - full-unselected-icon-color: inspection.get-theme-color($theme, foreground, secondary-text), - full-disabled-selected-checkmark-color: $checkmark-color, - full-disabled-unselected-icon-color: $disabled-color, - full-disabled-selected-icon-color: $disabled-color, - minimal-selected-checkmark-color: inspection.get-theme-color($theme, $palette-name), - minimal-disabled-selected-checkmark-color: if($is-dark, #686868, #b0b0b0), + base: (), + color: private-get-color-palette-color-tokens($theme, secondary), + typography: (), + density: (), ); } -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return (); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} +// Tokens that can be configured through Angular Material's color theming API. +@function private-get-color-palette-color-tokens($theme, $color-variant) { + $system: m2-utils.get-system($theme); + $system: m3-utils.replace-colors-with-variant($system, secondary, $color-variant); -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + @return ( + pseudo-checkbox-full-selected-icon-color: map.get($system, secondary), + pseudo-checkbox-full-selected-checkmark-color: map.get($system, background), + pseudo-checkbox-full-unselected-icon-color: map.get($system, on-surface-variant), + pseudo-checkbox-full-disabled-selected-checkmark-color: map.get($system, background), + pseudo-checkbox-full-disabled-unselected-icon-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + pseudo-checkbox-full-disabled-selected-icon-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + pseudo-checkbox-minimal-selected-checkmark-color: map.get($system, secondary), + pseudo-checkbox-minimal-disabled-selected-checkmark-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), ); } diff --git a/src/material/core/selection/pseudo-checkbox/_m3-pseudo-checkbox.scss b/src/material/core/selection/pseudo-checkbox/_m3-pseudo-checkbox.scss index 38b3d66ae199..ff45b0153e82 100644 --- a/src/material/core/selection/pseudo-checkbox/_m3-pseudo-checkbox.scss +++ b/src/material/core/selection/pseudo-checkbox/_m3-pseudo-checkbox.scss @@ -1,49 +1,34 @@ @use 'sass:map'; -@use '../../style/sass-utils'; @use '../../tokens/m3-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, pseudo-checkbox); +@use '../../tokens/m3'; /// Generates custom tokens for the full variant of mat-pseudo-checkbox. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the full variant of mat-pseudo-checkbox -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: ( - full-disabled-selected-checkmark-color: map.get($systems, md-sys-color, surface), - full-disabled-selected-icon-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - full-disabled-unselected-icon-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - full-selected-checkmark-color: map.get($systems, md-sys-color, on-primary), - full-selected-icon-color: map.get($systems, md-sys-color, primary), - full-unselected-icon-color: map.get($systems, md-sys-color, on-surface-variant), - minimal-disabled-selected-checkmark-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - minimal-selected-checkmark-color: map.get($systems, md-sys-color, primary), - ); +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@function get-tokens($theme: m3.$sys-theme, $color-variant: null) { + $system: m3-utils.get-system($theme); + @if $color-variant { + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); + } - $variant-tokens: ( - // Color variants: - primary: (), // Default, no overrides needed. - secondary: ( - full-selected-checkmark-color: map.get($systems, md-sys-color, on-secondary), - full-selected-icon-color: map.get($systems, md-sys-color, secondary), - minimal-selected-checkmark-color: map.get($systems, md-sys-color, secondary), + @return ( + base: (), + color: ( + pseudo-checkbox-full-disabled-selected-checkmark-color: map.get($system, surface), + pseudo-checkbox-full-disabled-selected-icon-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + pseudo-checkbox-full-disabled-unselected-icon-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + pseudo-checkbox-full-selected-checkmark-color: map.get($system, on-primary), + pseudo-checkbox-full-selected-icon-color: map.get($system, primary), + pseudo-checkbox-full-unselected-icon-color: map.get($system, on-surface-variant), + pseudo-checkbox-minimal-disabled-selected-checkmark-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + pseudo-checkbox-minimal-selected-checkmark-color: map.get($system, primary), ), - tertiary: ( - full-selected-checkmark-color: map.get($systems, md-sys-color, on-tertiary), - full-selected-icon-color: map.get($systems, md-sys-color, tertiary), - minimal-selected-checkmark-color: map.get($systems, md-sys-color, tertiary), - ), - error: ( - full-selected-checkmark-color: map.get($systems, md-sys-color, on-error), - full-selected-icon-color: map.get($systems, md-sys-color, error), - minimal-selected-checkmark-color: map.get($systems, md-sys-color, error), - ) + typography: (), + density: (), ); - @return m3-utils.namespace($prefix, ($tokens, $variant-tokens), $token-slots); + $tokens: ( + ); } diff --git a/src/material/core/selection/pseudo-checkbox/_pseudo-checkbox-theme.scss b/src/material/core/selection/pseudo-checkbox/_pseudo-checkbox-theme.scss index 92da5270c904..bbfff2857750 100644 --- a/src/material/core/selection/pseudo-checkbox/_pseudo-checkbox-theme.scss +++ b/src/material/core/selection/pseudo-checkbox/_pseudo-checkbox-theme.scss @@ -1,111 +1,110 @@ -@use '../../theming/theming'; @use '../../theming/inspection'; -@use '../../theming/validation'; -@use '../../style/sass-utils'; @use '../../tokens/token-utils'; @use './m2-pseudo-checkbox'; +@use './m3-pseudo-checkbox'; +@use 'sass:map'; @mixin _palette-styles($theme, $palette-name) { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-pseudo-checkbox.$prefix, - m2-pseudo-checkbox.get-color-tokens($theme, $palette-name) - ); - } + $tokens: map.get(m2-pseudo-checkbox.get-tokens($theme), color); + @include token-utils.values($tokens); } -@mixin _theme-from-tokens($tokens, $options...) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - $mat-pseudo-checkbox-tokens: token-utils.get-tokens-for( - $tokens, m2-pseudo-checkbox.$prefix, $options...); - @include token-utils.create-token-values-mixed( - m2-pseudo-checkbox.$prefix, $mat-pseudo-checkbox-tokens); -} - -/// Outputs base theme styles (styles not dependent on the color, typography, or density settings) -/// for the mat-pseudo-checkbox. -/// @param {Map} $theme The theme to generate base styles for. @mixin base($theme) { -} + $tokens: map.get(m2-pseudo-checkbox.get-tokens($theme), base); + @if inspection.get-theme-version($theme) == 1 { + $tokens: map.get(m3-pseudo-checkbox.get-tokens($theme), base); + } -/// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. -@function _define-overrides() { - @return ( - ( - namespace: m2-pseudo-checkbox.$prefix, - tokens: m2-pseudo-checkbox.get-token-slots(), - ), - ); + @include token-utils.values($tokens); } /// Outputs the CSS variable values for the given tokens. /// @param {Map} $tokens The token values to emit. @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } /// Outputs color theme styles for the mat-pseudo-checkbox. /// @param {Map} $theme The theme to generate color styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the pseudo-checkbox: primary, secondary, -/// tertiary, or error (If not specified, default primary color will be used). -@mixin color($theme, $options...) { +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin color($theme, $color-variant: null) { + $tokens: map.get(m2-pseudo-checkbox.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color), $options...); - } @else { + $tokens: map.get(m3-pseudo-checkbox.get-tokens($theme, $color-variant), color); + } + + @include token-utils.values($tokens); + + @if inspection.get-theme-version($theme) != 1 { // Default to the accent color. Note that the pseudo checkboxes are meant to inherit the // theme from their parent, rather than implementing their own theming, which is why we // don't attach to the `mat-*` classes. Also note that this needs to be below `.mat-primary` // in order to allow for the color to be overwritten if the checkbox is inside a parent that // has `mat-accent` and is placed inside another parent that has `mat-primary`. - @include _palette-styles($theme, accent); - .mat-primary { - @include _palette-styles($theme, primary); + $tokens: m2-pseudo-checkbox.private-get-color-palette-color-tokens($theme, primary); + @include token-utils.values($tokens); } .mat-accent { - @include _palette-styles($theme, accent); + $tokens: m2-pseudo-checkbox.private-get-color-palette-color-tokens($theme, secondary); + @include token-utils.values($tokens); } .mat-warn { - @include _palette-styles($theme, warn); + $tokens: m2-pseudo-checkbox.private-get-color-palette-color-tokens($theme, error); + @include token-utils.values($tokens); } } } -/// Outputs typography theme styles for the mat-pseudo-checkbox. -/// @param {Map} $theme The theme to generate typography styles for. @mixin typography($theme) { + $tokens: map.get(m2-pseudo-checkbox.get-tokens($theme), typography); + @if inspection.get-theme-version($theme) == 1 { + $tokens: map.get(m3-pseudo-checkbox.get-tokens($theme), typography); + } + + @include token-utils.values($tokens); } -/// Outputs density theme styles for the mat-pseudo-checkbox. -/// @param {Map} $theme The theme to generate density styles for. @mixin density($theme) { + $tokens: map.get(m2-pseudo-checkbox.get-tokens($theme), density); + @if inspection.get-theme-version($theme) == 1 { + $tokens: map.get(m3-pseudo-checkbox.get-tokens($theme), density); + } + + @include token-utils.values($tokens); +} + +/// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. +@function _define-overrides() { + @return ( + ( + namespace: pseudo-checkbox, + tokens: token-utils.get-overrides(m3-pseudo-checkbox.get-tokens(), pseudo-checkbox) + ), + ); } /// Outputs all (base, color, typography, and density) theme styles for the mat-pseudo-checkbox. /// @param {Map} $theme The theme to generate styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the pseudo-checkbox: primary, secondary, -/// tertiary, or error (If not specified, default primary color will be used). -@mixin theme($theme, $options...) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-pseudo-checkbox') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme), $options...); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin theme($theme, $color-variant: null) { + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme, $color-variant); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } } } diff --git a/src/material/core/selection/pseudo-checkbox/pseudo-checkbox.scss b/src/material/core/selection/pseudo-checkbox/pseudo-checkbox.scss index da6a89c46319..363c5486cf60 100644 --- a/src/material/core/selection/pseudo-checkbox/pseudo-checkbox.scss +++ b/src/material/core/selection/pseudo-checkbox/pseudo-checkbox.scss @@ -3,9 +3,11 @@ @use '../../style/private'; @use '../../style/variables'; @use '../../tokens/token-utils'; -@use './m2-pseudo-checkbox'; +@use './m3-pseudo-checkbox'; @use './pseudo-checkbox-common'; +$fallbacks: m3-pseudo-checkbox.get-tokens(); + .mat-pseudo-checkbox { border-radius: 2px; cursor: pointer; @@ -53,43 +55,42 @@ box-sizing: content-box; } -@include token-utils.use-tokens( - m2-pseudo-checkbox.$prefix, m2-pseudo-checkbox.get-token-slots()) { - .mat-pseudo-checkbox-minimal { - &.mat-pseudo-checkbox-checked, &.mat-pseudo-checkbox-indeterminate { - &::after { - color: token-utils.slot(minimal-selected-checkmark-color); - } +.mat-pseudo-checkbox-minimal { + &.mat-pseudo-checkbox-checked, &.mat-pseudo-checkbox-indeterminate { + &::after { + color: token-utils.slot(pseudo-checkbox-minimal-selected-checkmark-color, $fallbacks); + } - &.mat-pseudo-checkbox-disabled::after { - color: token-utils.slot(minimal-disabled-selected-checkmark-color); - } - } + &.mat-pseudo-checkbox-disabled::after { + color: token-utils.slot( + pseudo-checkbox-minimal-disabled-selected-checkmark-color, $fallbacks); + } + } +} + +.mat-pseudo-checkbox-full { + border-color: token-utils.slot(pseudo-checkbox-full-unselected-icon-color, $fallbacks); + border-width: checkbox-common.$border-width; + border-style: solid; + + &.mat-pseudo-checkbox-disabled { + border-color: token-utils.slot(pseudo-checkbox-full-disabled-unselected-icon-color, $fallbacks); } - .mat-pseudo-checkbox-full { - border-color: token-utils.slot(full-unselected-icon-color); - border-width: checkbox-common.$border-width; - border-style: solid; + &.mat-pseudo-checkbox-checked, &.mat-pseudo-checkbox-indeterminate { + background-color: token-utils.slot(pseudo-checkbox-full-selected-icon-color, $fallbacks); + border-color: transparent; - &.mat-pseudo-checkbox-disabled { - border-color: token-utils.slot(full-disabled-unselected-icon-color); + &::after { + color: token-utils.slot(pseudo-checkbox-full-selected-checkmark-color, $fallbacks); } - &.mat-pseudo-checkbox-checked, &.mat-pseudo-checkbox-indeterminate { - background-color: token-utils.slot(full-selected-icon-color); - border-color: transparent; + &.mat-pseudo-checkbox-disabled { + background-color: token-utils.slot( + pseudo-checkbox-full-disabled-selected-icon-color, $fallbacks); &::after { - color: token-utils.slot(full-selected-checkmark-color); - } - - &.mat-pseudo-checkbox-disabled { - background-color: token-utils.slot(full-disabled-selected-icon-color); - - &::after { - color: token-utils.slot(full-disabled-selected-checkmark-color); - } + color: token-utils.slot(pseudo-checkbox-full-disabled-selected-checkmark-color, $fallbacks); } } } diff --git a/src/material/core/style/BUILD.bazel b/src/material/core/style/BUILD.bazel index 663f7ffff165..959dc5a7c22f 100644 --- a/src/material/core/style/BUILD.bazel +++ b/src/material/core/style/BUILD.bazel @@ -22,15 +22,6 @@ sass_library( ], ) -sass_library( - name = "form_common", - srcs = ["_form-common.scss"], - deps = [ - ":sass_utils", - "//src/material/core/theming:_inspection", - ], -) - sass_library( name = "layout_common", srcs = ["_layout-common.scss"], @@ -57,6 +48,7 @@ sass_library( deps = [ ":elevation", "//src/material/core/theming:_inspection", + "//src/material/core/tokens:m2_utils", ], ) diff --git a/src/material/core/style/_form-common.scss b/src/material/core/style/_form-common.scss deleted file mode 100644 index fcb697d667c0..000000000000 --- a/src/material/core/style/_form-common.scss +++ /dev/null @@ -1,21 +0,0 @@ -@use '../theming/inspection'; -@use './sass-utils'; - -// Renders a gradient for showing the dashed line when the input is disabled. -// Unlike using a border, a gradient allows us to adjust the spacing of the dotted line -// to match the Material Design spec. -@mixin private-control-disabled-underline($color) { - background-image: linear-gradient(to right, $color 0%, $color 33%, transparent 0%); - background-size: 4px 100%; - background-repeat: repeat-x; -} - -// Figures out the color of the placeholder for a form control. -// Used primarily to prevent the various form controls from -// becoming out of sync since these colors aren't in a palette. -@function private-control-placeholder-color($theme) { - $is-dark-theme: inspection.get-theme-type($theme) == dark; - @return sass-utils.safe-color-change( - inspection.get-theme-color($theme, foreground, secondary-text), - $alpha: if($is-dark-theme, 0.5, 0.42)); -} diff --git a/src/material/core/style/_private.scss b/src/material/core/style/_private.scss index 026717998738..9274ce8365be 100644 --- a/src/material/core/style/_private.scss +++ b/src/material/core/style/_private.scss @@ -1,18 +1,15 @@ @use './elevation'; -@use '../theming/inspection'; +@use '../tokens/m2-utils'; +@use 'sass:map'; @mixin private-theme-elevation($zValue, $theme) { - $elevation-color: inspection.get-theme-color($theme, foreground, elevation); - $elevation-color-or-default: if($elevation-color == null, elevation.$color, $elevation-color); - - @include elevation.elevation($zValue, $elevation-color-or-default); + $system: m2-utils.get-system($theme); + @include elevation.elevation($zValue, map.get($system, shadow)); } @mixin private-theme-overridable-elevation($zValue, $theme) { - $elevation-color: inspection.get-theme-color($theme, foreground, elevation); - $elevation-color-or-default: if($elevation-color == null, elevation.$color, $elevation-color); - - @include elevation.overridable-elevation($zValue, $elevation-color-or-default); + $system: m2-utils.get-system($theme); + @include elevation.overridable-elevation($zValue, map.get($system, shadow)); } // If the mat-animation-noop class is present on the components root element, diff --git a/src/material/core/style/_sass-utils.scss b/src/material/core/style/_sass-utils.scss index 0d51c92e0f32..2bc7a72ac72f 100644 --- a/src/material/core/style/_sass-utils.scss +++ b/src/material/core/style/_sass-utils.scss @@ -1,6 +1,4 @@ -@use 'sass:color'; @use 'sass:string'; -@use 'sass:list'; @use 'sass:map'; @use 'sass:meta'; @@ -52,62 +50,6 @@ $use-system-typography-variables: false; @return $result; } -/// Coerces the given value to a list, by converting any non-list value into a single-item list. -/// This should be used when dealing with user-passed lists of args to avoid confusing errors, -/// since Sass treats `($x)` as equivalent to `$x`. -/// @param {Any} $value The value to coerce to a list. -/// @return {List} The original $value if it was a list, otherwise a single-item list containing -/// $value. -@function coerce-to-list($value) { - @return if(meta.type-of($value) != 'list', ($value,), $value); -} - -/// A version of the Sass `color.change` function that is safe ot use with CSS variables. -@function safe-color-change($color, $args...) { - $args: meta.keywords($args); - $use-color-mix: $use-system-color-variables or - (is-css-var-name($color) and string.index($color, '--mat') == 1); - @if (meta.type-of($color) == 'color') { - @return color.change($color, $args...); - } - @else if ($color != null and - map.get($args, alpha) != null and $use-color-mix) { - $opacity: map.get($args, alpha); - @if meta.type-of($opacity) == number { - $opacity: ($opacity * 100) + '%'; - } - - @if (is-css-var-name($opacity)) { - $opacity: calc(var($opacity) * 100%); - } - - @if (is-css-var-name($color)) { - $color: var($color); - } - - @return #{color-mix(in srgb, #{$color} #{$opacity}, transparent)}; - } - @return $color; -} - -/// Gets the given arguments as a map of keywords and validates that only supported arguments were -/// passed. -/// @param {ArgList} $args The arguments to convert to a keywords map. -/// @param {List} $supported-args The supported argument names. -/// @return {Map} The $args as a map of argument name to argument value. -@function validate-keyword-args($args, $supported-args) { - @if list.length($args) > 0 { - @error #{'Expected keyword args, but got positional args: '}#{$args}; - } - $kwargs: meta.keywords($args); - @each $arg, $v in $kwargs { - @if list.index($supported-args, $arg) == null { - @error #{'Unsupported argument '}#{$arg}#{'. Valid arguments are: '}#{$supported-args}; - } - } - @return $kwargs; -} - // Returns whether the $value is a CSS variable name based on whether it's a string prefixed // by "--". @function is-css-var-name($value) { diff --git a/src/material/core/theming/_all-theme.scss b/src/material/core/theming/_all-theme.scss index 66823927319b..3f101ef80f41 100644 --- a/src/material/core/theming/_all-theme.scss +++ b/src/material/core/theming/_all-theme.scss @@ -37,51 +37,47 @@ @use '../../snack-bar/snack-bar-theme'; @use '../../form-field/form-field-theme'; @use '../../timepicker/timepicker-theme'; -@use './theming'; // Create a theme. @mixin all-component-themes($theme) { - $dedupe-key: 'angular-material-theme'; - @include theming.private-check-duplicate-theme-styles($theme, $dedupe-key) { - @include core-theme.theme($theme); - @include card-theme.theme($theme); - @include progress-bar-theme.theme($theme); - @include tooltip-theme.theme($theme); - @include form-field-theme.theme($theme); - @include input-theme.theme($theme); - @include select-theme.theme($theme); - @include autocomplete-theme.theme($theme); - @include dialog-theme.theme($theme); - @include chips-theme.theme($theme); - @include slide-toggle-theme.theme($theme); - @include radio-theme.theme($theme); - @include slider-theme.theme($theme); - @include menu-theme.theme($theme); - @include list-theme.theme($theme); - @include paginator-theme.theme($theme); - @include tabs-theme.theme($theme); - @include checkbox-theme.theme($theme); - @include button-theme.theme($theme); - @include icon-button-theme.theme($theme); - @include fab-theme.theme($theme); - @include snack-bar-theme.theme($theme); - @include table-theme.theme($theme); - @include progress-spinner-theme.theme($theme); - @include badge-theme.theme($theme); - @include bottom-sheet-theme.theme($theme); - @include button-toggle-theme.theme($theme); - @include datepicker-theme.theme($theme); - @include divider-theme.theme($theme); - @include expansion-theme.theme($theme); - @include grid-list-theme.theme($theme); - @include icon-theme.theme($theme); - @include sidenav-theme.theme($theme); - @include stepper-theme.theme($theme); - @include sort-theme.theme($theme); - @include toolbar-theme.theme($theme); - @include tree-theme.theme($theme); - @include timepicker-theme.theme($theme); - } + @include core-theme.theme($theme); + @include card-theme.theme($theme); + @include progress-bar-theme.theme($theme); + @include tooltip-theme.theme($theme); + @include form-field-theme.theme($theme); + @include input-theme.theme($theme); + @include select-theme.theme($theme); + @include autocomplete-theme.theme($theme); + @include dialog-theme.theme($theme); + @include chips-theme.theme($theme); + @include slide-toggle-theme.theme($theme); + @include radio-theme.theme($theme); + @include slider-theme.theme($theme); + @include menu-theme.theme($theme); + @include list-theme.theme($theme); + @include paginator-theme.theme($theme); + @include tabs-theme.theme($theme); + @include checkbox-theme.theme($theme); + @include button-theme.theme($theme); + @include icon-button-theme.theme($theme); + @include fab-theme.theme($theme); + @include snack-bar-theme.theme($theme); + @include table-theme.theme($theme); + @include progress-spinner-theme.theme($theme); + @include badge-theme.theme($theme); + @include bottom-sheet-theme.theme($theme); + @include button-toggle-theme.theme($theme); + @include datepicker-theme.theme($theme); + @include divider-theme.theme($theme); + @include expansion-theme.theme($theme); + @include grid-list-theme.theme($theme); + @include icon-theme.theme($theme); + @include sidenav-theme.theme($theme); + @include stepper-theme.theme($theme); + @include sort-theme.theme($theme); + @include toolbar-theme.theme($theme); + @include tree-theme.theme($theme); + @include timepicker-theme.theme($theme); } @mixin all-component-bases($theme) { @@ -125,6 +121,47 @@ @include timepicker-theme.base($theme); } +@mixin all-component-colors($theme) { + @include core-theme.color($theme); + @include card-theme.color($theme); + @include progress-bar-theme.color($theme); + @include tooltip-theme.color($theme); + @include form-field-theme.color($theme); + @include input-theme.color($theme); + @include select-theme.color($theme); + @include autocomplete-theme.color($theme); + @include dialog-theme.color($theme); + @include chips-theme.color($theme); + @include slide-toggle-theme.color($theme); + @include radio-theme.color($theme); + @include slider-theme.color($theme); + @include menu-theme.color($theme); + @include list-theme.color($theme); + @include paginator-theme.color($theme); + @include tabs-theme.color($theme); + @include checkbox-theme.color($theme); + @include button-theme.color($theme); + @include icon-button-theme.color($theme); + @include fab-theme.color($theme); + @include snack-bar-theme.color($theme); + @include table-theme.color($theme); + @include progress-spinner-theme.color($theme); + @include badge-theme.color($theme); + @include bottom-sheet-theme.color($theme); + @include button-toggle-theme.color($theme); + @include datepicker-theme.color($theme); + @include divider-theme.color($theme); + @include expansion-theme.color($theme); + @include grid-list-theme.color($theme); + @include icon-theme.color($theme); + @include sidenav-theme.color($theme); + @include stepper-theme.color($theme); + @include sort-theme.color($theme); + @include toolbar-theme.color($theme); + @include tree-theme.color($theme); + @include timepicker-theme.color($theme); +} + // @deprecated Use `all-component-themes`. @mixin angular-material-theme($theme) { @include all-component-themes($theme); diff --git a/src/material/core/theming/_color-api-backwards-compatibility.scss b/src/material/core/theming/_color-api-backwards-compatibility.scss index b48c2972c27e..0b90e7adcfb5 100644 --- a/src/material/core/theming/_color-api-backwards-compatibility.scss +++ b/src/material/core/theming/_color-api-backwards-compatibility.scss @@ -24,92 +24,109 @@ $_overrides-only: true; @mixin _color-variant-styles($theme, $color-variant) { - $primary-options: (color-variant: $color-variant, emit-overrides-only: $_overrides-only); - - // Some components use the secondary color rather than primary color for `.mat-primary`. - // Those components should use the $secondary-color-variant. - $secondary-options: ( - color-variant: if($color-variant == primary, secondary, $color-variant), - emit-overrides-only: $_overrides-only - ); + $secondary-when-primary: if($color-variant == primary, secondary, $color-variant); & { - @include option-theme.color($theme, $secondary-options...); + @if ($color-variant != primary) { + @include option-theme.color($theme, $secondary-when-primary); + } } & { - @include progress-spinner-theme.color($theme, $primary-options...); + @if ($color-variant != primary) { + @include progress-spinner-theme.color($theme, $color-variant); + } } & { - @include pseudo-checkbox-theme.color($theme, $primary-options...); + @if ($color-variant != primary) { + @include pseudo-checkbox-theme.color($theme, $color-variant); + } } & { - @include stepper-theme.color($theme, $primary-options...); + @include stepper-theme.color($theme, $color-variant); } &.mat-icon { - @include icon-theme.color($theme, $primary-options...); + @include icon-theme.color($theme, $color-variant); } &.mat-mdc-checkbox { - @include checkbox-theme.color($theme, $primary-options...); + @if ($color-variant != primary) { + @include checkbox-theme.color($theme, $color-variant); + } } &.mat-mdc-slider { - @include slider-theme.color($theme, $primary-options...); + @if ($color-variant != primary) { + @include slider-theme.color($theme, $color-variant); + } } &.mat-mdc-tab-group, &.mat-mdc-tab-nav-bar { - @include tabs-theme.color($theme, $primary-options...); + @include tabs-theme.color($theme, $color-variant); } &.mat-mdc-slide-toggle { - @include slide-toggle-theme.color($theme, $primary-options...); + @include slide-toggle-theme.color($theme, $color-variant); } &.mat-mdc-form-field { - @include select-theme.color($theme, $primary-options...); + @if ($color-variant != primary) { + @include select-theme.color($theme, $color-variant); + } } &.mat-mdc-radio-button { - @include radio-theme.color($theme, $primary-options...); + @if ($color-variant != primary) { + @include radio-theme.color($theme, $color-variant); + } } &.mat-mdc-progress-bar { - @include progress-bar-theme.color($theme, $primary-options...); + @if ($color-variant != primary) { + @include progress-bar-theme.color($theme, $color-variant); + } } &.mat-mdc-form-field { - @include form-field-theme.color($theme, $primary-options...); + @if ($color-variant != primary) { + @include form-field-theme.color($theme, $color-variant); + } } &.mat-datepicker-content { - @include datepicker-theme.color($theme, $primary-options...); + @include datepicker-theme.color($theme, $color-variant); } &.mat-mdc-button-base { - @include button-theme.color($theme, $primary-options...); - @include icon-button-theme.color($theme, $primary-options...); + @if ($color-variant != primary) { + @include button-theme.color($theme, $color-variant); + } + @include icon-button-theme.color($theme, $color-variant); } &.mat-mdc-standard-chip { - @include chips-theme.color($theme, $secondary-options...); + @if ($color-variant != primary) { + @include chips-theme.color($theme, $secondary-when-primary); + } } .mdc-list-item__start, .mdc-list-item__end { - @include checkbox-theme.color($theme, $primary-options...); - @include radio-theme.color($theme, $primary-options...); + @if ($color-variant != primary) { + @include checkbox-theme.color($theme, $color-variant); + @include radio-theme.color($theme, $color-variant); + } } // M3 dropped support for warn/error color FABs. - @if $color-variant != error { + @if $color-variant != error and $color-variant != primary { &.mat-mdc-fab, &.mat-mdc-mini-fab { - @include fab-theme.color($theme, $primary-options...); + @include fab-theme.color($theme, $color-variant); } } } @@ -119,23 +136,20 @@ $_overrides-only: true; @include _color-variant-styles($theme, primary); } .mat-badge { - @include badge-theme.color($theme, $color-variant: primary, - $emit-overrides-only: $_overrides-only); + @include badge-theme.color($theme, $color-variant: primary); } .mat-accent { @include _color-variant-styles($theme, tertiary); } .mat-badge-accent { - @include badge-theme.color($theme, $color-variant: tertiary, - $emit-overrides-only: $_overrides-only); + @include badge-theme.color($theme, $color-variant: tertiary); } .mat-warn { @include _color-variant-styles($theme, error); } .mat-badge-warn { - @include badge-theme.color($theme, $color-variant: error, - $emit-overrides-only: $_overrides-only); + @include badge-theme.color($theme, $color-variant: error); } } diff --git a/src/material/core/theming/_definition.scss b/src/material/core/theming/_definition.scss index 5b75b9faf8f2..da0e5013316f 100644 --- a/src/material/core/theming/_definition.scss +++ b/src/material/core/theming/_definition.scss @@ -5,6 +5,7 @@ @use './palettes'; @use '../tokens/m3-tokens'; @use './config-validation'; +@use '../tokens/m3'; // Prefix used for component token fallback variables, e.g. // `color: var(--mat-text-button-label-text-color, var(--mat-sys-primary));` @@ -18,6 +19,7 @@ $system-level-prefix: mat-sys; /// Map key used to store internals of theme config. $internals: _mat-theming-internals-do-not-access; + /// The theme version of generated themes. $theme-version: 1; @@ -30,97 +32,110 @@ $theme-version: 1; @error $err; } - @return sass-utils.deep-merge-all( - define-colors(map.get($config, color) or ()), - define-typography(map.get($config, typography) or ()), - define-density(map.get($config, density) or ()), - ($internals: (base-tokens: m3-tokens.generate-base-tokens())), + $color-config: map.get($config, color) or (); + $typography-config: map.get($config, typography) or (); + $density-config: map.get($config, density) or (); + + // colors + $type: map.get($color-config, theme-type) or light; + $primary: map.get($color-config, primary) or palettes.$violet-palette; + $tertiary: map.get($color-config, tertiary) or $primary; + $color-system-variables-prefix: + map.get($color-config, system-variables-prefix) or $system-level-prefix; + sass-utils.$use-system-color-variables: map.get($color-config, use-system-variables) or false; + + $palettes: ( + primary: map.remove($primary, neutral, neutral-variant, secondary), + secondary: map.get($primary, secondary), + tertiary: map.remove($tertiary, neutral, neutral-variant, secondary, error), + neutral: map.get($primary, neutral), + neutral-variant: map.get($primary, neutral-variant), + error: map.get($primary, error), ); -} -/// Defines an Angular Material theme object with color settings. -/// @param {Map} $config The color configuration -/// @return {Map} A theme object -@function define-colors($config: ()) { - $err: config-validation.validate-color-config($config); - @if $err { - @error $err; - } + // typography + $default-plain: (Roboto, sans-serif); + $default-brand: $default-plain; + $plain: map.get($typography-config, plain-family) or $default-plain; + $brand: map.get($typography-config, brand-family) or $default-brand; + $bold: map.get($typography-config, bold-weight) or 700; + $medium: map.get($typography-config, medium-weight) or 500; + $regular: map.get($typography-config, regular-weight) or 400; + $typography-system-variables-prefix: map.get($typography-config, system-variables-prefix) or + $system-level-prefix; + sass-utils.$use-system-typography-variables: + map.get($typography-config, use-system-variables) or false; + $typography: ( + plain: $plain, + brand: $brand, + bold: $bold, + medium: $medium, + regular: $regular, + ); - $type: map.get($config, theme-type) or light; - $primary: map.get($config, primary) or palettes.$violet-palette; - $tertiary: map.get($config, tertiary) or $primary; - $system-variables-prefix: map.get($config, system-variables-prefix) or $system-level-prefix; - sass-utils.$use-system-color-variables: map.get($config, use-system-variables) or false; + // density + $density-scale: map.get($density-config, scale) or 0; + + $system-maps: ( + m3-tokens.get-sys-color($type, $palettes, $color-system-variables-prefix), + m3.md-sys-elevation-values(), + m3.md-sys-motion-values(), + m3.md-sys-shape-values(), + m3.md-sys-state-values(), + ( + // Form field native select option text color + neutral10: map.get($palettes, neutral, 10), + // Sidenav scrim (container background shadow when opened), + neutral-variant20: map.get($palettes, neutral-variant, 20), + ), + m3-tokens.get-sys-typeface($typography, $typography-system-variables-prefix), + ); + $system: ( + density-scale: $density-scale, + ); + @each $sys-map in $system-maps { + $system: map.merge($system, $sys-map); + } @return ( + _mat-system: $system, $internals: ( - theme-version: $theme-version, + base-tokens: m3-tokens.generate-base-tokens(), + color-system-variables-prefix: $color-system-variables-prefix, + color-tokens: + m3-tokens.generate-color-tokens($type, $palettes, $color-system-variables-prefix), + density-scale: $density-scale, + font-definition: $typography, + palettes: $palettes, theme-type: $type, - palettes: ( - primary: map.remove($primary, neutral, neutral-variant, secondary), - secondary: map.get($primary, secondary), - tertiary: map.remove($tertiary, neutral, neutral-variant, secondary), - neutral: map.get($primary, neutral), - neutral-variant: map.get($primary, neutral-variant), - error: map.get($primary, error), - ), - color-system-variables-prefix: $system-variables-prefix, - color-tokens: m3-tokens.generate-color-tokens( - $type, $primary, $tertiary, map.get($primary, error), $system-variables-prefix) + theme-version: $theme-version, + typography-system-variables-prefix: $typography-system-variables-prefix, + typography-tokens: + m3-tokens.generate-typography-tokens($typography, $typography-system-variables-prefix), ) ); } +/// Defines an Angular Material theme object with color settings. +/// @param {Map} $config The color configuration +/// @return {Map} A theme object +/// @deprecated Use define-theme with a map using the "color" key +@function define-colors($config: ()) { + @return define-theme((color: $config)); +} + /// Defines an Angular Material theme object with typography settings. /// @param {Map} $config The typography configuration /// @return {Map} A theme object +/// @deprecated Use define-theme with a map using the "typography" key @function define-typography($config: ()) { - $err: config-validation.validate-typography-config($config); - @if $err { - @error $err; - } - - $plain: map.get($config, plain-family) or (Roboto, sans-serif); - $brand: map.get($config, brand-family) or $plain; - $bold: map.get($config, bold-weight) or 700; - $medium: map.get($config, medium-weight) or 500; - $regular: map.get($config, regular-weight) or 400; - $system-variables-prefix: map.get($config, system-variables-prefix) or $system-level-prefix; - sass-utils.$use-system-typography-variables: map.get($config, use-system-variables) or false; - - @return ( - $internals: ( - theme-version: $theme-version, - font-definition: ( - plain: $plain, - brand: $brand, - bold: $bold, - medium: $medium, - regular: $regular, - ), - typography-system-variables-prefix: $system-variables-prefix, - typography-tokens: m3-tokens.generate-typography-tokens( - $brand, $plain, $bold, $medium, $regular, $system-variables-prefix) - ) - ); + @return define-theme((typography: $config)); } /// Defines an Angular Material theme object with density settings. /// @param {Map} $config The density configuration /// @return {Map} A theme object +/// @deprecated Use define-theme with a map using the "density" key @function define-density($config: ()) { - $err: config-validation.validate-density-config($config); - @if $err { - @error $err; - } - - $density-scale: map.get($config, scale) or 0; - - @return ( - $internals: ( - theme-version: $theme-version, - density-scale: $density-scale, - ) - ); + @return define-theme((density: $config)); } diff --git a/src/material/core/theming/_inspection.scss b/src/material/core/theming/_inspection.scss index 900ba77f8e06..107d7f580038 100644 --- a/src/material/core/theming/_inspection.scss +++ b/src/material/core/theming/_inspection.scss @@ -3,7 +3,7 @@ @use '../style/validation'; @use './m2-inspection'; -$_internals: _mat-theming-internals-do-not-access; +$internals: _mat-theming-internals-do-not-access; $_m3-typescales: ( display-large, @@ -30,7 +30,7 @@ $_typography-properties: (font, font-family, line-height, font-size, letter-spac /// @return {Boolean|Null} true if the theme has errors, else null. @function _validate-theme-object($theme) { $err: validation.validate-type($theme, 'map') or - map.get($theme, $_internals, theme-version) == null; + map.get($theme, $internals, theme-version) == null; @return if($err, true, null); } @@ -40,7 +40,7 @@ $_typography-properties: (font, font-family, line-height, font-size, letter-spac /// @return {Number} The version number of the theme (0 if unknown). @function get-theme-version($theme) { $err: _validate-theme-object($theme); - @return if($err, 0, map.get($theme, $_internals, theme-version) or 0); + @return if($err, 0, map.get($theme, $internals, theme-version) or 0); } /// Gets the type of theme represented by a theme object (light or dark). @@ -55,13 +55,15 @@ $_typography-properties: (font, font-family, line-height, font-size, letter-spac @if not theme-has($theme, color) { @error 'Color information is not available on this theme.'; } - @return map.get($theme, $_internals, theme-type) or light; + @return map.get($theme, $internals, theme-type) or light; } @else { @error #{'Unrecognized theme version:'} $version; } } + + /// Gets a color from a theme object. This function take a different amount of arguments depending /// on if it's working with an M2 or M3 theme: /// - With an M3 theme it accepts either 2 or 3 arguments. If 2 arguments are passed, the second @@ -119,7 +121,7 @@ $_typography-properties: (font, font-family, line-height, font-size, letter-spac @if not theme-has($theme, color) { @error 'Color information is not available on this theme.'; } - $color-roles: map.get($theme, $_internals, color-tokens, (mat, theme)); + $color-roles: map.get($theme, $internals, color-tokens, (mat, theme)); $result: map.get($color-roles, $color-role-name); @if not $result { @error #{'Valid color roles are: #{map.keys($color-roles)}. Got:'} $color-role-name; @@ -141,7 +143,7 @@ $_typography-properties: (font, font-family, line-height, font-size, letter-spac @if not theme-has($theme, color) { @error 'Color information is not available on this theme.'; } - $palettes: map.get($theme, $_internals, palettes); + $palettes: map.get($theme, $internals, palettes); $palette: map.get($palettes, $palette-name); @if not $palette { $supported-palettes: map.keys($palettes); @@ -185,7 +187,7 @@ $_typography-properties: (font, font-family, line-height, font-size, letter-spac font-weight: '-weight' ), $property); $token-name: '#{$typescale}#{$property-key}'; - @return map.get($theme, $_internals, typography-tokens, (mat, typography), $token-name); + @return map.get($theme, $internals, typography-tokens, (mat, typography), $token-name); } @else { @error #{'Unrecognized theme version:'} $version; @@ -204,7 +206,7 @@ $_typography-properties: (font, font-family, line-height, font-size, letter-spac @if not theme-has($theme, density) { @error 'Density information is not available on this theme.'; } - @return map.get($theme, $_internals, density-scale); + @return map.get($theme, $internals, density-scale); } @else { @error #{'Unrecognized theme version:'} $version; @@ -222,18 +224,18 @@ $_typography-properties: (font, font-family, line-height, font-size, letter-spac } @else if $version == 1 { @if $system == base { - @return map.get($theme, $_internals, base-tokens) != null; + @return map.get($theme, $internals, base-tokens) != null; } @if $system == color { - @return map.get($theme, $_internals, color-tokens) != null and - map.get($theme, $_internals, theme-type) != null and - map.get($theme, $_internals, palettes) != null; + @return map.get($theme, $internals, color-tokens) != null and + map.get($theme, $internals, theme-type) != null and + map.get($theme, $internals, palettes) != null; } @if $system == typography { - @return map.get($theme, $_internals, typography-tokens) != null; + @return map.get($theme, $internals, typography-tokens) != null; } @if $system == density { - @return map.get($theme, $_internals, density-scale) != null; + @return map.get($theme, $internals, density-scale) != null; } @error 'Valid systems are: base, color, typography, density. Got:' $system; } @@ -259,58 +261,21 @@ $_typography-properties: (font, font-family, line-height, font-size, letter-spac @else if $version == 1 { @each $system in $systems { @if $system == base { - $theme: map.deep-remove($theme, $_internals, base-tokens); + $theme: map.deep-remove($theme, $internals, base-tokens); } @else if $system == color { - $theme: map.deep-remove($theme, $_internals, color-tokens); - $theme: map.deep-remove($theme, $_internals, theme-type); - $theme: map.deep-remove($theme, $_internals, palettes); + $theme: map.deep-remove($theme, $internals, color-tokens); + $theme: map.deep-remove($theme, $internals, theme-type); + $theme: map.deep-remove($theme, $internals, palettes); } @else if $system == typography { - $theme: map.deep-remove($theme, $_internals, typography-tokens); + $theme: map.deep-remove($theme, $internals, typography-tokens); } @else if $system == density { - $theme: map.deep-remove($theme, $_internals, density-scale); - $theme: map.deep-remove($theme, $_internals, density-tokens); + $theme: map.deep-remove($theme, $internals, density-scale); + $theme: map.deep-remove($theme, $internals, density-tokens); } } @return $theme; } } - -/// Gets the set of tokens from the given theme, limited to those affected by the requested theming -/// systems. -/// @param {Map} $theme The theme to get tokens from. -/// @param {String...} $systems The theming systems to get tokens for. Valid values are: color, -/// typography, density, base. If no systems are passed, all tokens will be returned. -/// @return {Map} The requested tokens for the theme. -@function get-theme-tokens($theme, $systems...) { - $systems: if(list.length($systems) == 0, (color, typography, density, base), $systems); - $err: _validate-theme-object($theme); - @if $err { - @error #{'Expected $theme to be an Angular Material theme object. Got:'} $theme; - } - $err: validation.validate-allowed-values($systems, color, typography, density, base); - @if $err { - @error #{'Expected $systems to contain valid system names (color, typography, density, or'} - #{'base). Got invalid system names:'} $err; - } - $result: (); - @each $system in $systems { - $result: map.deep-merge($result, map.get($theme, $_internals, '#{$system}-tokens') or ()); - } - - @return $result; -} - -/// Gets a version of the theme with a modified typography config that preserves old behavior in -/// some components that previously used `private-typography-to-2014-config`. -/// Do not introduce new usages of this, it should be cleaned up and removed. -/// @deprecated -@function private-get-typography-back-compat-theme($theme) { - @return if( - get-theme-version($theme) == 0, - m2-inspection.private-get-typography-back-compat-theme($theme), - $theme - ); -} diff --git a/src/material/core/theming/_m2-inspection.scss b/src/material/core/theming/_m2-inspection.scss index f096009282ff..d333f5d754f8 100644 --- a/src/material/core/theming/_m2-inspection.scss +++ b/src/material/core/theming/_m2-inspection.scss @@ -235,36 +235,3 @@ $_typography-properties: (font, font-family, line-height, font-size, letter-spac } @return $theme; } - -/// Gets a version of the theme with a modified typography config that preserves old behavior in -/// some components that previously used `private-typography-to-2014-config`. -/// Do not introduce new usages of this, it should be cleaned up and removed. -/// @deprecated -@function private-get-typography-back-compat-theme($theme) { - // It is possible for a user to pass a "density theme" that is just a number. - @if meta.type-of($theme) != 'map' { - @return $theme; - } - $internal: map.get($theme, $_internals, m2-config); - $theme: map.remove($theme, $_internals); - @if theme-has($theme, typography) { - $typography-config: m2-theming.get-typography-config($theme); - // gmat configs have both 2018 and 2014 keys. - @if (not typography-versioning.private-typography-is-2014-config($typography-config)) or - (not typography-versioning.private-typography-is-2018-config($typography-config)) { - @return $theme; - } - $new-typography-config: typography-versioning.private-typography-to-2018-config( - $typography-config, true); - // subtitle-2 is mapped differently by `private-typography-to-2014-config`. - $new-typography-config: map.set( - $new-typography-config, subtitle-2, map.get($new-typography-config, body-1)); - $theme: if($theme == $typography-config, $new-typography-config, - map.set($theme, typography, $new-typography-config)); - } - @if $internal { - $internal: private-get-typography-back-compat-theme($internal); - $theme: map.set($theme, $_internals, m2-config, $internal); - } - @return $theme; -} diff --git a/src/material/core/theming/_theming.scss b/src/material/core/theming/_theming.scss index c10879a7bc19..98a1f9cc217b 100644 --- a/src/material/core/theming/_theming.scss +++ b/src/material/core/theming/_theming.scss @@ -4,24 +4,21 @@ @use 'sass:meta'; @use 'sass:color'; +// Deprecated flag that is no longer used to control duplication warnings. +// Remove in v22 +$theme-ignore-duplication-warnings: false; + // Whether to enable compatibility with legacy methods for accessing theme information. $theme-legacy-inspection-api-compatibility: true !default; -// Whether duplication warnings should be disabled. Warnings enabled by default. -$theme-ignore-duplication-warnings: false !default; - // Whether density should be generated by default. $_generate-default-density: true !default; -// Warning that will be printed if duplicated styles are generated by a theme. -$_duplicate-warning: 'Read more about how style duplication can be avoided in a dedicated ' + - 'guide. https://v18.material.angular.io/guide/duplicate-theming-styles'; - // Warning that will be printed if the legacy theming API is used. $private-legacy-theme-warning: 'Angular Material themes should be created from a map containing ' + 'the keys "color", "typography", and "density". The color value should be a map containing the ' + 'palette values for "primary", "accent", and "warn". ' + - 'See https://material.angular.io/guide/theming for more information.'; + 'See https://material.angular.dev/guide/theming for more information.'; // Flag whether to disable theme definitions copying color values to the top-level theme config. // This copy is to preserve backwards compatibility. @@ -40,46 +37,6 @@ $_emitted-base: () !default; $private-internal-name: _mat-theming-internals-do-not-access; -// Checks if configurations that have been declared in the given theme have been generated -// before. If so, warnings will be reported. This should notify developers in case duplicate -// styles are accidentally generated due to wrong usage of the all-theme mixins. -// -// Additionally, this mixin controls the default value for the density configuration. By -// default, density styles are generated at scale zero. If the same density styles would be -// generated a second time though, the default value will change to avoid duplicate styles. -// -// The mixin keeps track of all configurations in a list that is scoped to the specified -// id. This is necessary because a given theme can be passed to multiple disjoint theme mixins -// (e.g. `all-component-themes` and `all-legacy-component-themes`) without causing any -// style duplication. -@mixin private-check-duplicate-theme-styles($theme-or-color-config, $id) { - // TODO(mmalerba): use get-theme-version for this check when its moved out of experimental. - @if map.get($theme-or-color-config, $private-internal-name, theme-version) == 1 { - @include _check-duplicate-theme-styles-v1($theme-or-color-config, $id) { - // Optionally, consumers of this mixin can wrap contents inside so that nested - // duplicate style checks do not report another warning. e.g. if developers include - // the `all-component-themes` mixin twice, only the top-level duplicate styles check - // should report a warning. Not all individual components should report a warning too. - $orig-mat-theme-ignore-duplication-warnings: $theme-ignore-duplication-warnings; - $theme-ignore-duplication-warnings: true !global; - @content; - $theme-ignore-duplication-warnings: $orig-mat-theme-ignore-duplication-warnings !global; - } - } - @else { - @include _check-duplicate-theme-styles-v0($theme-or-color-config, $id) { - // Optionally, consumers of this mixin can wrap contents inside so that nested - // duplicate style checks do not report another warning. e.g. if developers include - // the `all-component-themes` mixin twice, only the top-level duplicate styles check - // should report a warning. Not all individual components should report a warning too. - $orig-mat-theme-ignore-duplication-warnings: $theme-ignore-duplication-warnings; - $theme-ignore-duplication-warnings: true !global; - @content; - $theme-ignore-duplication-warnings: $orig-mat-theme-ignore-duplication-warnings !global; - } - } -} - /// Strip out any settings map entries that have empty values (null or ()). @function _strip-empty-settings($settings) { $result: (); @@ -91,131 +48,6 @@ $private-internal-name: _mat-theming-internals-do-not-access; @return if($result == (), null, $result); } -// Checks for duplicate styles in a `theme-version: 1` style theme. -@mixin _check-duplicate-theme-styles-v1($theme-or-color-config, $id) { - $color-settings: _strip-empty-settings(( - theme-type: map.get($theme-or-color-config, $private-internal-name, theme-type), - color-tokens: map.get($theme-or-color-config, $private-internal-name, color-tokens), - )); - $typography-settings: _strip-empty-settings(( - typography-tokens: - map.get($theme-or-color-config, $private-internal-name, typography-tokens), - )); - $density-settings: _strip-empty-settings(( - density-scale: - map.get($theme-or-color-config, $private-internal-name, density-scale), - density-tokens: - map.get($theme-or-color-config, $private-internal-name, density-tokens), - )); - $base-settings: _strip-empty-settings(( - base-tokens: map.get($theme-or-color-config, $private-internal-name, base-tokens), - )); - $previous-color-settings: map.get($_emitted-color, $id) or (); - $previous-typography-settings: map.get($_emitted-typography, $id) or (); - $previous-density-settings: map.get($_emitted-density, $id) or (); - $previous-base-settings: map.get($_emitted-base, $id) or (); - - // Check if the color configuration has been generated before. - @if $color-settings != null { - @if list.index($previous-color-settings, $color-settings) != null and - not $theme-ignore-duplication-warnings { - @warn 'The same color styles are generated multiple times. ' + $_duplicate-warning; - } - $previous-color-settings: list.append($previous-color-settings, $color-settings); - } - - // Check if the typography configuration has been generated before. - @if $typography-settings != null { - @if list.index($previous-typography-settings, $typography-settings) != null and - not $theme-ignore-duplication-warnings { - @warn 'The same typography styles are generated multiple times. ' + $_duplicate-warning; - } - $previous-typography-settings: list.append($previous-typography-settings, $typography-settings); - } - - // Check if the density configuration has been generated before. - @if $density-settings != null { - @if list.index($previous-density-settings, $density-settings) != null and - not $theme-ignore-duplication-warnings { - @warn 'The same density styles are generated multiple times. ' + $_duplicate-warning; - } - $previous-density-settings: list.append($previous-density-settings, $density-settings); - } - - // Check if the base configuration has been generated before. - @if $base-settings != null { - @if list.index($previous-base-settings, $base-settings) != null and - not $theme-ignore-duplication-warnings { - @warn 'The same base theme styles are generated multiple times. ' + $_duplicate-warning; - } - $previous-base-settings: list.append($previous-base-settings, $base-settings); - } - - $_emitted-color: map.set($_emitted-color, $id, $previous-color-settings) !global; - $_emitted-density: map.set($_emitted-density, $id, $previous-density-settings) !global; - $_emitted-typography: map.set($_emitted-typography, $id, $previous-typography-settings) !global; - $_emitted-base: map.set($_emitted-base, $id, $previous-base-settings) !global; - - @content; -} - -// Checks for duplicate styles in a `theme-version: 0` style theme. -@mixin _check-duplicate-theme-styles-v0($theme-or-color-config, $id) { - $theme: private-legacy-get-theme($theme-or-color-config); - $color-config: map.get($theme, $private-internal-name, m2-config, color) or - private-get-color-config($theme); - $density-config: map.get($theme, $private-internal-name, m2-config, density) or - private-get-density-config($theme); - $typography-config: map.get($theme, $private-internal-name, m2-config, typography) or - private-get-typography-config($theme); - // Lists of previous `color`, `density` and `typography` configurations. - $previous-color: map.get($_emitted-color, $id) or (); - $previous-typography: map.get($_emitted-typography, $id) or (); - $previous-density: map.get($_emitted-density, $id) or (); - // Whether duplicate legacy density styles would be generated. - $duplicate-legacy-density: false; - - // Check if the color configuration has been generated before. - @if $color-config != null { - @if list.index($previous-color, $color-config) != null and - not $theme-ignore-duplication-warnings { - @warn 'The same color styles are generated multiple times. ' + $_duplicate-warning; - } - $previous-color: list.append($previous-color, $color-config); - } - - // Check if the typography configuration has been generated before. - @if $typography-config != null { - @if list.index($previous-typography, $typography-config) != null and - not $theme-ignore-duplication-warnings { - @warn 'The same typography styles are generated multiple times. ' + $_duplicate-warning; - } - $previous-typography: list.append($previous-typography, $typography-config); - } - - // Check if the density configuration has been generated before. - @if $density-config != null { - @if list.index($previous-density, $density-config) != null { - // Only report a warning if density styles would be duplicated for non-legacy theme - // definitions. For legacy themes, we have compatibility logic that avoids duplication - // of default density styles. We don't want to report a warning in those cases. - @if private-is-legacy-constructed-theme($theme) { - $duplicate-legacy-density: true; - } - @else if not $theme-ignore-duplication-warnings { - @warn 'The same density styles are generated multiple times. ' + $_duplicate-warning; - } - } - $previous-density: list.append($previous-density, $density-config); - } - - $_emitted-color: map.merge($_emitted-color, ($id: $previous-color)) !global; - $_emitted-density: map.merge($_emitted-density, ($id: $previous-density)) !global; - $_emitted-typography: map.merge($_emitted-typography, ($id: $previous-typography)) !global; - - @content; -} - // Checks whether the given value resolves to a theme object. Theme objects are always // of type `map` and can optionally only specify `color`, `density` or `typography`. @function private-is-theme-object($value) { diff --git a/src/material/core/theming/tests/m3-theme.spec.ts b/src/material/core/theming/tests/m3-theme.spec.ts index 01061e9873e9..eaa524c78f85 100644 --- a/src/material/core/theming/tests/m3-theme.spec.ts +++ b/src/material/core/theming/tests/m3-theme.spec.ts @@ -128,12 +128,6 @@ describe('M3 theme', () => { .toEqual([]); }); - it('should throw if theme included at root', () => { - expect(() => transpile(`@include mat.all-component-themes($theme)`)).toThrowError( - /Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector/, - ); - }); - describe('theme override API', () => { beforeEach(() => { spyOn(process.stderr, 'write').and.callThrough(); diff --git a/src/material/core/theming/tests/test-theming-api.scss b/src/material/core/theming/tests/test-theming-api.scss index 566992f8ceac..963c79333e92 100644 --- a/src/material/core/theming/tests/test-theming-api.scss +++ b/src/material/core/theming/tests/test-theming-api.scss @@ -9,10 +9,6 @@ @use '../theming'; @use 'sass:map'; -// Disable theme style duplication warnings. This test intentionally generates -// the same themes multiple times. -theming.$theme-ignore-duplication-warnings: true; - // A new way to configure themes has been introduced. This Sass file ensures that the theming // API is backwards compatible, so that the old pattern for configuring themes can be still // used. The Sass compilation of this file will fail if one of the tests/assertions fails. diff --git a/src/material/core/theming/tests/test-theming-bundle.scss b/src/material/core/theming/tests/test-theming-bundle.scss index 4763e52c9446..61f1191f7353 100644 --- a/src/material/core/theming/tests/test-theming-bundle.scss +++ b/src/material/core/theming/tests/test-theming-bundle.scss @@ -3,10 +3,6 @@ // with the include paths being set to the `bazel-bin` directory. @use '../../../index' as mat; -// Disable theme style duplication warnings. This test intentionally generates -// the same themes multiple times. -mat.$theme-ignore-duplication-warnings: true; - // If one of these mixins or function is not available, the compilation will fail and the // test will be reported as failing. diff --git a/src/material/core/theming/tests/theming-definition-api.spec.ts b/src/material/core/theming/tests/theming-definition-api.spec.ts index 512fa7f8655f..fe96363cc029 100644 --- a/src/material/core/theming/tests/theming-definition-api.spec.ts +++ b/src/material/core/theming/tests/theming-definition-api.spec.ts @@ -69,16 +69,16 @@ describe('theming definition api', () => { `); const vars = getRootVars(css); expect(vars['keys'].split(', ')).toEqual([ - 'theme-version', - 'theme-type', - 'palettes', + 'base-tokens', 'color-system-variables-prefix', 'color-tokens', + 'density-scale', 'font-definition', + 'palettes', + 'theme-type', + 'theme-version', 'typography-system-variables-prefix', 'typography-tokens', - 'density-scale', - 'base-tokens', ]); expect(vars['version']).toBe('1'); expect(vars['type']).toBe('light'); @@ -243,57 +243,4 @@ describe('theming definition api', () => { ); }); }); - - describe('define-colors', () => { - it('should omit non-color info', () => { - const css = transpile(` - $theme: mat.define-colors(); - $data: map.get($theme, $internals); - :root { - --keys: #{map.keys($data)}; - } - `); - const vars = getRootVars(css); - expect(vars['keys'].split(', ')).toEqual([ - 'theme-version', - 'theme-type', - 'palettes', - 'color-system-variables-prefix', - 'color-tokens', - ]); - }); - }); - - describe('define-typography', () => { - it('should omit non-typography info', () => { - const css = transpile(` - $theme: mat.define-typography(); - $data: map.get($theme, $internals); - :root { - --keys: #{map.keys($data)}; - } - `); - const vars = getRootVars(css); - expect(vars['keys'].split(', ')).toEqual([ - 'theme-version', - 'font-definition', - 'typography-system-variables-prefix', - 'typography-tokens', - ]); - }); - }); - - describe('define-density', () => { - it('should omit non-density info', () => { - const css = transpile(` - $theme: mat.define-density(); - $data: map.get($theme, $internals); - :root { - --keys: #{map.keys($data)}; - } - `); - const vars = getRootVars(css); - expect(vars['keys'].split(', ')).toEqual(['theme-version', 'density-scale']); - }); - }); }); diff --git a/src/material/core/theming/tests/theming-inspection-api.spec.ts b/src/material/core/theming/tests/theming-inspection-api.spec.ts index 0da3e6853a0c..e3ce2cdef6cd 100644 --- a/src/material/core/theming/tests/theming-inspection-api.spec.ts +++ b/src/material/core/theming/tests/theming-inspection-api.spec.ts @@ -371,96 +371,19 @@ describe('theming inspection api', () => { ).toMatch('--density-scale: 0;'); }); - it('should check what information the theme has', () => { + it('should check that the theme has all the information', () => { const css = transpile(` $theme: mat.define-theme(); - $color-only: mat.define-colors(); - $typography-only: mat.define-typography(); - $density-only: mat.define-density(); div { --base: #{( mat.theme-has($theme, base), - mat.theme-has($color-only, base), - mat.theme-has($typography-only, base), - mat.theme-has($density-only, base), - )}; - --color: #{( mat.theme-has($theme, color), - mat.theme-has($color-only, color), - mat.theme-has($typography-only, color), - mat.theme-has($density-only, color), - )}; - --typography: #{( mat.theme-has($theme, typography), - mat.theme-has($color-only, typography), - mat.theme-has($typography-only, typography), - mat.theme-has($density-only, typography), - )}; - --density: #{( mat.theme-has($theme, density), - mat.theme-has($color-only, density), - mat.theme-has($typography-only, density), - mat.theme-has($density-only, density), )}; } `); - expect(css).toMatch(/--base: true, false, false, false;/); - expect(css).toMatch(/--color: true, true, false, false;/); - expect(css).toMatch(/--typography: true, false, true, false;/); - expect(css).toMatch(/--density: true, false, false, true;/); - }); - - it('should error when reading theme type from a theme with no color information', () => { - expect(() => - transpile(` - $theme: mat.define-density(); - div { - color: mat.get-theme-type($theme); - } - `), - ).toThrowError(/Color information is not available on this theme/); - }); - - it('should error when reading color from a theme with no color information', () => { - expect(() => - transpile(` - $theme: mat.define-density(); - div { - color: mat.get-theme-color($theme, primary); - } - `), - ).toThrowError(/Color information is not available on this theme/); - }); - - it('should error when reading typography from a theme with no typography information', () => { - expect(() => - transpile(` - $theme: mat.define-density(); - div { - font: mat.get-theme-typography($theme, body-small); - } - `), - ).toThrowError(/Typography information is not available on this theme/); - }); - - it('should error when reading density from a theme with no density information', () => { - expect(() => - transpile(` - $theme: mat.define-colors(); - div { - --density: #{mat.get-theme-density($theme)}; - } - `), - ).toThrowError(/Density information is not available on this theme/); - }); - - it('should not emit styles for removed theme dimensions', () => { - const css = transpile(` - $theme: mat.theme-remove(mat.define-theme(), base, color, typography, density); - div { - @include mat.all-component-themes($theme); - }`); - expect(css.trim()).toBe(''); + expect(css).toMatch(/--base: true, true, true, true;/); }); }); }); diff --git a/src/material/core/theming/tests/theming-mixin-api.spec.ts b/src/material/core/theming/tests/theming-mixin-api.spec.ts deleted file mode 100644 index 2ddcef420450..000000000000 --- a/src/material/core/theming/tests/theming-mixin-api.spec.ts +++ /dev/null @@ -1,290 +0,0 @@ -import {parse, Root, Rule} from 'postcss'; -import {compileString} from 'sass'; -import {runfiles} from '@bazel/runfiles'; -import * as path from 'path'; - -import {compareNodes} from '../../../../../tools/postcss/compare-nodes.js'; -import {createLocalAngularPackageImporter} from '../../../../../tools/sass/local-sass-importer.js'; - -// Note: For Windows compatibility, we need to resolve the directory paths through runfiles -// which are guaranteed to reside in the source tree. -const testDir = path.join(runfiles.resolvePackageRelative('../_all-theme.scss'), '../tests'); -const packagesDir = path.join(runfiles.resolveWorkspaceRelative('src/cdk/_index.scss'), '../..'); - -const localPackageSassImporter = createLocalAngularPackageImporter(packagesDir); - -describe('theming api', () => { - /** Map of known selectors for density styles and their corresponding AST rule. */ - let knownDensitySelectors: Map; - - // Before all tests, we collect all nodes specific to density styles. We can then - // use this check how density styles are generated. i.e. if they are properly scoped to a - // given selector. - beforeAll(() => { - knownDensitySelectors = new Map(); - parse(transpile(`@include mat.all-component-densities(0);`)).each(node => { - if (node.type === 'rule') { - node.selectors.forEach(s => knownDensitySelectors.set(s, node)); - } - }); - }); - - it('should warn if color styles are duplicated', () => { - spyOn(process.stderr, 'write'); - - transpile(` - $theme: mat.m2-define-light-theme(( - color: ( - primary: mat.m2-define-palette(mat.$m2-red-palette), - accent: mat.m2-define-palette(mat.$m2-red-palette), - ) - )); - - @include mat.all-component-themes($theme); - - .dark-theme { - @include mat.all-component-themes($theme); - } - `); - - expectWarning(/The same color styles are generated multiple times/); - }); - - it('should not warn if color styles and density are not duplicated', () => { - const parsed = parse( - transpile(` - $theme: mat.m2-define-light-theme(( - color: ( - primary: mat.m2-define-palette(mat.$m2-red-palette), - accent: mat.m2-define-palette(mat.$m2-red-palette), - ) - )); - $theme2: mat.m2-define-light-theme(( - color: ( - primary: mat.m2-define-palette(mat.$m2-red-palette), - accent: mat.m2-define-palette(mat.$m2-blue-palette), - ) - )); - - @include mat.all-component-themes($theme); - - .dark-theme { - @include mat.all-component-colors($theme2); - } - `), - ); - - expect(hasDensityStyles(parsed, null)).toBe('all'); - expect(hasDensityStyles(parsed, '.dark-theme')).toBe('none'); - expectNoWarning(/The same color styles are generated multiple times/); - }); - - it('should be possible to modify color configuration directly', () => { - const result = transpile(` - $theme: mat.m2-define-light-theme(( - color: ( - primary: mat.m2-define-palette(mat.$m2-red-palette), - accent: mat.m2-define-palette(mat.$m2-blue-palette), - ) - )); - - // Updates the "icon" foreground color to hotpink. - $color: map-get($theme, color); - $theme: map-merge($color, - (foreground: map-merge(map-get($color, foreground), (icon: hotpink)))); - - @include mat.all-component-themes($theme); - `); - - expect(result).toContain(': hotpink'); - }); - - it('should warn if default density styles are duplicated', () => { - spyOn(process.stderr, 'write'); - - const parsed = parse( - transpile(` - @include mat.all-component-themes((color: null)); - - .dark-theme { - @include mat.all-component-themes((color: null)); - } - `), - ); - - expect(hasDensityStyles(parsed, null)).toBe('all'); - // TODO(mmalerba): Re-enable - disabled because this test does not account - // for the fact that: - // ```scss - // @include mat.button-theme($theme); - // @include mat.checkbox-theme($theme); - // ``` - // produces different results than: - // ```scss - // html { - // @include mat.button-theme($theme); - // @include mat.checkbox-theme($theme); - // } - // ``` - // expect(hasDensityStyles(parsed, '.dark-theme')).toBe('all'); - expectWarning(/The same density styles are generated multiple times/); - }); - - it('should warn if density styles are duplicated', () => { - spyOn(process.stderr, 'write'); - - transpile(` - @include mat.all-component-themes((density: -1)); - - .dark-theme { - @include mat.all-component-themes((density: -1)); - } - `); - - expectWarning(/The same density styles are generated multiple times/); - }); - - it('should not warn if density styles are not duplicated', () => { - spyOn(process.stderr, 'write'); - - transpile(` - @include mat.all-component-themes((density: -1)); - - .dark-theme { - @include mat.all-component-themes((density: -2)); - } - `); - - expect(process.stderr.write).toHaveBeenCalledTimes(0); - }); - - it('should warn if typography styles are duplicated', () => { - spyOn(process.stderr, 'write'); - - transpile(` - $theme: (typography: mat.m2-define-typography-config(), density: null); - @include mat.all-component-themes($theme); - - .dark-theme { - @include mat.all-component-themes($theme); - } - `); - - expectWarning(/The same typography styles are generated multiple times/); - }); - - it('should not warn if typography styles are not duplicated', () => { - spyOn(process.stderr, 'write'); - - transpile(` - @include mat.all-component-themes(( - typography: mat.m2-define-typography-config(), - density: null, - )); - - .dark-theme { - @include mat.all-component-themes(( - typography: mat.m2-define-typography-config($font-family: "sans-serif"), - density: null, - )); - } - `); - - expect(process.stderr.write).toHaveBeenCalledTimes(0); - }); - - /** - * Checks whether the given parsed stylesheet contains density styles scoped to - * a given selector. If the selector is `null`, then density is expected to be - * generated at top-level. - */ - function hasDensityStyles(parsed: Root, baseSelector: string | null): 'all' | 'partial' | 'none' { - expect(parsed.nodes).withContext('Expected CSS to be not empty.').toBeDefined(); - expect(knownDensitySelectors.size).not.toBe(0); - const missingDensitySelectors = new Set(knownDensitySelectors.keys()); - const baseSelectorRegex = new RegExp(`^${baseSelector} `, 'g'); - - // Go through all rules in the stylesheet and check if they match with any - // of the density style selectors. If so, we remove it from the copied set - // of density selectors. If the set is empty at the end, we know that density - // styles have been generated as expected. - parsed.nodes!.forEach(node => { - if (node.type !== 'rule') { - return; - } - node.selectors.forEach(selector => { - if (baseSelector && selector === baseSelector) { - // Styles emitted directly to the baseSelector are emitted to html - // when there is no baseSelector. - selector = 'html'; - } else { - // Only check selectors that match the specified base selector. - if (baseSelector && !baseSelectorRegex.test(selector)) { - return; - } - } - selector = selector.replace(baseSelectorRegex, ''); - const matchingRule = knownDensitySelectors.get(selector); - if (matchingRule && compareNodes(node, matchingRule)) { - missingDensitySelectors.delete(selector); - } - }); - }); - - // If there are no unmatched density selectors, then it's confirmed that - // all density styles have been generated (scoped to the given selector). - if (missingDensitySelectors.size === 0) { - return 'all'; - } - // If no density selector has been matched at all, then no density - // styles have been generated. - if (missingDensitySelectors.size === knownDensitySelectors.size) { - return 'none'; - } - console.error('MISSING!!! ', [...missingDensitySelectors].join(',')); - return 'partial'; - } - - /** Transpiles given Sass content into CSS. */ - function transpile(content: string) { - return compileString( - ` - @use '../../../index' as mat; - - ${content} - `, - { - loadPaths: [testDir], - importers: [localPackageSassImporter], - }, - ).css.toString(); - } - - /** Expects the given warning to be reported in Sass. */ - function expectWarning(message: RegExp) { - expect(getMatchingWarning(message)) - .withContext('Expected warning to be printed.') - .toBeDefined(); - } - - /** Expects the given warning not to be reported in Sass. */ - function expectNoWarning(message: RegExp) { - expect(getMatchingWarning(message)) - .withContext('Expected no warning to be printed.') - .toBeUndefined(); - } - - /** - * Gets first instance of the given warning reported in Sass. Dart sass directly writes - * to the `process.stderr` stream, so we spy on the `stderr.write` method. We - * cannot expect a specific amount of writes as Sass calls `stderr.write` multiple - * times for a warning (e.g. spacing and stack trace) - */ - function getMatchingWarning(message: RegExp) { - const writeSpy = process.stderr.write as jasmine.Spy; - return (writeSpy.calls?.all() ?? []).find( - (s: jasmine.CallInfo) => - typeof s.args[0] === 'string' && message.test(s.args[0]), - ); - } -}); diff --git a/src/material/core/tokens/BUILD.bazel b/src/material/core/tokens/BUILD.bazel index 7e91512f1b42..a669dcabbc84 100644 --- a/src/material/core/tokens/BUILD.bazel +++ b/src/material/core/tokens/BUILD.bazel @@ -2,11 +2,6 @@ load("//tools:defaults.bzl", "sass_library") package(default_visibility = ["//visibility:public"]) -sass_library( - name = "format_tokens", - srcs = ["_format-tokens.scss"], -) - sass_library( name = "m3_utils", srcs = ["_m3-utils.scss"], @@ -47,8 +42,6 @@ sass_library( name = "m3_tokens", srcs = ["_m3-tokens.scss"], deps = [ - ":format_tokens", - ":m2_tokens", "//src/material/autocomplete:m3", "//src/material/badge:m3", "//src/material/bottom-sheet:m3", @@ -93,52 +86,3 @@ sass_library( "//src/material/tree:m3", ], ) - -sass_library( - name = "m2_tokens", - srcs = ["_m2-tokens.scss"], - deps = [ - ":format_tokens", - "//src/material/autocomplete:m2", - "//src/material/badge:m2", - "//src/material/bottom-sheet:m2", - "//src/material/button:m2", - "//src/material/button-toggle:m2", - "//src/material/card:m2", - "//src/material/checkbox:m2", - "//src/material/chips:m2", - "//src/material/core:m2", - "//src/material/core:ripple_m2", - "//src/material/core/m2:m2_sass", - "//src/material/core/option:m2", - "//src/material/core/selection/pseudo-checkbox:m2", - "//src/material/core/style:sass_utils", - "//src/material/core/theming", - "//src/material/datepicker:m2", - "//src/material/dialog:m2", - "//src/material/divider:m2", - "//src/material/expansion:m2", - "//src/material/form-field:m2", - "//src/material/grid-list:m2", - "//src/material/icon:m2", - "//src/material/list:m2", - "//src/material/menu:m2", - "//src/material/paginator:m2", - "//src/material/progress-bar:m2", - "//src/material/progress-spinner:m2", - "//src/material/radio:m2", - "//src/material/select:m2", - "//src/material/sidenav:m2", - "//src/material/slide-toggle:m2", - "//src/material/slider:m2", - "//src/material/snack-bar:m2", - "//src/material/sort:m2", - "//src/material/stepper:m2", - "//src/material/table:m2", - "//src/material/tabs:m2", - "//src/material/timepicker:m2", - "//src/material/toolbar:m2", - "//src/material/tooltip:m2", - "//src/material/tree:m2", - ], -) diff --git a/src/material/core/tokens/_format-tokens.scss b/src/material/core/tokens/_format-tokens.scss deleted file mode 100644 index f395f654eef1..000000000000 --- a/src/material/core/tokens/_format-tokens.scss +++ /dev/null @@ -1,5 +0,0 @@ -// DO NOT REMOVE -// This function is used internally. -@function private-format-tokens($systems) { - @return $systems; -} diff --git a/src/material/core/tokens/_m2-tokens.scss b/src/material/core/tokens/_m2-tokens.scss deleted file mode 100644 index b7af3fa4a84c..000000000000 --- a/src/material/core/tokens/_m2-tokens.scss +++ /dev/null @@ -1,131 +0,0 @@ -@use '../../autocomplete/m2-autocomplete'; -@use '../../badge/m2-badge'; -@use '../../bottom-sheet/m2-bottom-sheet'; -@use '../../button-toggle/m2-button-toggle'; -@use '../../button/m2-button'; -@use '../../button/m2-fab'; -@use '../../button/m2-icon-button'; -@use '../../card/m2-card'; -@use '../../checkbox/m2-checkbox'; -@use '../../chips/m2-chip'; -@use '../../datepicker/m2-datepicker'; -@use '../../dialog/m2-dialog'; -@use '../../divider/m2-divider'; -@use '../../expansion/m2-expansion'; -@use '../../form-field/m2-form-field'; -@use '../../grid-list/m2-grid-list'; -@use '../../icon/m2-icon'; -@use '../../list/m2-list'; -@use '../../menu/m2-menu'; -@use '../../paginator/m2-paginator'; -@use '../../progress-bar/m2-progress-bar'; -@use '../../progress-spinner/m2-progress-spinner'; -@use '../../radio/m2-radio'; -@use '../../select/m2-select'; -@use '../../sidenav/m2-sidenav'; -@use '../../slide-toggle/m2-slide-toggle'; -@use '../../slider/m2-slider'; -@use '../../snack-bar/m2-snack-bar'; -@use '../../sort/m2-sort'; -@use '../../stepper/m2-stepper'; -@use '../../table/m2-table'; -@use '../../tabs/m2-tabs'; -@use '../../timepicker/m2-timepicker'; -@use '../../toolbar/m2-toolbar'; -@use '../../tooltip/m2-tooltip'; -@use '../../tree/m2-tree'; -@use '../m2-app'; -@use '../option/m2-optgroup'; -@use '../option/m2-option'; -@use '../ripple/m2-ripple'; -@use '../selection/pseudo-checkbox/m2-pseudo-checkbox'; -@use '../style/sass-utils'; -@use '../theming/inspection'; -@use 'sass:map'; -@use 'sass:meta'; - -/// Gets the tokens for the given theme, m2 tokens module, and theming system. -/// @param {Map} $theme The Angular Material theme object to generate token values from. -/// @param {String} $module The Sass module containing the token getter functions. -/// @param {String} $system The theming system to get tokens for. Valid values are: unthemable, -/// color, typography, density. -/// @return {Map} The token map by calling the token getter for the given system in the given module -/// with the given Angular Material theme. Token names are not fully-qualified. -@function _get-tokens-for-module-and-system($theme, $module, $system) { - @if $system == unthemable { - @return meta.call(meta.get-function(get-#{$system}-tokens, $module: $module)); - } - @if not inspection.theme-has($theme, $system) { - @return (); - } - @return meta.call(meta.get-function(get-#{$system}-tokens, $module: $module), $theme); -} - -/// Gets the fully qualified tokens map for the given theme and m2 tokens module. -/// @param {Map} $theme The Angular Material theme object to generate token values from. -/// @param {String} $module The Sass module containing the token getter functions. -/// @return {Map} The token map by calling the token getters in the given module with the given -/// Angular Material theme. Token names are fully-qualified. -@function _get-tokens-for-module($theme, $module) { - $tokens: sass-utils.deep-merge-all( - _get-tokens-for-module-and-system($theme, $module, unthemable), - _get-tokens-for-module-and-system($theme, $module, color), - _get-tokens-for-module-and-system($theme, $module, typography), - _get-tokens-for-module-and-system($theme, $module, density) - ); - @return map.set((), map.get(meta.module-variables($module), prefix), $tokens); -} - -/// Gets the full set of M2 tokens for the given theme object. -/// @param {Map} $theme The Angular Material theme object to generate token values from. -/// @return {Map} The token map for the given Angular Material theme. Returned format: -/// ( -/// (fully, qualified, namespace): ( -/// token: value -/// ) -/// ) -@function m2-tokens-from-theme($theme) { - @return sass-utils.deep-merge-all( - _get-tokens-for-module($theme, m2-app), - _get-tokens-for-module($theme, m2-autocomplete), - _get-tokens-for-module($theme, m2-badge), - _get-tokens-for-module($theme, m2-bottom-sheet), - _get-tokens-for-module($theme, m2-button), - _get-tokens-for-module($theme, m2-button-toggle), - _get-tokens-for-module($theme, m2-card), - _get-tokens-for-module($theme, m2-checkbox), - _get-tokens-for-module($theme, m2-chip), - _get-tokens-for-module($theme, m2-datepicker), - _get-tokens-for-module($theme, m2-dialog), - _get-tokens-for-module($theme, m2-divider), - _get-tokens-for-module($theme, m2-expansion), - _get-tokens-for-module($theme, m2-fab), - _get-tokens-for-module($theme, m2-form-field), - _get-tokens-for-module($theme, m2-grid-list), - _get-tokens-for-module($theme, m2-icon), - _get-tokens-for-module($theme, m2-icon-button), - _get-tokens-for-module($theme, m2-list), - _get-tokens-for-module($theme, m2-menu), - _get-tokens-for-module($theme, m2-optgroup), - _get-tokens-for-module($theme, m2-option), - _get-tokens-for-module($theme, m2-paginator), - _get-tokens-for-module($theme, m2-progress-bar), - _get-tokens-for-module($theme, m2-progress-spinner), - _get-tokens-for-module($theme, m2-pseudo-checkbox), - _get-tokens-for-module($theme, m2-radio), - _get-tokens-for-module($theme, m2-ripple), - _get-tokens-for-module($theme, m2-select), - _get-tokens-for-module($theme, m2-sidenav), - _get-tokens-for-module($theme, m2-slide-toggle), - _get-tokens-for-module($theme, m2-slider), - _get-tokens-for-module($theme, m2-snack-bar), - _get-tokens-for-module($theme, m2-sort), - _get-tokens-for-module($theme, m2-stepper), - _get-tokens-for-module($theme, m2-tabs), - _get-tokens-for-module($theme, m2-table), - _get-tokens-for-module($theme, m2-timepicker), - _get-tokens-for-module($theme, m2-toolbar), - _get-tokens-for-module($theme, m2-tooltip), - _get-tokens-for-module($theme, m2-tree), - ); -} diff --git a/src/material/core/tokens/_m2-utils.scss b/src/material/core/tokens/_m2-utils.scss index 5ef6d41d0ddd..f3af536395df 100644 --- a/src/material/core/tokens/_m2-utils.scss +++ b/src/material/core/tokens/_m2-utils.scss @@ -1,109 +1,25 @@ -@use '../m2/palette'; -@use '../m2/theming'; -@use '../m2/typography'; -@use 'sass:color'; -@use 'sass:math'; +@use 'sass:map'; @use 'sass:meta'; - -$_placeholder-color-palette: theming.define-palette(palette.$red-palette); - -// Indicates whether we're building internally. Used for backwards compatibility. -$private-is-internal-build: false; - -// Placeholder color config that can be passed to token getter functions when generating token -// slots. -$placeholder-color-config: ( - primary: $_placeholder-color-palette, - accent: $_placeholder-color-palette, - warn: $_placeholder-color-palette, - is-dark: false, - foreground: palette.$light-theme-foreground-palette, - background: palette.$light-theme-background-palette, -); - -$_placeholder-typography-level-config: typography.typography-config-level-from-mdc(body1); - -// Placeholder typography config that can be passed to token getter functions when generating token -// slots. -$placeholder-typography-config: ( - font-family: 'Roboto, sans-serif', - headline-1: $_placeholder-typography-level-config, - headline-2: $_placeholder-typography-level-config, - headline-3: $_placeholder-typography-level-config, - headline-4: $_placeholder-typography-level-config, - headline-5: $_placeholder-typography-level-config, - headline-6: $_placeholder-typography-level-config, - subtitle-1: $_placeholder-typography-level-config, - subtitle-2: $_placeholder-typography-level-config, - body-1: $_placeholder-typography-level-config, - body-2: $_placeholder-typography-level-config, - caption: $_placeholder-typography-level-config, - button: $_placeholder-typography-level-config, - overline: $_placeholder-typography-level-config, - subheading-1: $_placeholder-typography-level-config, - title: $_placeholder-typography-level-config, -); - -// Placeholder density config that can be passed to token getter functions when generating token -// slots. -$placeholder-density-config: 0; - -/// Inherited function from MDC that computes which contrast tone to use on top of a color. -/// This is used only in a narrow set of use cases when generating M2 button tokens to maintain -/// backwards compatibility. -/// @param {Color} $value Color for which we're calculating the contrast tone. -/// @param {Boolean} $is-dark Whether the current theme is dark. -/// @return {Map} Either `dark` or `light`. -@function contrast-tone($value, $is-dark) { - @if ($value == 'dark') { - @return 'light'; - } - - @if ($value == 'light') { - @return 'dark'; +@use './m2'; + +// Gets the theme's system values as a flat map. +@function get-system($theme) { + @if meta.type-of($theme) == map { + $system: map.get($theme, _mat-system); + @if $system { + @return $system; + } + + // If the $theme is a typography-config, convert it to a system map. + @if map.has-key($theme, body-1) { + @return m2.md-sys-typescale-values($theme); + } } - // Fallback if the app is using a non-color palette (e.g. CSS variable based). - @if (meta.type-of($value) != 'color') { - @return if($is-dark, 'light', 'dark'); - } - - $minimum-contrast: 3.1; - $light-contrast: _contrast($value, #fff); - $dark-contrast: _contrast($value, rgba(0, 0, 0, 0.87)); - - @if ($light-contrast < $minimum-contrast) and ($dark-contrast > $light-contrast) { - @return 'dark'; + // If the $theme is a density scale, convert it to a system map. + @if meta.type-of($theme) == 'number' or $theme == minimum or $theme == maximum { + @return (density-scale: $theme); } - @return 'light'; -} - -@function _linear-channel-value($channel-value) { - $normalized-channel-value: math.div($channel-value, 255); - - @if ($normalized-channel-value < 0.03928) { - @return math.div($normalized-channel-value, 12.92); - } - - @return math.pow(math.div($normalized-channel-value + 0.055, 1.055), 2.4); -} - -// Calculate the luminance for a color. -// See https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests -@function _luminance($color) { - $red: _linear-channel-value(color.red($color)); - $green: _linear-channel-value(color.green($color)); - $blue: _linear-channel-value(color.blue($color)); - - @return 0.2126 * $red + 0.7152 * $green + 0.0722 * $blue; -} - -// Calculate the contrast ratio between two colors. -// See https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests -@function _contrast($back, $front) { - $back-lum: _luminance($back) + 0.05; - $fore-lum: _luminance($front) + 0.05; - - @return math.div(math.max($back-lum, $fore-lum), math.min($back-lum, $fore-lum)); + @return (); } diff --git a/src/material/core/tokens/_m3-system.scss b/src/material/core/tokens/_m3-system.scss index 2346f4e42588..ed6c040a5a7e 100644 --- a/src/material/core/tokens/_m3-system.scss +++ b/src/material/core/tokens/_m3-system.scss @@ -16,12 +16,11 @@ @use '../../toolbar/m3-toolbar'; @use '../../tree/m3-tree'; @use '../style/elevation'; -@use '../style/sass-utils'; @use '../theming/config-validation'; @use '../theming/definition'; +@use '../theming/palettes'; +@use '../style/sass-utils'; @use './m3'; -@use './m3-tokens'; -@use 'sass:list'; @use 'sass:map'; @use 'sass:meta'; @@ -63,9 +62,40 @@ $color: map.set($color, theme-type, color-scheme); } - $color-config: if($is-palette, - definition.define-colors((primary: $color, theme-type: color-scheme)), - definition.define-colors($color)); + $color-config: $color; + @if ($is-palette) { + $color: map.set($color, tertiary, $color); + $color-config: ( + definition.$internals: ( + palettes: ( + primary: map.remove($color, neutral, neutral-variant, secondary), + secondary: map.get($color, secondary), + tertiary: map.remove($color, neutral, neutral-variant, secondary, error), + neutral: map.get($color, neutral), + neutral-variant: map.get($color, neutral-variant), + error: map.get($color, error), + ), + theme-type: color-scheme, + ) + ); + } @else { + $primary: map.get($color, primary) or palettes.$violet-palette; + $tertiary: map.get($color, tertiary) or $primary; + $color-config: ( + definition.$internals: ( + palettes: ( + primary: map.remove($primary, neutral, neutral-variant, secondary), + secondary: map.get($primary, secondary), + tertiary: map.remove($tertiary, neutral, neutral-variant, secondary, error), + neutral: map.get($primary, neutral), + neutral-variant: map.get($primary, neutral-variant), + error: map.get($primary, error), + ), + theme-type: map.get($color, theme-type), + ) + ); + } + @include system-level-colors($color-config, $overrides, definition.$system-fallback-prefix); @include system-level-elevation($color-config, $overrides, definition.$system-fallback-prefix); } @@ -73,9 +103,32 @@ $typography: map.get($config, typography); $typography-config: null; @if ($typography) { - $typography-config: if(meta.type-of($typography) == 'map', - definition.define-typography($typography), - definition.define-typography((plain-family: $typography))); + $plain: (Roboto, sans-serif); + $brand: $plain; + $bold: 700; + $medium: 500; + $regular: 400; + @if (meta.type-of($typography) == map) { + $plain: map.get($typography, plain-family); + $brand: map.get($typography, brand-family) or $plain; + $bold: map.get($typography, bold-weight) or $bold; + $medium: map.get($typography, medium-weight) or $medium; + $regular: map.get($typography, regular-weight) or $regular; + } @else { + $plain: $typography; + $brand: $typography; + } + $typography-config: ( + definition.$internals: ( + font-definition: ( + plain: $plain, + brand: $brand, + bold: $bold, + medium: $medium, + regular: $regular, + ) + ) + ); @include system-level-typography( $typography-config, $overrides, definition.$system-fallback-prefix); } @@ -83,17 +136,19 @@ $density: map.get($config, density); $density-config: null; @if ($density) { - $density-config: if(meta.type-of($density) == 'map', - definition.define-density($density), - definition.define-density((scale: $density))); - $scale: map.get($density-config, _mat-theming-internals-do-not-access, density-scale); + $scale: 0; + @if (meta.type-of($density) == map) { + $scale: map.get($density, scale); + } @else { + $scale: $density; + } @if ($scale != 0) { // Emit component-level density tokens if the scale is lower than 0. The density tokens // do not fallback to any system-level values and must be defined if the scale is different. $density-tokens: get-density-tokens($scale); - @each $prefix, $tokens in $density-tokens { + @each $tokens in $density-tokens { @each $token-name, $token-value in $tokens { - --#{list.nth($prefix, 1)}-#{list.nth($prefix, 2)}-#{$token-name}: #{$token-value}; + --mat-#{$token-name}: #{$token-value}; } } } @@ -106,23 +161,23 @@ // Gets all density-related tokens from the components. @function get-density-tokens($scale) { @return ( - m3-checkbox.$prefix: m3-checkbox.get-density-tokens($scale), - m3-chip.$prefix: m3-chip.get-density-tokens($scale), - m3-expansion.$prefix: m3-expansion.get-density-tokens($scale), - m3-fab.$prefix: m3-fab.get-density-tokens($scale), - m3-button.$prefix: m3-button.get-density-tokens($scale), - m3-form-field.$prefix: m3-form-field.get-density-tokens($scale), - m3-icon-button.$prefix: m3-icon-button.get-density-tokens($scale), - m3-list.$prefix: m3-list.get-density-tokens($scale), - m3-paginator.$prefix: m3-paginator.get-density-tokens($scale), - m3-radio.$prefix: m3-radio.get-density-tokens($scale), - m3-tabs.$prefix: m3-tabs.get-density-tokens($scale), - m3-select.$prefix: m3-select.get-density-tokens($scale), - m3-button-toggle.$prefix: m3-button-toggle.get-density-tokens($scale), - m3-stepper.$prefix: m3-stepper.get-density-tokens($scale), - m3-table.$prefix: m3-table.get-density-tokens($scale), - m3-toolbar.$prefix: m3-toolbar.get-density-tokens($scale), - m3-tree.$prefix: m3-tree.get-density-tokens($scale), + m3-checkbox.get-density-tokens($scale), + m3-chip.get-density-tokens($scale), + m3-expansion.get-density-tokens($scale), + m3-fab.get-density-tokens($scale), + m3-button.get-density-tokens($scale), + m3-form-field.get-density-tokens($scale), + m3-icon-button.get-density-tokens($scale), + m3-list.get-density-tokens($scale), + m3-paginator.get-density-tokens($scale), + m3-radio.get-density-tokens($scale), + m3-tabs.get-density-tokens($scale), + m3-select.get-density-tokens($scale), + m3-button-toggle.get-density-tokens($scale), + m3-stepper.get-density-tokens($scale), + m3-table.get-density-tokens($scale), + m3-toolbar.get-density-tokens($scale), + m3-tree.get-density-tokens($scale), ); } @@ -130,13 +185,19 @@ /// change the primary color to red, use `mat.theme-overrides((primary: red));` @mixin theme-overrides($overrides, $prefix: definition.$system-fallback-prefix) { $sys-names: map-merge-all( - m3.md-sys-color-values-light(), - m3.md-sys-typescale-values(), + m3.md-sys-color-values-light(palettes.$blue-palette), + m3.md-sys-typescale-values(( + brand: (Roboto), + plain: (Roboto), + bold: 700, + medium: 500, + regular: 400 + )), m3.md-sys-elevation-values(), m3.md-sys-shape-values(), m3.md-sys-state-values()); - & { + @include sass-utils.current-selector-or-root { @each $name, $value in $overrides { @if (map.has-key($sys-names, $name)) { --#{$prefix}-#{$name}: #{map.get($overrides, $name)}; @@ -146,51 +207,36 @@ } @mixin system-level-colors($theme, $overrides: (), $prefix: null) { - $palettes: map.get($theme, _mat-theming-internals-do-not-access, palettes); - $base-palettes: ( - neutral: map.get($palettes, neutral), - neutral-variant: map.get($palettes, neutral-variant), - secondary: map.get($palettes, secondary), - error: map.get($palettes, error), - ); - - $type: map.get($theme, _mat-theming-internals-do-not-access, theme-type); - $primary: map.merge(map.get($palettes, primary), $base-palettes); - $tertiary: map.merge(map.get($palettes, tertiary), $base-palettes); - $error: map.get($palettes, error); + $palettes: map.get($theme, definition.$internals, palettes); + $type: map.get($theme, definition.$internals, theme-type); @if (not $prefix) { - $prefix: map.get($theme, _mat-theming-internals-do-not-access, + $prefix: map.get($theme, definition.$internals, color-system-variables-prefix) or definition.$system-level-prefix; } - $ref: ( - md-ref-palette: m3-tokens.generate-ref-palette-tokens($primary, $tertiary, $error) - ); - $sys-colors: _generate-sys-colors($ref, $type); + $sys-colors: _generate-sys-colors($palettes, $type); // Manually insert a subset of palette values that are used directly by components // instead of system variables. - $sys-colors: map.set($sys-colors, - 'neutral-variant20', map.get($ref, md-ref-palette, neutral-variant20)); - $sys-colors: map.set($sys-colors, - 'neutral10', map.get($ref, md-ref-palette, neutral10)); + $sys-colors: map.set($sys-colors, neutral-variant20, map.get($palettes, neutral-variant, 20)); + $sys-colors: map.set($sys-colors, neutral10, map.get($palettes, neutral, 10)); - & { + @include sass-utils.current-selector-or-root { @each $name, $value in $sys-colors { --#{$prefix}-#{$name}: #{map.get($overrides, $name) or $value}; } } } -@function _generate-sys-colors($ref, $type) { - $light-sys-colors: m3.md-sys-color-values-light($ref); +@function _generate-sys-colors($palettes, $type) { + $light-sys-colors: m3.md-sys-color-values-light($palettes); @if ($type == light) { @return $light-sys-colors; } - $dark-sys-colors: m3.md-sys-color-values-dark($ref); + $dark-sys-colors: m3.md-sys-color-values-dark($palettes); @if ($type == dark) { @return $dark-sys-colors; } @@ -209,23 +255,15 @@ } @mixin system-level-typography($theme, $overrides: (), $prefix: null) { - $font-definition: map.get($theme, _mat-theming-internals-do-not-access, font-definition); - $brand: map.get($font-definition, brand); - $plain: map.get($font-definition, plain); - $bold: map.get($font-definition, bold); - $medium: map.get($font-definition, medium); - $regular: map.get($font-definition, regular); - $ref: (md-ref-typeface: - m3-tokens.generate-ref-typeface-tokens($brand, $plain, $bold, $medium, $regular) - ); + $font-definition: map.get($theme, definition.$internals, font-definition); @if (not $prefix) { - $prefix: map.get($theme, _mat-theming-internals-do-not-access, + $prefix: map.get($theme, definition.$internals, typography-system-variables-prefix) or definition.$system-level-prefix; } - & { - @each $name, $value in m3.md-sys-typescale-values($ref) { + @include sass-utils.current-selector-or-root { + @each $name, $value in m3.md-sys-typescale-values($font-definition) { --#{$prefix}-#{$name}: #{map.get($overrides, $name) or $value}; } } @@ -233,19 +271,20 @@ @mixin system-level-elevation($theme, $overrides: (), $prefix: definition.$system-level-prefix) { $shadow-color: map.get( - $theme, _mat-theming-internals-do-not-access, color-tokens, (mat, theme), shadow); + $theme, definition.$internals, palettes, neutral, 0); - @each $name, $value in m3.md-sys-elevation-values() { - $level: map.get($overrides, $name) or $value; - $value: elevation.get-box-shadow($level, $shadow-color); - & { + + @include sass-utils.current-selector-or-root { + @each $name, $value in m3.md-sys-elevation-values() { + $level: map.get($overrides, $name) or $value; + $value: elevation.get-box-shadow($level, $shadow-color); --#{$prefix}-#{$name}: #{$value}; } } } @mixin system-level-shape($theme: (), $overrides: (), $prefix: definition.$system-level-prefix) { - & { + @include sass-utils.current-selector-or-root { @each $name, $value in m3.md-sys-shape-values() { --#{$prefix}-#{$name}: #{map.get($overrides, $name) or $value}; } @@ -253,55 +292,13 @@ } @mixin system-level-state($theme: (), $overrides: (), $prefix: definition.$system-level-prefix) { - & { + @include sass-utils.current-selector-or-root { @each $name, $value in m3.md-sys-state-values() { --#{$prefix}-#{$name}: #{map.get($overrides, $name) or $value}; } } } -// Return a new map where the values are the same as the provided map's -// keys, prefixed with "--mat-sys-". For example: -// (key1: '', key2: '') --> (key1: --mat-sys-key1, key2: --mat-sys-key2) -@function _create-system-app-vars-map($map) { - $new-map: (); - @each $key, $value in $map { - $new-map: map.set($new-map, $key, --#{definition.$system-fallback-prefix}-#{$key}); - } - @return $new-map; -} - -// Create a components tokens map where values are based on -// system fallback variables referencing Material's system keys. -// Includes density token fallbacks where density is 0. -@function create-system-fallbacks() { - $app-vars: ( - 'md-sys-color': - _create-system-app-vars-map(m3.md-sys-color-values-light()), - 'md-sys-typescale': - _create-system-app-vars-map(m3.md-sys-typescale-values()), - 'md-sys-elevation': - _create-system-app-vars-map(m3.md-sys-elevation-values()), - 'md-sys-state': - _create-system-app-vars-map(m3.md-sys-state-values()), - 'md-sys-shape': - _create-system-app-vars-map(m3.md-sys-shape-values()), - // Add a subset of palette-specific colors used by components instead of system values - 'md-ref-palette': - _create-system-app-vars-map( - ( - neutral10: '', // Form field native select option text color - neutral-variant20: '', // Sidenav scrim (container background shadow when opened), - ) - ), - ); - - @return sass-utils.deep-merge-all( - m3-tokens.generate-tokens($app-vars, true, true), - get-density-tokens(0), - ); -} - /// Creates a single merged map from the provided variable-length map arguments @function map-merge-all($maps...) { $result: (); diff --git a/src/material/core/tokens/_m3-tokens.scss b/src/material/core/tokens/_m3-tokens.scss index 8ad6d8e81bf8..2b1334a9b7d1 100644 --- a/src/material/core/tokens/_m3-tokens.scss +++ b/src/material/core/tokens/_m3-tokens.scss @@ -1,127 +1,7 @@ -@use '../../autocomplete/m3-autocomplete'; -@use '../../badge/m3-badge'; -@use '../../bottom-sheet/m3-bottom-sheet'; -@use '../../button-toggle/m3-button-toggle'; -@use '../../button/m3-button'; -@use '../../button/m3-fab'; -@use '../../button/m3-icon-button'; -@use '../../card/m3-card'; -@use '../../checkbox/m3-checkbox'; -@use '../../chips/m3-chip'; -@use '../../datepicker/m3-datepicker'; -@use '../../dialog/m3-dialog'; -@use '../../divider/m3-divider'; -@use '../../expansion/m3-expansion'; -@use '../../form-field/m3-form-field'; -@use '../../grid-list/m3-grid-list'; -@use '../../icon/m3-icon'; -@use '../../list/m3-list'; -@use '../../menu/m3-menu'; -@use '../../paginator/m3-paginator'; -@use '../../progress-bar/m3-progress-bar'; -@use '../../progress-spinner/m3-progress-spinner'; -@use '../../radio/m3-radio'; -@use '../../select/m3-select'; -@use '../../sidenav/m3-sidenav'; -@use '../../slide-toggle/m3-slide-toggle'; -@use '../../slider/m3-slider'; -@use '../../snack-bar/m3-snack-bar'; -@use '../../sort/m3-sort'; -@use '../../stepper/m3-stepper'; -@use '../../table/m3-table'; -@use '../../tabs/m3-tabs'; -@use '../../timepicker/m3-timepicker'; -@use '../../toolbar/m3-toolbar'; -@use '../../tooltip/m3-tooltip'; -@use '../../tree/m3-tree'; -@use '../m2' as m2-theming; -@use '../m3-app'; -@use '../option/m3-optgroup'; -@use '../option/m3-option'; -@use '../ripple/m3-ripple'; -@use '../selection/pseudo-checkbox/m3-pseudo-checkbox'; @use '../style/sass-utils'; -@use './format-tokens'; -@use './m2-tokens'; @use './m3'; @use 'sass:map'; -/// Generates tokens for the given palette with the given prefix. -/// @param {Map} $palette The palette to generate tokens for -/// @param {String} $prefix The key prefix used to name the tokens -/// @return {Map} A set of tokens for the given palette -@function _generate-palette-tokens($palette, $prefix) { - $palette: map.remove($palette, neutral, neutral-variant); - $result: (); - @each $hue, $value in $palette { - $result: map.set($result, '#{$prefix}#{$hue}', $value); - } - @return $result; -} - -/// Creates a set of `md-ref-palette` tokens from the given palettes. (See -/// ./m3/definitions/_md-ref-palette.scss) -/// @param {Map} $primary The primary palette -/// @param {Map} $secondary The secondary palette -/// @param {Map} $tertiary The tertiary palette -/// @param {Map} $error The error palette -/// @return {Map} A set of `md-ref-palette` tokens -@function generate-ref-palette-tokens($primary, $tertiary, $error) { - @return sass-utils.merge-all( - (black: #000, white: #fff), - _generate-palette-tokens($primary, primary), - _generate-palette-tokens(map.get($primary, secondary), secondary), - _generate-palette-tokens($tertiary, tertiary), - _generate-palette-tokens(map.get($primary, neutral), neutral), - _generate-palette-tokens(map.get($primary, neutral-variant), neutral-variant), - _generate-palette-tokens($error, error), - ); -} - -/// Creates a set of `md-ref-typeface` tokens from the given palettes. (See -/// ./m3/definitions/_md-ref-typeface.scss) -/// @param {List|String} $brand The font-family to use for brand text -/// @param {List|String} $plain The font-family to use for plain text -/// @param {String} $bold The font-weight to use for bold text -/// @param {String} $medium The font-weight to use for medium text -/// @param {String} $regular The font-weight to use for regular text -/// @return {Map} A set of `md-ref-typeface` tokens -@function generate-ref-typeface-tokens($brand, $plain, $bold, $medium, $regular) { - @return ( - brand: $brand, - plain: $plain, - weight-bold: $bold, - weight-medium: $medium, - weight-regular: $regular, - ); -} - -$_cached-token-slots: null; - -/// Determines the token slots for all components. -@function _get-token-slots() { - // Cache the slots since they're constant and calculating - // them appears to be expensive (see #29009). - @if ($_cached-token-slots) { - @return $_cached-token-slots; - } - - // TODO(mmalerba): Refactor this to not depend on the legacy theme when moving out of - // material-experimental. This is a hack for now because there is no good way to get the token - // slots in material-experimental without exposing them all from material. - $fake-theme: m2-theming.define-light-theme(( - color: ( - primary: m2-theming.define-palette(m2-theming.$red-palette), - accent: m2-theming.define-palette(m2-theming.$red-palette), - warn: m2-theming.define-palette(m2-theming.$red-palette), - ), - typography: m2-theming.define-typography-config(), - density: 0 - )); - $_cached-token-slots: m2-tokens.m2-tokens-from-theme($fake-theme) !global; - @return $_cached-token-slots; -} - /// Generates a set of namespaced tokens for all components. /// @param {Map} $systems The MDC system tokens /// @param {Boolean} $include-non-systemized Whether to include non-systemized tokens @@ -138,11 +18,6 @@ $_cached-token-slots: null; ), $systems); $exclude-hardcoded: not $include-non-systemized; - // DO NOT REMOVE - // This function is used internally. - $systems: format-tokens.private-format-tokens($systems); - $token-slots: _get-token-slots(); - // TODO(mmalerba): Fill in remaining tokens. $result: sass-utils.deep-merge-all( // Add the system color & typography tokens (so we can give users access via an API). @@ -150,7 +25,6 @@ $_cached-token-slots: null; (mat, theme): map.get($systems, md-sys-color), (mat, typography): map.get($systems, md-sys-typescale), ), - get-m3-tokens($systems, $exclude-hardcoded, $token-slots), ); @return $result; @@ -164,170 +38,34 @@ $_cached-token-slots: null; @return $result; } -@function _get-sys-color($type, $ref, $prefix) { - $mdc-sys-color: if($type == dark, - m3.md-sys-color-values-dark($ref), - m3.md-sys-color-values-light($ref)); +@function get-sys-color($type, $palettes, $prefix) { + $sys-color: if($type == dark, + m3.md-sys-color-values-dark($palettes), + m3.md-sys-color-values-light($palettes)); @if (sass-utils.$use-system-color-variables) { - $keys: ( - 'background', - 'error', - 'error-container', - 'inverse-on-surface', - 'inverse-primary', - 'inverse-surface', - 'on-background', - 'on-error', - 'on-error-container', - 'on-primary', - 'on-primary-container', - 'on-primary-fixed', - 'on-primary-fixed-variant', - 'on-secondary', - 'on-secondary-container', - 'on-secondary-fixed', - 'on-secondary-fixed-variant', - 'on-surface', - 'on-surface-variant', - 'on-tertiary', - 'on-tertiary-container', - 'on-tertiary-fixed', - 'on-tertiary-fixed-variant', - 'outline', - 'outline-variant', - 'primary', - 'primary-container', - 'primary-fixed', - 'primary-fixed-dim', - 'scrim', - 'secondary', - 'secondary-container', - 'secondary-fixed', - 'secondary-fixed-dim', - 'surface', - 'surface-bright', - 'surface-container', - 'surface-container-high', - 'surface-container-highest', - 'surface-container-low', - 'surface-container-lowest', - 'surface-dim', - 'surface-tint', - 'surface-variant', - 'tertiary', - 'tertiary-container', - 'tertiary-fixed', - 'tertiary-fixed-dim' - ); - @return map.merge(create-map($keys, $prefix), ( - shadow: map.get($mdc-sys-color, shadow) - )); + $var-values: (); + @each $key in map.keys($sys-color) { + $var-values: map.set($var-values, $key, var(--#{$prefix}-#{$key})); + } + $var-values: map.set($var-values, shadow, map.get($sys-color, shadow)); + @return $var-values; } - @return $mdc-sys-color; + @return $sys-color; } -@function _get-sys-typeface($ref, $prefix) { +@function get-sys-typeface($typography, $prefix) { + $sys-typography: m3.md-sys-typescale-values($typography); @if (sass-utils.$use-system-typography-variables) { - $keys: ( - 'body-large', - 'body-large-font', - 'body-large-line-height', - 'body-large-size', - 'body-large-tracking', - 'body-large-weight', - 'body-medium', - 'body-medium-font', - 'body-medium-line-height', - 'body-medium-size', - 'body-medium-tracking', - 'body-medium-weight', - 'body-small', - 'body-small-font', - 'body-small-line-height', - 'body-small-size', - 'body-small-tracking', - 'body-small-weight', - 'display-large', - 'display-large-font', - 'display-large-line-height', - 'display-large-size', - 'display-large-tracking', - 'display-large-weight', - 'display-medium', - 'display-medium-font', - 'display-medium-line-height', - 'display-medium-size', - 'display-medium-tracking', - 'display-medium-weight', - 'display-small', - 'display-small-font', - 'display-small-line-height', - 'display-small-size', - 'display-small-tracking', - 'display-small-weight', - 'headline-large', - 'headline-large-font', - 'headline-large-line-height', - 'headline-large-size', - 'headline-large-tracking', - 'headline-large-weight', - 'headline-medium', - 'headline-medium-font', - 'headline-medium-line-height', - 'headline-medium-size', - 'headline-medium-tracking', - 'headline-medium-weight', - 'headline-small', - 'headline-small-font', - 'headline-small-line-height', - 'headline-small-size', - 'headline-small-tracking', - 'headline-small-weight', - 'label-large', - 'label-large-font', - 'label-large-line-height', - 'label-large-size', - 'label-large-tracking', - 'label-large-weight', - 'label-large-weight-prominent', - 'label-medium', - 'label-medium-font', - 'label-medium-line-height', - 'label-medium-size', - 'label-medium-tracking', - 'label-medium-weight', - 'label-medium-weight-prominent', - 'label-small', - 'label-small-font', - 'label-small-line-height', - 'label-small-size', - 'label-small-tracking', - 'label-small-weight', - 'title-large', - 'title-large-font', - 'title-large-line-height', - 'title-large-size', - 'title-large-tracking', - 'title-large-weight', - 'title-medium', - 'title-medium-font', - 'title-medium-line-height', - 'title-medium-size', - 'title-medium-tracking', - 'title-medium-weight', - 'title-small', - 'title-small-font', - 'title-small-line-height', - 'title-small-size', - 'title-small-tracking', - 'title-small-weight' - ); - - @return create-map($keys, $prefix); + $var-values: (); + @each $key in map.keys($sys-typography) { + $var-values: map.set($var-values, $key, var(--#{$prefix}-#{$key})); + } + @return $var-values; } - @return m3.md-sys-typescale-values($ref); + + @return $sys-typography; } /// Generates a set of namespaced color tokens for all components. @@ -337,15 +75,15 @@ $_cached-token-slots: null; /// @param {Map} $error The error palette /// @param {String} $system-variables-prefix The prefix of system tokens /// @return {Map} A map of namespaced color tokens -@function generate-color-tokens($type, $primary, $tertiary, $error, $system-variables-prefix) { - $ref: ( - md-ref-palette: generate-ref-palette-tokens($primary, $tertiary, $error) - ); - - $sys-color: _get-sys-color($type, $ref, $system-variables-prefix); +@function generate-color-tokens($type, $palettes, $system-variables-prefix) { + $sys-color: get-sys-color($type, $palettes, $system-variables-prefix); - @return generate-tokens(map.merge($ref, ( + @return generate-tokens(( md-sys-color: $sys-color, + md-ref-palette: ( + neutral-10: map.get($palettes, neutral, 10), + neutral-variant20: map.get($palettes, neutral-variant, 20), + ), // Because the elevation values are always combined with color values to create the box shadow, // elevation needs to be part of the color dimension. md-sys-elevation: m3.md-sys-elevation-values(), @@ -354,7 +92,7 @@ $_cached-token-slots: null; // TODO(mmalerba): If at some point we remove the need for these combined values, we can move // state to the base dimension. md-sys-state: m3.md-sys-state-values(), - ))); + )); } /// Generates a set of namespaced color tokens for all components. @@ -365,12 +103,8 @@ $_cached-token-slots: null; /// @param {String|Number} $regular The regular font-weight /// @param {String} $system-variables-prefix The prefix of system tokens /// @return {Map} A map of namespaced typography tokens -@function generate-typography-tokens($brand, $plain, $bold, $medium, $regular, -$system-variables-prefix) { - $ref: ( - md-ref-typeface: generate-ref-typeface-tokens($brand, $plain, $bold, $medium, $regular) - ); - $sys-typeface: _get-sys-typeface($ref, $system-variables-prefix); +@function generate-typography-tokens($typography, $system-variables-prefix) { + $sys-typeface: get-sys-typeface($typography, $system-variables-prefix); @return generate-tokens(( md-sys-typescale: $sys-typeface )); @@ -386,61 +120,3 @@ $system-variables-prefix) { md-sys-shape: m3.md-sys-shape-values(), ), $include-non-systemized: true); } - -/// Gets the full set of M3 tokens for the given theme object. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} Full set of M3 tokens -@function get-m3-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens-list: ( - m3-app.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-autocomplete.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-badge.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-bottom-sheet.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-button-toggle.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-button.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-card.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-checkbox.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-chip.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-datepicker.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-dialog.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-divider.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-expansion.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-fab.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-form-field.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-grid-list.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-icon-button.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-icon.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-list.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-menu.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-optgroup.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-option.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-paginator.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-progress-bar.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-progress-spinner.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-pseudo-checkbox.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-radio.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-ripple.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-tabs.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-select.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-sidenav.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-slide-toggle.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-slider.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-snack-bar.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-sort.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-stepper.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-table.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-timepicker.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-toolbar.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-tooltip.get-tokens($systems, $exclude-hardcoded, $token-slots), - m3-tree.get-tokens($systems, $exclude-hardcoded, $token-slots), - ); - - $merged-tokens: (); - @each $tokens in $tokens-list { - $merged-tokens: map.deep-merge($merged-tokens, $tokens); - } - - @return $merged-tokens; -} diff --git a/src/material/core/tokens/_m3-utils.scss b/src/material/core/tokens/_m3-utils.scss index 9efd6c5cab76..179ecaf867e0 100644 --- a/src/material/core/tokens/_m3-utils.scss +++ b/src/material/core/tokens/_m3-utils.scss @@ -1,85 +1,36 @@ @use 'sass:map'; -@use 'sass:list'; @use 'sass:meta'; +@use 'sass:string'; -/// Gets the MDC tokens for the given prefix, M3 token values, and supported token slots. -/// @param {List} $prefix The token prefix for the given tokens. -/// @param {Map|(Map, Map)} $values A map of M3 token values for the given prefix. -/// This param may also be a tuple of maps, the first one representing the default M3 token values, -// and the second containing overrides for different color variants. -// Single map example: -// (token1: green, token2: 2px) -// Tuple example: -// ( -// (token1: green, token2: 2px), -// ( -// secondary: (token1: blue), -// error: (token1: red), -// ) -// ) -/// @param {Map} $slots A map of token slots, with null value indicating the token is not supported. -/// @param {String|null} $variant The name of the variant the token values are for. -/// @return {Map} A map of fully qualified token names to values, for only the supported tokens. -@function namespace($prefix, $values, $slots, $variant: null) { - $result: (); - @if $variant == null and meta.type-of($values) == 'list' and list.length($values == 2) { - $variants: list.nth($values, 2); - $values: list.nth($values, 1); - @each $variant, $overrides in $variants { - - $result: map.merge($result, namespace($prefix, $overrides, $slots, $variant)); - } - } - $used-token-names: map.keys(_filter-nulls(map.get($slots, $prefix))); - $used-m3-tokens: _pick(_filter-nulls($values), $used-token-names); - $prefix: if($variant == null, $prefix, list.append($prefix, $variant)); - @return map.merge($result, ($prefix: $used-m3-tokens)); +// Replaces color tokens in the map with those defined as the variant color. +@function replace-colors-with-variant($system, $color, $variant) { + $system: map.set($system, on-#{$color}, map.get($system, on-#{$variant})); + $system: map.set($system, on-#{$color}-container, map.get($system, on-#{$variant}-container)); + $system: map.set($system, #{$color}, map.get($system, #{$variant})); + $system: map.set($system, #{$color}-container, map.get($system, #{$variant}-container)); + $system: map.set($system, inverse-#{$color}, map.get($system, inverse-#{$variant})); + @return $system; } -/// Hardcode the given value, or null if hardcoded values are excluded. -@function hardcode($value, $exclude-hardcoded) { - @return if($exclude-hardcoded, null, $value); +// Gets the theme's system values as a flat map. +@function get-system($theme) { + @return map.get($theme, _mat-system); } -/// Picks a submap containing only the given keys out the given map. -/// @param {Map} $map The map to pick from. -/// @param {List} $keys The map keys to pick. -/// @return {Map} A submap containing only the given keys. -@function _pick($map, $keys) { - $result: (); - @each $key in $keys { - @if map.has-key($map, $key) { - $result: map.set($result, $key, map.get($map, $key)); - } +// Returns the color with an opacity value using color-mix. If the color is a variable name, it +// will wrap it with `var()`. +@function color-with-opacity($color, $opacity) { + @if (meta.type-of($color) == string and string.index($color, '--') == 1) { + $color: var($color); } - @return $result; -} - -/// Filters keys with a null value out of the map. -/// @param {Map} $map The map to filter. -/// @return {Map} The given map with all of the null keys filtered out. -@function _filter-nulls($map) { - $result: (); - @each $key, $val in $map { - @if $val != null { - $result: map.set($result, $key, $val); - } + // Opacity may be a system level value less than 1, instead of the intended + // whole percentage, e.g. 38%. Remove this support when possible. + @if (meta.type-of($opacity) == string and string.index($opacity, '--') == 1) { + $opacity: 'calc(var(#{$opacity}) * 100%)'; + } @else if (meta.type-of($opacity) == number and $opacity < 1) { + $opacity: '#{$opacity * 100}%'; } - @return $result; -} -/// Sets all of the standard typography tokens for the given token base name to the given typography -/// level. -/// @param {Map} $systems The MDC system tokens -/// @param {String} $base-name The token base name to get the typography tokens for -/// @param {String} $typography-level The typography level to base the token values on -/// @return {Map} A map containing the typography tokens for the given base token name -@function generate-typography-tokens($systems, $base-name, $typography-level) { - $result: (); - @each $prop in (font, line-height, size, tracking, weight) { - $result: map.set($result, #{$base-name}-#{$prop}, - map.get($systems, md-sys-typescale, #{$typography-level}-#{$prop})); - } - @return $result; + @return color-mix(in srgb, #{$color} #{$opacity}, transparent); } diff --git a/src/material/core/tokens/_token-utils.scss b/src/material/core/tokens/_token-utils.scss index f18ddc7123bf..fdc07754e4d9 100644 --- a/src/material/core/tokens/_token-utils.scss +++ b/src/material/core/tokens/_token-utils.scss @@ -1,42 +1,8 @@ -@use '../style/elevation'; @use '../style/sass-utils'; -@use './m3-system'; @use 'sass:list'; -@use 'sass:meta'; @use 'sass:map'; @use 'sass:string'; -$_tokens: null; -$_component-prefix: null; -$_system-fallbacks: m3-system.create-system-fallbacks(); - -// Sets the token prefix and map to use when creating token slots. -@mixin use-tokens($prefix, $tokens) { - $_component-prefix: $prefix !global; - $_tokens: $tokens !global; - - @content; - - $_component-prefix: null !global; - $_tokens: null !global; -} - -// Combines a prefix and a string to generate a CSS variable name for a token. -@function _create-var-name($prefix, $token) { - @if $prefix == null or $token == null { - @error 'Must specify both prefix and name when generating token'; - } - - $string-prefix: ''; - - // Prefixes are lists so we need to combine them. - @each $part in $prefix { - $string-prefix: if($string-prefix == '', $part, '#{$string-prefix}-#{$part}'); - } - - @return string.unquote('--#{$string-prefix}-#{$token}'); -} - // Creates a CSS variable, including the fallback if provided. @function _create-var($name, $fallback: null) { @if ($fallback) { @@ -46,186 +12,78 @@ $_system-fallbacks: m3-system.create-system-fallbacks(); } } -// Gets the value of the token given the current global context state. -@function _get-token-value($token, $fallback) { - $var-name: _create-var-name($_component-prefix, $token); - $fallback: _get-token-fallback($token, $fallback); - @return _create-var($var-name, $fallback); +// Returns a list of overrides for the given M3 get-tokens mixin and prefix. Each token has its +// prefix removed since the overrides API expects its absence. The returned map includes "all" for +// all override tokens, and also the subsets with keys base, color, typography, and density. +@function get-overrides($tokens, $prefix) { + $base: remove-token-prefixes(map.get($tokens, base), $prefix); + $color: remove-token-prefixes(map.get($tokens, color), $prefix); + $typography: remove-token-prefixes(map.get($tokens, typography), $prefix); + $density: remove-token-prefixes(map.get($tokens, density), $prefix); + $all: (); + @each $map in ($base, $color, $typography, $density) { + $all: map.merge($all, $map); + } + + @return ( + all: $all, + base: $base, + color: $color, + typography: $typography, + density: $density, + ); } -// Assertion mixin that throws an error if the global state has not been set up by wrapping -// calls with `use-tokens`. -@function _assert-use-tokens($token) { - @if $_component-prefix == null or $_tokens == null { - @error 'Function was not called within a wrapping call of `use-tokens`'; +// Removes a prefix from each component token in the provided map of prefixed tokens. +@function remove-token-prefixes($prefixed-tokens: (), $prefix) { + $tokens: (); + @each $prefixed-token, $value in $prefixed-tokens { + $token: string.slice($prefixed-token, string.length($prefix) + 2); + $tokens: map.set($tokens, $token, $value); } - @if not map.has-key($_tokens, $token) { - @error 'Token #{$token} does not exist. Configured tokens are: #{map.keys($_tokens)}'; - } - - @return true; + @return $tokens; } // Returns the token slot value. // Accepts an optional fallback parameter to include in the CSS variable. // If $fallback is `true`, then use the tokens map to get the fallback. -// TODO: Remove the use case where we accept "true" and handle any failing client screenshots -@function slot($token, $fallback: null) { - $_assert: _assert-use-tokens($token); - @return _get-token-value($token, $fallback); -} - -// Returns the name of a token including the current prefix. Intended to be used in calculations -// involving tokens. `create-token-slot` should be used when outputting tokens. -@function get-token-variable-name($token) { - $_assert: _assert-use-tokens($token); - @return _create-var-name($_component-prefix, $token); -} - -// Gets the token's fallback value. Prefers adding a system-level fallback if one exists, otherwise -// use the provided fallback. -@function _get-token-fallback($token, $fallback: null) { - // If the $fallback is `true`, this is the component's signal to use the current token map value - @if ($fallback == true) { - $fallback: map.get($_tokens, $token); +@function slot($token, $fallbacks, $fallback: null) { + // Fallbacks are a map of base, color, typography, and density tokens. To simplify + // lookup, flatten these token groups into a single map. + $fallbacks-flattened: (); + @each $tokens in map.values($fallbacks) { + @each $token, $value in $tokens { + $fallbacks-flattened: map.set($fallbacks-flattened, $token, $value); + } } - - // Check whether there's a system-level fallback. If not, return the optional - // provided fallback (otherwise null). - $sys-fallback: map.get($_system-fallbacks, $_component-prefix, $token); - @if (not $sys-fallback) { - @return $fallback; + @if not map.has-key($fallbacks-flattened, $token) { + @error 'Token #{$token} does not exist. Configured tokens are:' + + #{map.keys($fallbacks-flattened)}; } + $sys-fallback: map.get($fallbacks-flattened, $token); @if (sass-utils.is-css-var-name($sys-fallback)) { - @return _create-var($sys-fallback, $fallback); + $sys-fallback: _create-var($sys-fallback, $fallback); } - @return $sys-fallback; + @return _create-var(--mat-#{$token}, $sys-fallback); } -// Outputs a map of tokens under a specific prefix. -@mixin create-token-values($prefix, $tokens) { - @include _create-token-values-internal($prefix, $tokens, false); -} - -// Outputs a map of tokens under a specific prefix in scenarios where tokens may be mixed with -// other declarations (e.g. M2 themes). Used to avoid https://sass-lang.com/documentation/breaking-changes/mixed-decls/ -@mixin create-token-values-mixed($prefix, $tokens) { - @include _create-token-values-internal($prefix, $tokens, true); -} - -@mixin _create-token-values-internal($prefix, $tokens, $in-place) { - @if ($tokens != null) { - @if ($in-place) { - & { - @each $key, $value in $tokens { - @if $value != null { - @include define-css_var($prefix, $key, $value); - } - } - } - } @else { - @each $key, $value in $tokens { - @if $value != null { - @include define-css-var($prefix, $key, $value); - } +// Outputs a map of token values as CSS variable definitions. +@mixin values($tokens) { + @include sass-utils.current-selector-or-root() { + @each $key, $value in $tokens { + @if $value != null { + --mat-#{$key}: #{$value}; } } } } -@mixin define-css-var($prefix, $key, $value) { - $name: _create-var-name($prefix, $key); - #{$name}: #{$value}; -} - -// MDC doesn't currently handle elevation tokens properly. As a temporary workaround we can combine -// the elevation and shadow-color tokens into a full box-shadow and use it as the value for the -// elevation token. -@function resolve-elevation($tokens, $elevation-token, $shadow-color-token) { - $elevation: map.get($tokens, $elevation-token); - $shadow-color: map.get($tokens, $shadow-color-token); - @return map.merge( - $tokens, - ( - $elevation-token: elevation.get-box-shadow($elevation, $shadow-color), - $shadow-color-token: null, - ) - ); -} - -/// Checks whether a list starts wih a given prefix -/// @param {List} $list The list value to check the prefix of. -/// @param {List} $prefix The prefix to check. -/// @return {Boolean} Whether the list starts with the prefix. -@function _is-prefix($list, $prefix) { - @for $i from 1 through list.length($prefix) { - @if list.nth($list, $i) != list.nth($prefix, $i) { - @return false; - } - } - @return true; -} - -/// Gets the supported color variants in the given token set for the given prefix. -/// @param {Map} $tokens The full token map. -/// @param {List} $prefix The component prefix to get color variants for. -/// @return {List} The supported color variants. -@function _supported-color-variants($tokens, $prefix) { - $result: (); - @each $namespace in map.keys($tokens) { - @if list.length($prefix) == list.length($namespace) - 1 and _is-prefix($namespace, $prefix) { - $result: list.append($result, list.nth($namespace, list.length($namespace)), comma); - } - } - @return $result; -} - -/// Gets the token values for the given components prefix with the given options. -/// @param {Map} $tokens The full token map. -/// @param {List} $prefix The component prefix to get the token values for. -/// @param {ArgList} Any additional options -/// Currently the additional supported options are: -// - $color-variant - The color variant to use for the component -// - $emit-overrides-only - Whether to emit *only* the overrides for the -// specific color variant, or all color styles. Defaults to false. -/// @throws If given options are invalid -/// @return {Map} The token values for the requested component. -@function get-tokens-for($tokens, $prefix, $options...) { - $options: sass-utils.validate-keyword-args($options, (color-variant, emit-overrides-only)); - @if $tokens == () { - @return (); - } - $values: map.get($tokens, $prefix); - @if ($values == null) { - @return (); - } - - $color-variant: map.get($options, color-variant); - $emit-overrides-only: map.get($options, emit-overrides-only); - @if $color-variant == null { - @return $values; - } - $overrides: map.get($tokens, list.append($prefix, $color-variant)); - @if $overrides == null { - $variants: _supported-color-variants($tokens, $prefix); - $secondary-message: if( - $variants == (), - 'Mixin does not support color variants', - 'Supported color variants are: #{$variants}' - ); - - @error 'Invalid color variant: #{$color-variant}. #{$secondary-message}.'; - } - - @return if($emit-overrides-only, $overrides, map.merge($values, $overrides)); -} - /// Emits new token values for the given token overrides. /// Verifies that the overrides passed in are valid tokens. /// New token values are emitted under the current selector or root. -@mixin batch-create-token-values($overrides: (), $namespace-configs...) { +@mixin batch-create-token-values($overrides: (), $namespace-configs) { @include sass-utils.current-selector-or-root() { $prefixed-name-data: (); $all-names: (); @@ -233,7 +91,7 @@ $_system-fallbacks: m3-system.create-system-fallbacks(); @each $config in $namespace-configs { $namespace: map.get($config, namespace); $prefix: if(map.has-key($config, prefix), map.get($config, prefix), ''); - $tokens: _filter-nulls(map.get($config, tokens)); + $tokens: map.get(map.get($config, tokens), all); @each $name, $value in $tokens { $prefixed-name: $prefix + $name; $all-names: list.append($all-names, $prefixed-name, $separator: comma); @@ -246,62 +104,11 @@ $_system-fallbacks: m3-system.create-system-fallbacks(); $data: map.get($prefixed-name-data, $name); $namespace: list.nth($data, 1); $name: list.nth($data, 2); - @include create-token-values( - $namespace, - ( - $name: $value, - ) - ); + $prefixed-name: $namespace + '-' + $name; + @include values(($prefixed-name: $value)); } @else { @error #{'Invalid token name `'}#{$name}#{'`. '}#{'Valid tokens are: '}#{$all-names}; } } } } - -/// Filters keys with a null value out of the map. -/// @param {Map} $map The map to filter. -/// @return {Map} The given map with all of the null keys filtered out. -@function _filter-nulls($map) { - $result: (); - @each $key, $val in $map { - @if $val != null { - $result: map.set($result, $key, $val); - } - } - @return $result; -} - -/// Gets the MDC tokens for the given prefix, M3 token values, and supported token slots. -/// @param {List} $prefix The token prefix for the given tokens. -/// @param {Map|(Map, Map)} $values A map of M3 token values for the given prefix. -/// This param may also be a tuple of maps, the first one representing the default M3 token values, -// and the second containing overrides for different color variants. -// Single map example: -// (token1: green, token2: 2px) -// Tuple example: -// ( -// (token1: green, token2: 2px), -// ( -// secondary: (token1: blue), -// error: (token1: red), -// ) -// ) -/// @param {Map} $slots A map of token slots, with null value indicating the token is not supported. -/// @param {String|null} $variant The name of the variant the token values are for. -/// @return {Map} A map of fully qualified token names to values, for only the supported tokens. -@function namespace-tokens($prefix, $values, $slots, $variant: null) { - $result: (); - @if $variant == null and meta.type-of($values) == 'list' and list.length($values == 2) { - $variants: list.nth($values, 2); - $values: list.nth($values, 1); - @each $variant, $overrides in $variants { - - $result: map.merge($result, namespace-tokens($prefix, $overrides, $slots, $variant)); - } - } - $used-token-names: map.keys(_filter-nulls(map.get($slots, $prefix))); - $used-m3-tokens: _pick(_filter-nulls($values), $used-token-names); - $prefix: if($variant == null, $prefix, list.append($prefix, $variant)); - @return map.merge($result, ($prefix: $used-m3-tokens)); -} diff --git a/src/material/core/tokens/m2/BUILD.bazel b/src/material/core/tokens/m2/BUILD.bazel new file mode 100644 index 000000000000..945d36b686e2 --- /dev/null +++ b/src/material/core/tokens/m2/BUILD.bazel @@ -0,0 +1,19 @@ +load("//tools:defaults.bzl", "sass_library") + +package(default_visibility = ["//visibility:public"]) + +sass_library( + name = "m2", + srcs = [ + "_index.scss", + "_md-sys-color.scss", + "_md-sys-elevation.scss", + "_md-sys-motion.scss", + "_md-sys-shape.scss", + "_md-sys-state.scss", + "_md-sys-typescale.scss", + ], + deps = [ + "//src/material/core/m2:palette", + ], +) diff --git a/src/material/core/tokens/m2/_index.scss b/src/material/core/tokens/m2/_index.scss new file mode 100644 index 000000000000..7cc298e39920 --- /dev/null +++ b/src/material/core/tokens/m2/_index.scss @@ -0,0 +1,6 @@ +@forward './md-sys-color'; +@forward './md-sys-elevation'; +@forward './md-sys-motion'; +@forward './md-sys-shape'; +@forward './md-sys-state'; +@forward './md-sys-typescale'; diff --git a/src/material/core/tokens/m2/_md-sys-color.scss b/src/material/core/tokens/m2/_md-sys-color.scss new file mode 100644 index 000000000000..7e6c238cdaf6 --- /dev/null +++ b/src/material/core/tokens/m2/_md-sys-color.scss @@ -0,0 +1,123 @@ +// +// Design system display name: Material 3 +// Design system version: v0.161 +// + +@use 'sass:map'; +@use '../../m2/palette'; + +@function md-sys-color-values-dark($palettes) { + @return ( + primary: map.get($palettes, primary, default), + on-primary: map.get($palettes, primary, default-contrast), + inverse-primary: map.get($palettes, primary, 600), + secondary: map.get($palettes, accent, default), + on-secondary: map.get($palettes, accent, default-contrast), + inverse-secondary: map.get($palettes, accent, 600), + error: map.get($palettes, warn, default), + on-error: map.get($palettes, warn, default-contrast), + inverse-error: map.get($palettes, warn, 600), + shadow: black, + surface: map.get(palette.$grey-palette, 800), + on-surface: white, + surface-variant: #4a4a4a, + on-surface-variant: rgba(white, 0.7), + background: #303030, + inverse-surface: white, + inverse-on-surface: rgba(black, 0.87), + outline: rgba(white, 0.12), + outline-variant: rgba(white, 0.38), + + // TBD + error-container: null, + on-background: null, + on-error-container: null, + on-primary-container: null, + on-primary-fixed: null, + on-primary-fixed-variant: null, + on-secondary-container: null, + on-secondary-fixed: null, + on-secondary-fixed-variant: null, + on-tertiary: null, + on-tertiary-container: null, + on-tertiary-fixed: null, + on-tertiary-fixed-variant: null, + primary-container: null, + primary-fixed: null, + primary-fixed-dim: null, + scrim: null, + secondary-container: null, + secondary-fixed: null, + secondary-fixed-dim: null, + surface-bright: null, + surface-container: null, + surface-container-high: null, + surface-container-highest: null, + surface-container-low: null, + surface-container-lowest: null, + surface-dim: null, + surface-tint: null, + tertiary: null, + tertiary-container: null, + tertiary-fixed: null, + tertiary-fixed-dim: null, + ); +} + +@function md-sys-color-values-light($palettes) { + @return ( + primary: map.get($palettes, primary, default), + on-primary: map.get($palettes, primary, default-contrast), + inverse-primary: map.get($palettes, primary, 300), + secondary: map.get($palettes, accent, default), + on-secondary: map.get($palettes, accent, default-contrast), + inverse-secondary: map.get($palettes, accent, 300), + error: map.get($palettes, warn, default), + on-error: map.get($palettes, warn, default-contrast), + inverse-error: map.get($palettes, warn, 300), + shadow: black, + surface: white, + on-surface: rgba(black, 0.87), + surface-variant: #f6f6f6, + on-surface-variant: rgba(black, 0.54), + background: map.get(palette.$grey-palette, 50), + inverse-surface: map.get(palette.$grey-palette, 800), + inverse-on-surface: white, + outline: rgba(black, 0.12), + outline-variant: rgba(black, 0.38), + + // TBD + error-container: null, + on-background: null, + on-error-container: null, + on-primary-container: null, + on-primary-fixed: null, + on-primary-fixed-variant: null, + on-secondary-container: null, + on-secondary-fixed: null, + on-secondary-fixed-variant: null, + on-tertiary: null, + on-tertiary-container: null, + on-tertiary-fixed: null, + on-tertiary-fixed-variant: null, + primary-container: null, + primary-fixed: null, + primary-fixed-dim: null, + scrim: null, + secondary-container: null, + secondary-fixed: null, + secondary-fixed-dim: null, + surface-bright: null, + surface-container: null, + surface-container-high: null, + surface-container-highest: null, + surface-container-low: null, + surface-container-lowest: null, + surface-dim: null, + surface-tint: null, + tertiary: null, + tertiary-container: null, + tertiary-fixed: null, + tertiary-fixed-dim: null, + ); +} diff --git a/src/material/core/tokens/m2/_md-sys-elevation.scss b/src/material/core/tokens/m2/_md-sys-elevation.scss new file mode 100644 index 000000000000..338dae2ea8e7 --- /dev/null +++ b/src/material/core/tokens/m2/_md-sys-elevation.scss @@ -0,0 +1,16 @@ +// +// Design system display name: Material 3 +// Design system version: v0.161 +// + +@function md-sys-elevation-values() { + @return ( + // TBD + level0: null, + level1: null, + level2: null, + level3: null, + level4: null, + level5: null, + ); +} diff --git a/src/material/core/tokens/m2/_md-sys-motion.scss b/src/material/core/tokens/m2/_md-sys-motion.scss new file mode 100644 index 000000000000..233682c6c359 --- /dev/null +++ b/src/material/core/tokens/m2/_md-sys-motion.scss @@ -0,0 +1,36 @@ +// +// Design system display name: Material 3 +// Design system version: v0.161 +// + +@function md-sys-motion-values() { + @return ( + // TBD + duration-extra-long1: null, + duration-extra-long2: null, + duration-extra-long3: null, + duration-extra-long4: null, + duration-long1: null, + duration-long2: null, + duration-long3: null, + duration-long4: null, + duration-medium1: null, + duration-medium2: null, + duration-medium3: null, + duration-medium4: null, + duration-short1: null, + duration-short2: null, + duration-short3: null, + duration-short4: null, + easing-emphasized: null, + easing-emphasized-accelerate: null, + easing-emphasized-decelerate: null, + easing-legacy: null, + easing-legacy-accelerate: null, + easing-legacy-decelerate: null, + easing-linear: null, + easing-standard: null, + easing-standard-accelerate: null, + easing-standard-decelerate: null, + ); +} diff --git a/src/material/core/tokens/m2/_md-sys-shape.scss b/src/material/core/tokens/m2/_md-sys-shape.scss new file mode 100644 index 000000000000..17499bf4ee24 --- /dev/null +++ b/src/material/core/tokens/m2/_md-sys-shape.scss @@ -0,0 +1,22 @@ +// +// Design system display name: Material 3 +// Design system version: v0.161 +// + +@function md-sys-shape-values() { + @return ( + // TBD + corner-extra-large: null, + corner-extra-large-top: null, + corner-extra-small: null, + corner-extra-small-top: null, + corner-full: null, + corner-large: null, + corner-large-end: null, + corner-large-start: null, + corner-large-top: null, + corner-medium: null, + corner-none: null, + corner-small: null, + ); +} diff --git a/src/material/core/tokens/m2/_md-sys-state.scss b/src/material/core/tokens/m2/_md-sys-state.scss new file mode 100644 index 000000000000..5733370c9470 --- /dev/null +++ b/src/material/core/tokens/m2/_md-sys-state.scss @@ -0,0 +1,15 @@ +// +// Design system display name: Material 3 +// Design system version: v0.161 +// + +@function md-sys-state-values($exclude-hardcoded-values: false) { + @return ( + focus-state-layer-opacity: 0.12, + hover-state-layer-opacity: 0.04, + pressed-state-layer-opacity: 0.12, + + // TBD + dragged-state-layer-opacity: 0.16, + ); +} diff --git a/src/material/core/tokens/m2/_md-sys-typescale.scss b/src/material/core/tokens/m2/_md-sys-typescale.scss new file mode 100644 index 000000000000..212c3afdeab4 --- /dev/null +++ b/src/material/core/tokens/m2/_md-sys-typescale.scss @@ -0,0 +1,68 @@ +@use 'sass:map'; +@use 'sass:string'; +@use 'sass:meta'; + +@function md-sys-typescale-values($config) { + // Mapping is according to the old 2014 version of the typography spec. + $sys-to-config: ( + body-large: subheading-1, + body-medium: body-1, + body-small: caption, + display-large: display-4, + display-medium: display-4, + display-small: display-4, + headline-large: display-3, + headline-medium: display-2, + headline-small: display-1, + label-large: subheading-2, + label-medium: body-2, + label-small: button, + title-large: headline, + title-medium: headline, + title-small: title, + ); + + // If the config is based on the updated 2018 version of the typography spec, then + // use the correct config keys mapping. + @if (map.get($config, headline-1) != null) { + $sys-to-config: ( + body-large: body-1, + body-medium: body-2, + body-small: caption, + label-large: subtitle-1, + label-medium: subtitle-2, + label-small: button, + display-large: headline-1, + display-medium: headline-1, + display-small: headline-1, + headline-large: headline-2, + headline-medium: headline-3, + headline-small: headline-4, + title-large: headline-5, + title-medium: headline-5, + title-small: headline-6, + ); + } + + $typography: (); + @each $sys-key, $config-key in $sys-to-config { + $font: map.get($config, $config-key, font-family); + @if (meta.type-of($font) == 'string') { + $font: string.unquote($font); + } + $line-height: map.get($config, $config-key, line-height); + $size: map.get($config, $config-key, font-size); + $tracking: map.get($config, $config-key, letter-spacing); + $weight: map.get($config, $config-key, font-weight); + $typography: map.merge($typography, ( + #{$sys-key}: $weight $size #{'/'} $line-height $font, + #{$sys-key}-font: $font, + #{$sys-key}-line-height: $line-height, + #{$sys-key}-size: $size, + #{$sys-key}-tracking: $tracking, + #{$sys-key}-weight: $weight, + )); + } + + @return $typography; +} diff --git a/src/material/core/tokens/m3/BUILD.bazel b/src/material/core/tokens/m3/BUILD.bazel index d9013d84d46c..31e685cc1945 100644 --- a/src/material/core/tokens/m3/BUILD.bazel +++ b/src/material/core/tokens/m3/BUILD.bazel @@ -6,13 +6,12 @@ sass_library( name = "m3", srcs = [ "_index.scss", - "_md-ref-palette.scss", - "_md-ref-typeface.scss", "_md-sys-color.scss", "_md-sys-elevation.scss", "_md-sys-motion.scss", "_md-sys-shape.scss", "_md-sys-state.scss", "_md-sys-typescale.scss", + "_theme.scss", ], ) diff --git a/src/material/core/tokens/m3/_index.scss b/src/material/core/tokens/m3/_index.scss index 1a4ff56056f6..9bcad7f5b598 100644 --- a/src/material/core/tokens/m3/_index.scss +++ b/src/material/core/tokens/m3/_index.scss @@ -1,8 +1,7 @@ -@forward './md-ref-palette' as md-ref-palette-*; -@forward './md-ref-typeface' as md-ref-typeface-*; -@forward './md-sys-color' as md-sys-color-*; -@forward './md-sys-elevation' as md-sys-elevation-*; -@forward './md-sys-motion' as md-sys-motion-*; -@forward './md-sys-shape' as md-sys-shape-*; -@forward './md-sys-state' as md-sys-state-*; -@forward './md-sys-typescale' as md-sys-typescale-*; +@forward './md-sys-color'; +@forward './md-sys-elevation'; +@forward './md-sys-motion'; +@forward './md-sys-shape'; +@forward './md-sys-state'; +@forward './md-sys-typescale'; +@forward './theme'; diff --git a/src/material/core/tokens/m3/_md-ref-palette.scss b/src/material/core/tokens/m3/_md-ref-palette.scss deleted file mode 100644 index 60364cb43c97..000000000000 --- a/src/material/core/tokens/m3/_md-ref-palette.scss +++ /dev/null @@ -1,100 +0,0 @@ -// -// Design system display name: Material 3 -// Design system version: v0.161 -// - -@function values($exclude-hardcoded-values: false) { - @return ( - 'black': if($exclude-hardcoded-values, null, #000), - 'error0': if($exclude-hardcoded-values, null, #000), - 'error10': if($exclude-hardcoded-values, null, #410e0b), - 'error100': if($exclude-hardcoded-values, null, #fff), - 'error20': if($exclude-hardcoded-values, null, #601410), - 'error30': if($exclude-hardcoded-values, null, #8c1d18), - 'error40': if($exclude-hardcoded-values, null, #b3261e), - 'error50': if($exclude-hardcoded-values, null, #dc362e), - 'error60': if($exclude-hardcoded-values, null, #e46962), - 'error70': if($exclude-hardcoded-values, null, #ec928e), - 'error80': if($exclude-hardcoded-values, null, #f2b8b5), - 'error90': if($exclude-hardcoded-values, null, #f9dedc), - 'error95': if($exclude-hardcoded-values, null, #fceeee), - 'error99': if($exclude-hardcoded-values, null, #fffbf9), - 'neutral-variant0': if($exclude-hardcoded-values, null, #000), - 'neutral-variant10': if($exclude-hardcoded-values, null, #1d1a22), - 'neutral-variant100': if($exclude-hardcoded-values, null, #fff), - 'neutral-variant20': if($exclude-hardcoded-values, null, #322f37), - 'neutral-variant30': if($exclude-hardcoded-values, null, #49454f), - 'neutral-variant40': if($exclude-hardcoded-values, null, #605d66), - 'neutral-variant50': if($exclude-hardcoded-values, null, #79747e), - 'neutral-variant60': if($exclude-hardcoded-values, null, #938f99), - 'neutral-variant70': if($exclude-hardcoded-values, null, #aea9b4), - 'neutral-variant80': if($exclude-hardcoded-values, null, #cac4d0), - 'neutral-variant90': if($exclude-hardcoded-values, null, #e7e0ec), - 'neutral-variant95': if($exclude-hardcoded-values, null, #f5eefa), - 'neutral-variant99': if($exclude-hardcoded-values, null, #fffbfe), - 'neutral0': if($exclude-hardcoded-values, null, #000), - 'neutral10': if($exclude-hardcoded-values, null, #1c1b1f), - 'neutral100': if($exclude-hardcoded-values, null, #fff), - 'neutral12': if($exclude-hardcoded-values, null, #201f23), - 'neutral17': if($exclude-hardcoded-values, null, #2b292d), - 'neutral20': if($exclude-hardcoded-values, null, #313033), - 'neutral22': if($exclude-hardcoded-values, null, #313033), - 'neutral24': if($exclude-hardcoded-values, null, #313033), - 'neutral30': if($exclude-hardcoded-values, null, #484649), - 'neutral4': if($exclude-hardcoded-values, null, #0e0e11), - 'neutral40': if($exclude-hardcoded-values, null, #605d62), - 'neutral50': if($exclude-hardcoded-values, null, #787579), - 'neutral6': if($exclude-hardcoded-values, null, #141317), - 'neutral60': if($exclude-hardcoded-values, null, #939094), - 'neutral70': if($exclude-hardcoded-values, null, #aeaaae), - 'neutral80': if($exclude-hardcoded-values, null, #c9c5ca), - 'neutral87': if($exclude-hardcoded-values, null, #ddd8dd), - 'neutral90': if($exclude-hardcoded-values, null, #e6e1e5), - 'neutral92': if($exclude-hardcoded-values, null, #ece7ec), - 'neutral94': if($exclude-hardcoded-values, null, #f1ecf1), - 'neutral95': if($exclude-hardcoded-values, null, #f4eff4), - 'neutral96': if($exclude-hardcoded-values, null, #f7f2f7), - 'neutral98': if($exclude-hardcoded-values, null, #fdf8fd), - 'neutral99': if($exclude-hardcoded-values, null, #fffbfe), - 'primary0': if($exclude-hardcoded-values, null, #000), - 'primary10': if($exclude-hardcoded-values, null, #21005d), - 'primary100': if($exclude-hardcoded-values, null, #fff), - 'primary20': if($exclude-hardcoded-values, null, #381e72), - 'primary30': if($exclude-hardcoded-values, null, #4f378b), - 'primary40': if($exclude-hardcoded-values, null, #6750a4), - 'primary50': if($exclude-hardcoded-values, null, #7f67be), - 'primary60': if($exclude-hardcoded-values, null, #9a82db), - 'primary70': if($exclude-hardcoded-values, null, #b69df8), - 'primary80': if($exclude-hardcoded-values, null, #d0bcff), - 'primary90': if($exclude-hardcoded-values, null, #eaddff), - 'primary95': if($exclude-hardcoded-values, null, #f6edff), - 'primary99': if($exclude-hardcoded-values, null, #fffbfe), - 'secondary0': if($exclude-hardcoded-values, null, #000), - 'secondary10': if($exclude-hardcoded-values, null, #1d192b), - 'secondary100': if($exclude-hardcoded-values, null, #fff), - 'secondary20': if($exclude-hardcoded-values, null, #332d41), - 'secondary30': if($exclude-hardcoded-values, null, #4a4458), - 'secondary40': if($exclude-hardcoded-values, null, #625b71), - 'secondary50': if($exclude-hardcoded-values, null, #7a7289), - 'secondary60': if($exclude-hardcoded-values, null, #958da5), - 'secondary70': if($exclude-hardcoded-values, null, #b0a7c0), - 'secondary80': if($exclude-hardcoded-values, null, #ccc2dc), - 'secondary90': if($exclude-hardcoded-values, null, #e8def8), - 'secondary95': if($exclude-hardcoded-values, null, #f6edff), - 'secondary99': if($exclude-hardcoded-values, null, #fffbfe), - 'tertiary0': if($exclude-hardcoded-values, null, #000), - 'tertiary10': if($exclude-hardcoded-values, null, #31111d), - 'tertiary100': if($exclude-hardcoded-values, null, #fff), - 'tertiary20': if($exclude-hardcoded-values, null, #492532), - 'tertiary30': if($exclude-hardcoded-values, null, #633b48), - 'tertiary40': if($exclude-hardcoded-values, null, #7d5260), - 'tertiary50': if($exclude-hardcoded-values, null, #986977), - 'tertiary60': if($exclude-hardcoded-values, null, #b58392), - 'tertiary70': if($exclude-hardcoded-values, null, #d29dac), - 'tertiary80': if($exclude-hardcoded-values, null, #efb8c8), - 'tertiary90': if($exclude-hardcoded-values, null, #ffd8e4), - 'tertiary95': if($exclude-hardcoded-values, null, #ffecf1), - 'tertiary99': if($exclude-hardcoded-values, null, #fffbfa), - 'white': if($exclude-hardcoded-values, null, #fff) - ); -} diff --git a/src/material/core/tokens/m3/_md-ref-typeface.scss b/src/material/core/tokens/m3/_md-ref-typeface.scss deleted file mode 100644 index c26dcbc46d95..000000000000 --- a/src/material/core/tokens/m3/_md-ref-typeface.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Design system display name: Material 3 -// Design system version: v0.161 -// - -@function values($exclude-hardcoded-values: false) { - @return ( - 'brand': if($exclude-hardcoded-values, null, (Roboto)), - 'plain': if($exclude-hardcoded-values, null, (Roboto)), - 'weight-bold': if($exclude-hardcoded-values, null, 700), - 'weight-medium': if($exclude-hardcoded-values, null, 500), - 'weight-regular': if($exclude-hardcoded-values, null, 400) - ); -} diff --git a/src/material/core/tokens/m3/_md-sys-color.scss b/src/material/core/tokens/m3/_md-sys-color.scss index cb69dc47af24..9eb5802a6231 100644 --- a/src/material/core/tokens/m3/_md-sys-color.scss +++ b/src/material/core/tokens/m3/_md-sys-color.scss @@ -5,162 +5,150 @@ @use 'sass:map'; -@use './md-ref-palette'; - // Indicates whether alternative tokens should be used $_alternate-tokens: false; -$_default-dark: ( - 'md-ref-palette': md-ref-palette.values(), -); - -@function values-dark($deps: $_default-dark) { +@function md-sys-color-values-dark($palettes: ()) { $values: ( - 'background': map.get($deps, 'md-ref-palette', 'neutral6'), - 'error': map.get($deps, 'md-ref-palette', 'error80'), - 'error-container': map.get($deps, 'md-ref-palette', 'error30'), - 'inverse-on-surface': map.get($deps, 'md-ref-palette', 'neutral20'), - 'inverse-primary': map.get($deps, 'md-ref-palette', 'primary40'), - 'inverse-surface': map.get($deps, 'md-ref-palette', 'neutral90'), - 'on-background': map.get($deps, 'md-ref-palette', 'neutral90'), - 'on-error': map.get($deps, 'md-ref-palette', 'error20'), - 'on-error-container': map.get($deps, 'md-ref-palette', 'error90'), - 'on-primary': map.get($deps, 'md-ref-palette', 'primary20'), - 'on-primary-container': map.get($deps, 'md-ref-palette', 'primary90'), - 'on-primary-fixed': map.get($deps, 'md-ref-palette', 'primary10'), - 'on-primary-fixed-variant': map.get($deps, 'md-ref-palette', 'primary30'), - 'on-secondary': map.get($deps, 'md-ref-palette', 'secondary20'), - 'on-secondary-container': map.get($deps, 'md-ref-palette', 'secondary90'), - 'on-secondary-fixed': map.get($deps, 'md-ref-palette', 'secondary10'), - 'on-secondary-fixed-variant': - map.get($deps, 'md-ref-palette', 'secondary30'), - 'on-surface': map.get($deps, 'md-ref-palette', 'neutral90'), - 'on-surface-variant': map.get($deps, 'md-ref-palette', 'neutral-variant90'), - 'on-tertiary': map.get($deps, 'md-ref-palette', 'tertiary20'), - 'on-tertiary-container': map.get($deps, 'md-ref-palette', 'tertiary90'), - 'on-tertiary-fixed': map.get($deps, 'md-ref-palette', 'tertiary10'), - 'on-tertiary-fixed-variant': map.get($deps, 'md-ref-palette', 'tertiary30'), - 'outline': map.get($deps, 'md-ref-palette', 'neutral-variant60'), - 'outline-variant': map.get($deps, 'md-ref-palette', 'neutral-variant30'), - 'primary': map.get($deps, 'md-ref-palette', 'primary80'), - 'primary-container': map.get($deps, 'md-ref-palette', 'primary30'), - 'primary-fixed': map.get($deps, 'md-ref-palette', 'primary90'), - 'primary-fixed-dim': map.get($deps, 'md-ref-palette', 'primary80'), - 'scrim': map.get($deps, 'md-ref-palette', 'neutral0'), - 'secondary': map.get($deps, 'md-ref-palette', 'secondary80'), - 'secondary-container': map.get($deps, 'md-ref-palette', 'secondary30'), - 'secondary-fixed': map.get($deps, 'md-ref-palette', 'secondary90'), - 'secondary-fixed-dim': map.get($deps, 'md-ref-palette', 'secondary80'), - 'shadow': map.get($deps, 'md-ref-palette', 'neutral0'), - 'surface': map.get($deps, 'md-ref-palette', 'neutral6'), - 'surface-bright': map.get($deps, 'md-ref-palette', 'neutral24'), - 'surface-container': map.get($deps, 'md-ref-palette', 'neutral12'), - 'surface-container-high': map.get($deps, 'md-ref-palette', 'neutral17'), - 'surface-container-highest': map.get($deps, 'md-ref-palette', 'neutral22'), - 'surface-container-low': map.get($deps, 'md-ref-palette', 'neutral10'), - 'surface-container-lowest': map.get($deps, 'md-ref-palette', 'neutral4'), - 'surface-dim': map.get($deps, 'md-ref-palette', 'neutral6'), - 'surface-tint': map.get($deps, 'md-ref-palette', 'primary80'), - 'surface-variant': map.get($deps, 'md-ref-palette', 'neutral-variant30'), - 'tertiary': map.get($deps, 'md-ref-palette', 'tertiary80'), - 'tertiary-container': map.get($deps, 'md-ref-palette', 'tertiary30'), - 'tertiary-fixed': map.get($deps, 'md-ref-palette', 'tertiary90'), - 'tertiary-fixed-dim': map.get($deps, 'md-ref-palette', 'tertiary80') + background: map.get($palettes, neutral, 6), + error: map.get($palettes, error, 80), + error-container: map.get($palettes, error, 30), + inverse-on-surface: map.get($palettes, neutral, 20), + inverse-primary: map.get($palettes, primary, 40), + inverse-surface: map.get($palettes, neutral, 90), + on-background: map.get($palettes, neutral, 90), + on-error: map.get($palettes, error, 20), + on-error-container: map.get($palettes, error, 90), + on-primary: map.get($palettes, primary, 20), + on-primary-container: map.get($palettes, primary, 90), + on-primary-fixed: map.get($palettes, primary, 10), + on-primary-fixed-variant: map.get($palettes, primary, 30), + on-secondary: map.get($palettes, secondary, 20), + on-secondary-container: map.get($palettes, secondary, 90), + on-secondary-fixed: map.get($palettes, secondary, 10), + on-secondary-fixed-variant: map.get($palettes, secondary, 30), + on-surface: map.get($palettes, neutral, 90), + on-surface-variant: map.get($palettes, neutral-variant, 90), + on-tertiary: map.get($palettes, tertiary, 20), + on-tertiary-container: map.get($palettes, tertiary, 90), + on-tertiary-fixed: map.get($palettes, tertiary, 10), + on-tertiary-fixed-variant: map.get($palettes, tertiary, 30), + outline: map.get($palettes, neutral-variant, 60), + outline-variant: map.get($palettes, neutral-variant, 30), + primary: map.get($palettes, primary, 80), + primary-container: map.get($palettes, primary, 30), + primary-fixed: map.get($palettes, primary, 90), + primary-fixed-dim: map.get($palettes, primary, 80), + scrim: map.get($palettes, neutral, 0), + secondary: map.get($palettes, secondary, 80), + secondary-container: map.get($palettes, secondary, 30), + secondary-fixed: map.get($palettes, secondary, 90), + secondary-fixed-dim: map.get($palettes, secondary, 80), + shadow: map.get($palettes, neutral, 0), + surface: map.get($palettes, neutral, 6), + surface-bright: map.get($palettes, neutral, 24), + surface-container: map.get($palettes, neutral, 12), + surface-container-high: map.get($palettes, neutral, 17), + surface-container-highest: map.get($palettes, neutral, 22), + surface-container-low: map.get($palettes, neutral, 10), + surface-container-lowest: map.get($palettes, neutral, 4), + surface-dim: map.get($palettes, neutral, 6), + surface-tint: map.get($palettes, primary, 80), + surface-variant: map.get($palettes, neutral-variant, 30), + tertiary: map.get($palettes, tertiary, 80), + tertiary-container: map.get($palettes, tertiary, 30), + tertiary-fixed: map.get($palettes, tertiary, 90), + tertiary-fixed-dim: map.get($palettes, tertiary, 80) ); @if ($_alternate-tokens) { $values: map.merge($values, ( - 'background': map.get($deps, 'md-ref-palette', 'neutral10'), - 'on-surface-variant': map.get($deps, 'md-ref-palette', 'neutral-variant80'), - 'surface': map.get($deps, 'md-ref-palette', 'neutral10'), - 'surface-bright': #37393b, - 'surface-container': #1e1f20, - 'surface-container-high': #282a2c, - 'surface-container-highest': #333537, - 'surface-container-low': #1b1b1b, - 'surface-container-lowest': #0e0e0e, - 'surface-dim': #131313, - 'surface-tint': #d1e1ff, + background: map.get($palettes, neutral, 10), + on-surface-variant: map.get($palettes, neutral-variant, 80), + surface: map.get($palettes, neutral, 10), + surface-bright: #37393b, + surface-container: #1e1f20, + surface-container-high: #282a2c, + surface-container-highest: #333537, + surface-container-low: #1b1b1b, + surface-container-lowest: #0e0e0e, + surface-dim: #131313, + surface-tint: #d1e1ff, )); } @return $values; } -$_default-light: ( - 'md-ref-palette': md-ref-palette.values(), -); - -@function values-light($deps: $_default-light) { +@function md-sys-color-values-light($palettes: ()) { $values: ( - 'background': map.get($deps, 'md-ref-palette', 'neutral98'), - 'error': map.get($deps, 'md-ref-palette', 'error40'), - 'error-container': map.get($deps, 'md-ref-palette', 'error90'), - 'inverse-on-surface': map.get($deps, 'md-ref-palette', 'neutral95'), - 'inverse-primary': map.get($deps, 'md-ref-palette', 'primary80'), - 'inverse-surface': map.get($deps, 'md-ref-palette', 'neutral20'), - 'on-background': map.get($deps, 'md-ref-palette', 'neutral10'), - 'on-error': map.get($deps, 'md-ref-palette', 'error100'), - 'on-error-container': map.get($deps, 'md-ref-palette', 'error30'), - 'on-primary': map.get($deps, 'md-ref-palette', 'primary100'), - 'on-primary-container': map.get($deps, 'md-ref-palette', 'primary30'), - 'on-primary-fixed': map.get($deps, 'md-ref-palette', 'primary10'), - 'on-primary-fixed-variant': map.get($deps, 'md-ref-palette', 'primary30'), - 'on-secondary': map.get($deps, 'md-ref-palette', 'secondary100'), - 'on-secondary-container': map.get($deps, 'md-ref-palette', 'secondary30'), - 'on-secondary-fixed': map.get($deps, 'md-ref-palette', 'secondary10'), - 'on-secondary-fixed-variant': - map.get($deps, 'md-ref-palette', 'secondary30'), - 'on-surface': map.get($deps, 'md-ref-palette', 'neutral10'), - 'on-surface-variant': map.get($deps, 'md-ref-palette', 'neutral-variant30'), - 'on-tertiary': map.get($deps, 'md-ref-palette', 'tertiary100'), - 'on-tertiary-container': map.get($deps, 'md-ref-palette', 'tertiary30'), - 'on-tertiary-fixed': map.get($deps, 'md-ref-palette', 'tertiary10'), - 'on-tertiary-fixed-variant': map.get($deps, 'md-ref-palette', 'tertiary30'), - 'outline': map.get($deps, 'md-ref-palette', 'neutral-variant50'), - 'outline-variant': map.get($deps, 'md-ref-palette', 'neutral-variant80'), - 'primary': map.get($deps, 'md-ref-palette', 'primary40'), - 'primary-container': map.get($deps, 'md-ref-palette', 'primary90'), - 'primary-fixed': map.get($deps, 'md-ref-palette', 'primary90'), - 'primary-fixed-dim': map.get($deps, 'md-ref-palette', 'primary80'), - 'scrim': map.get($deps, 'md-ref-palette', 'neutral0'), - 'secondary': map.get($deps, 'md-ref-palette', 'secondary40'), - 'secondary-container': map.get($deps, 'md-ref-palette', 'secondary90'), - 'secondary-fixed': map.get($deps, 'md-ref-palette', 'secondary90'), - 'secondary-fixed-dim': map.get($deps, 'md-ref-palette', 'secondary80'), - 'shadow': map.get($deps, 'md-ref-palette', 'neutral0'), - 'surface': map.get($deps, 'md-ref-palette', 'neutral98'), - 'surface-bright': map.get($deps, 'md-ref-palette', 'neutral98'), - 'surface-container': map.get($deps, 'md-ref-palette', 'neutral94'), - 'surface-container-high': map.get($deps, 'md-ref-palette', 'neutral92'), - 'surface-container-highest': map.get($deps, 'md-ref-palette', 'neutral90'), - 'surface-container-low': map.get($deps, 'md-ref-palette', 'neutral96'), - 'surface-container-lowest': map.get($deps, 'md-ref-palette', 'neutral100'), - 'surface-dim': map.get($deps, 'md-ref-palette', 'neutral87'), - 'surface-tint': map.get($deps, 'md-ref-palette', 'primary40'), - 'surface-variant': map.get($deps, 'md-ref-palette', 'neutral-variant90'), - 'tertiary': map.get($deps, 'md-ref-palette', 'tertiary40'), - 'tertiary-container': map.get($deps, 'md-ref-palette', 'tertiary90'), - 'tertiary-fixed': map.get($deps, 'md-ref-palette', 'tertiary90'), - 'tertiary-fixed-dim': map.get($deps, 'md-ref-palette', 'tertiary80') + background: map.get($palettes, neutral, 98), + error: map.get($palettes, error, 40), + error-container: map.get($palettes, error, 90), + inverse-on-surface: map.get($palettes, neutral, 95), + inverse-primary: map.get($palettes, primary, 80), + inverse-surface: map.get($palettes, neutral, 20), + on-background: map.get($palettes, neutral, 10), + on-error: map.get($palettes, error, 100), + on-error-container: map.get($palettes, error, 30), + on-primary: map.get($palettes, primary, 100), + on-primary-container: map.get($palettes, primary, 30), + on-primary-fixed: map.get($palettes, primary, 10), + on-primary-fixed-variant: map.get($palettes, primary, 30), + on-secondary: map.get($palettes, secondary, 100), + on-secondary-container: map.get($palettes, secondary, 30), + on-secondary-fixed: map.get($palettes, secondary, 10), + on-secondary-fixed-variant: map.get($palettes, secondary, 30), + on-surface: map.get($palettes, neutral, 10), + on-surface-variant: map.get($palettes, neutral-variant, 30), + on-tertiary: map.get($palettes, tertiary, 100), + on-tertiary-container: map.get($palettes, tertiary, 30), + on-tertiary-fixed: map.get($palettes, tertiary, 10), + on-tertiary-fixed-variant: map.get($palettes, tertiary, 30), + outline: map.get($palettes, neutral-variant, 50), + outline-variant: map.get($palettes, neutral-variant, 80), + primary: map.get($palettes, primary, 40), + primary-container: map.get($palettes, primary, 90), + primary-fixed: map.get($palettes, primary, 90), + primary-fixed-dim: map.get($palettes, primary, 80), + scrim: map.get($palettes, neutral, 0), + secondary: map.get($palettes, secondary, 40), + secondary-container: map.get($palettes, secondary, 90), + secondary-fixed: map.get($palettes, secondary, 90), + secondary-fixed-dim: map.get($palettes, secondary, 80), + shadow: map.get($palettes, neutral, 0), + surface: map.get($palettes, neutral, 98), + surface-bright: map.get($palettes, neutral, 98), + surface-container: map.get($palettes, neutral, 94), + surface-container-high: map.get($palettes, neutral, 92), + surface-container-highest: map.get($palettes, neutral, 90), + surface-container-low: map.get($palettes, neutral, 96), + surface-container-lowest: map.get($palettes, neutral, 100), + surface-dim: map.get($palettes, neutral, 87), + surface-tint: map.get($palettes, primary, 40), + surface-variant: map.get($palettes, neutral-variant, 90), + tertiary: map.get($palettes, tertiary, 40), + tertiary-container: map.get($palettes, tertiary, 90), + tertiary-fixed: map.get($palettes, tertiary, 90), + tertiary-fixed-dim: map.get($palettes, tertiary, 80) ); @if ($_alternate-tokens) { $values: map.merge($values, ( - 'background': map.get($deps, 'md-ref-palette', 'neutral100'), - 'on-error-container': map.get($deps, 'md-ref-palette', 'error10'), - 'on-primary-container': map.get($deps, 'md-ref-palette', 'primary10'), - 'on-secondary-container': map.get($deps, 'md-ref-palette', 'secondary10'), - 'on-tertiary-container': map.get($deps, 'md-ref-palette', 'tertiary10'), - 'surface': map.get($deps, 'md-ref-palette', 'neutral100'), - 'surface-bright': map.get($deps, 'md-ref-palette', 'neutral100'), - 'surface-container': #f0f4f9, - 'surface-container-high': #e9eef6, - 'surface-container-highest': #dde3ea, - 'surface-container-low': #f8fafd, - 'surface-container-lowest': map.get($deps, 'md-ref-palette', 'primary100'), - 'surface-dim': #d3dbe5, - 'surface-tint': #6991d6, + background: map.get($palettes, neutral, 100), + on-error-container: map.get($palettes, error, 10), + on-primary-container: map.get($palettes, primary, 10), + on-secondary-container: map.get($palettes, secondary, 10), + on-tertiary-container: map.get($palettes, tertiary, 10), + surface: map.get($palettes, neutral, 100), + surface-bright: map.get($palettes, neutral, 100), + surface-container: #f0f4f9, + surface-container-high: #e9eef6, + surface-container-highest: #dde3ea, + surface-container-low: #f8fafd, + surface-container-lowest: map.get($palettes, primary, 100), + surface-dim: #d3dbe5, + surface-tint: #6991d6, )); } diff --git a/src/material/core/tokens/m3/_md-sys-elevation.scss b/src/material/core/tokens/m3/_md-sys-elevation.scss index 8263f7ef84b3..6aad8c6baad9 100644 --- a/src/material/core/tokens/m3/_md-sys-elevation.scss +++ b/src/material/core/tokens/m3/_md-sys-elevation.scss @@ -3,13 +3,13 @@ // Design system version: v0.161 // -@function values($exclude-hardcoded-values: false) { +@function md-sys-elevation-values() { @return ( - 'level0': if($exclude-hardcoded-values, null, 0), - 'level1': if($exclude-hardcoded-values, null, 1), - 'level2': if($exclude-hardcoded-values, null, 3), - 'level3': if($exclude-hardcoded-values, null, 6), - 'level4': if($exclude-hardcoded-values, null, 8), - 'level5': if($exclude-hardcoded-values, null, 12) + level0: 0, + level1: 1, + level2: 3, + level3: 6, + level4: 8, + level5: 12 ); } diff --git a/src/material/core/tokens/m3/_md-sys-motion.scss b/src/material/core/tokens/m3/_md-sys-motion.scss index f2f9ca84d410..645f99b158d9 100644 --- a/src/material/core/tokens/m3/_md-sys-motion.scss +++ b/src/material/core/tokens/m3/_md-sys-motion.scss @@ -3,44 +3,33 @@ // Design system version: v0.161 // -@function values($exclude-hardcoded-values: false) { +@function md-sys-motion-values() { @return ( - 'duration-extra-long1': if($exclude-hardcoded-values, null, 700ms), - 'duration-extra-long2': if($exclude-hardcoded-values, null, 800ms), - 'duration-extra-long3': if($exclude-hardcoded-values, null, 900ms), - 'duration-extra-long4': if($exclude-hardcoded-values, null, 1000ms), - 'duration-long1': if($exclude-hardcoded-values, null, 450ms), - 'duration-long2': if($exclude-hardcoded-values, null, 500ms), - 'duration-long3': if($exclude-hardcoded-values, null, 550ms), - 'duration-long4': if($exclude-hardcoded-values, null, 600ms), - 'duration-medium1': if($exclude-hardcoded-values, null, 250ms), - 'duration-medium2': if($exclude-hardcoded-values, null, 300ms), - 'duration-medium3': if($exclude-hardcoded-values, null, 350ms), - 'duration-medium4': if($exclude-hardcoded-values, null, 400ms), - 'duration-short1': if($exclude-hardcoded-values, null, 50ms), - 'duration-short2': if($exclude-hardcoded-values, null, 100ms), - 'duration-short3': if($exclude-hardcoded-values, null, 150ms), - 'duration-short4': if($exclude-hardcoded-values, null, 200ms), - 'easing-emphasized': - if($exclude-hardcoded-values, null, cubic-bezier(0.2, 0, 0, 1)), - 'easing-emphasized-accelerate': - if($exclude-hardcoded-values, null, cubic-bezier(0.3, 0, 0.8, 0.15)), - 'easing-emphasized-decelerate': - if($exclude-hardcoded-values, null, cubic-bezier(0.05, 0.7, 0.1, 1)), - 'easing-legacy': - if($exclude-hardcoded-values, null, cubic-bezier(0.4, 0, 0.2, 1)), - 'easing-legacy-accelerate': - if($exclude-hardcoded-values, null, cubic-bezier(0.4, 0, 1, 1)), - 'easing-legacy-decelerate': - if($exclude-hardcoded-values, null, cubic-bezier(0, 0, 0.2, 1)), - 'easing-linear': - if($exclude-hardcoded-values, null, cubic-bezier(0, 0, 1, 1)), - 'easing-standard': - if($exclude-hardcoded-values, null, cubic-bezier(0.2, 0, 0, 1)), - 'easing-standard-accelerate': - if($exclude-hardcoded-values, null, cubic-bezier(0.3, 0, 1, 1)), - 'easing-standard-decelerate': - if($exclude-hardcoded-values, null, cubic-bezier(0, 0, 0, 1)), - 'path': /** TODO: type "motion_path" is not supported. */ null + duration-extra-long1: 700ms, + duration-extra-long2: 800ms, + duration-extra-long3: 900ms, + duration-extra-long4: 1000ms, + duration-long1: 450ms, + duration-long2: 500ms, + duration-long3: 550ms, + duration-long4: 600ms, + duration-medium1: 250ms, + duration-medium2: 300ms, + duration-medium3: 350ms, + duration-medium4: 400ms, + duration-short1: 50ms, + duration-short2: 100ms, + duration-short3: 150ms, + duration-short4: 200ms, + easing-emphasized: cubic-bezier(0.2, 0, 0, 1), + easing-emphasized-accelerate: cubic-bezier(0.3, 0, 0.8, 0.15), + easing-emphasized-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1), + easing-legacy: cubic-bezier(0.4, 0, 0.2, 1), + easing-legacy-accelerate: cubic-bezier(0.4, 0, 1, 1), + easing-legacy-decelerate: cubic-bezier(0, 0, 0.2, 1), + easing-linear: cubic-bezier(0, 0, 1, 1), + easing-standard: cubic-bezier(0.2, 0, 0, 1), + easing-standard-accelerate: cubic-bezier(0.3, 0, 1, 1), + easing-standard-decelerate: cubic-bezier(0, 0, 0, 1) ); } diff --git a/src/material/core/tokens/m3/_md-sys-shape.scss b/src/material/core/tokens/m3/_md-sys-shape.scss index a61298966965..73c3f30c131e 100644 --- a/src/material/core/tokens/m3/_md-sys-shape.scss +++ b/src/material/core/tokens/m3/_md-sys-shape.scss @@ -3,22 +3,19 @@ // Design system version: v0.161 // -@function values($exclude-hardcoded-values: false) { +@function md-sys-shape-values() { @return ( - 'corner-extra-large': if($exclude-hardcoded-values, null, 28px), - 'corner-extra-large-top': - if($exclude-hardcoded-values, null, (28px 28px 0 0)), - 'corner-extra-small': if($exclude-hardcoded-values, null, 4px), - 'corner-extra-small-top': - if($exclude-hardcoded-values, null, (4px 4px 0 0)), - 'corner-full': if($exclude-hardcoded-values, null, 9999px), - 'corner-large': if($exclude-hardcoded-values, null, 16px), - 'corner-large-end': if($exclude-hardcoded-values, null, (0 16px 16px 0)), - 'corner-large-start': - if($exclude-hardcoded-values, null, (16px 0 0 16px)), - 'corner-large-top': if($exclude-hardcoded-values, null, (16px 16px 0 0)), - 'corner-medium': if($exclude-hardcoded-values, null, 12px), - 'corner-none': if($exclude-hardcoded-values, null, 0), - 'corner-small': if($exclude-hardcoded-values, null, 8px) + corner-extra-large: 28px, + corner-extra-large-top: (28px 28px 0 0), + corner-extra-small: 4px, + corner-extra-small-top: (4px 4px 0 0), + corner-full: 9999px, + corner-large: 16px, + corner-large-end: (0 16px 16px 0), + corner-large-start: (16px 0 0 16px), + corner-large-top: (16px 16px 0 0), + corner-medium: 12px, + corner-none: 0, + corner-small: 8px ); } diff --git a/src/material/core/tokens/m3/_md-sys-state.scss b/src/material/core/tokens/m3/_md-sys-state.scss index 463224a95cf7..ddc9ebfb7aa2 100644 --- a/src/material/core/tokens/m3/_md-sys-state.scss +++ b/src/material/core/tokens/m3/_md-sys-state.scss @@ -3,11 +3,11 @@ // Design system version: v0.161 // -@function values($exclude-hardcoded-values: false) { +@function md-sys-state-values($exclude-hardcoded-values: false) { @return ( - 'dragged-state-layer-opacity': if($exclude-hardcoded-values, null, 0.16), - 'focus-state-layer-opacity': if($exclude-hardcoded-values, null, 0.12), - 'hover-state-layer-opacity': if($exclude-hardcoded-values, null, 0.08), - 'pressed-state-layer-opacity': if($exclude-hardcoded-values, null, 0.12) + dragged-state-layer-opacity: 0.16, + focus-state-layer-opacity: 0.12, + hover-state-layer-opacity: 0.08, + pressed-state-layer-opacity: 0.12 ); } diff --git a/src/material/core/tokens/m3/_md-sys-typescale.scss b/src/material/core/tokens/m3/_md-sys-typescale.scss index 3196faeb04c4..1d70b8505f82 100644 --- a/src/material/core/tokens/m3/_md-sys-typescale.scss +++ b/src/material/core/tokens/m3/_md-sys-typescale.scss @@ -5,321 +5,122 @@ @use 'sass:map'; -@use './md-ref-typeface'; - // Indicates whether alternative tokens should be used $_alternate-tokens: false; -$_default: ( - 'md-ref-typeface': md-ref-typeface.values(), -); +@function md-sys-typescale-values($typography: ()) { + $plain: map.get($typography, plain); + $brand: map.get($typography, brand); + $bold: map.get($typography, bold); + $medium: map.get($typography, medium); + $regular: map.get($typography, regular); -@function values($deps: $_default, $exclude-hardcoded-values: false) { $values: ( - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.body-large.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'body-large': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-regular') - if($exclude-hardcoded-values, null, 1rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 1.5rem - ) map.get($deps, 'md-ref-typeface', 'plain') - ), - 'body-large-font': map.get($deps, 'md-ref-typeface', 'plain'), - 'body-large-line-height': if($exclude-hardcoded-values, null, 1.5rem), - 'body-large-size': if($exclude-hardcoded-values, null, 1rem), - 'body-large-tracking': if($exclude-hardcoded-values, null, 0.031rem), - 'body-large-weight': map.get($deps, 'md-ref-typeface', 'weight-regular'), - - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.body-medium.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'body-medium': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-regular') - if($exclude-hardcoded-values, null, 0.875rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 1.25rem - ) map.get($deps, 'md-ref-typeface', 'plain') - ), - 'body-medium-font': map.get($deps, 'md-ref-typeface', 'plain'), - 'body-medium-line-height': if($exclude-hardcoded-values, null, 1.25rem), - 'body-medium-size': if($exclude-hardcoded-values, null, 0.875rem), - 'body-medium-tracking': if($exclude-hardcoded-values, null, 0.016rem), - 'body-medium-weight': map.get($deps, 'md-ref-typeface', 'weight-regular'), - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.body-small.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'body-small': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-regular') - if($exclude-hardcoded-values, null, 0.75rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 1rem - ) map.get($deps, 'md-ref-typeface', 'plain') - ), - 'body-small-font': map.get($deps, 'md-ref-typeface', 'plain'), - 'body-small-line-height': if($exclude-hardcoded-values, null, 1rem), - 'body-small-size': if($exclude-hardcoded-values, null, 0.75rem), - 'body-small-tracking': if($exclude-hardcoded-values, null, 0.025rem), - 'body-small-weight': map.get($deps, 'md-ref-typeface', 'weight-regular'), - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.display-large.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'display-large': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-regular') - if($exclude-hardcoded-values, null, 3.562rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 4rem - ) map.get($deps, 'md-ref-typeface', 'brand') - ), - 'display-large-font': map.get($deps, 'md-ref-typeface', 'brand'), - 'display-large-line-height': if($exclude-hardcoded-values, null, 4rem), - 'display-large-size': if($exclude-hardcoded-values, null, 3.562rem), - 'display-large-tracking': if($exclude-hardcoded-values, null, -0.016rem), - 'display-large-weight': map.get($deps, 'md-ref-typeface', 'weight-regular'), - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.display-medium.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'display-medium': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-regular') - if($exclude-hardcoded-values, null, 2.812rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 3.25rem - ) map.get($deps, 'md-ref-typeface', 'brand') - ), - 'display-medium-font': map.get($deps, 'md-ref-typeface', 'brand'), - 'display-medium-line-height': if($exclude-hardcoded-values, null, 3.25rem), - 'display-medium-size': if($exclude-hardcoded-values, null, 2.812rem), - 'display-medium-tracking': if($exclude-hardcoded-values, null, 0), - 'display-medium-weight': map.get($deps, 'md-ref-typeface', 'weight-regular'), - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.display-small.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'display-small': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-regular') - if($exclude-hardcoded-values, null, 2.25rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 2.75rem - ) map.get($deps, 'md-ref-typeface', 'brand') - ), - 'display-small-font': map.get($deps, 'md-ref-typeface', 'brand'), - 'display-small-line-height': if($exclude-hardcoded-values, null, 2.75rem), - 'display-small-size': if($exclude-hardcoded-values, null, 2.25rem), - 'display-small-tracking': if($exclude-hardcoded-values, null, 0), - 'display-small-weight': map.get($deps, 'md-ref-typeface', 'weight-regular'), - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.headline-large.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'headline-large': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-regular') - if($exclude-hardcoded-values, null, 2rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 2.5rem - ) map.get($deps, 'md-ref-typeface', 'brand') - ), - 'headline-large-font': map.get($deps, 'md-ref-typeface', 'brand'), - 'headline-large-line-height': if($exclude-hardcoded-values, null, 2.5rem), - 'headline-large-size': if($exclude-hardcoded-values, null, 2rem), - 'headline-large-tracking': if($exclude-hardcoded-values, null, 0), - 'headline-large-weight': map.get($deps, 'md-ref-typeface', 'weight-regular'), - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.headline-medium.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'headline-medium': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-regular') - if($exclude-hardcoded-values, null, 1.75rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 2.25rem - ) map.get($deps, 'md-ref-typeface', 'brand') - ), - 'headline-medium-font': map.get($deps, 'md-ref-typeface', 'brand'), - 'headline-medium-line-height': if($exclude-hardcoded-values, null, 2.25rem), - 'headline-medium-size': if($exclude-hardcoded-values, null, 1.75rem), - 'headline-medium-tracking': if($exclude-hardcoded-values, null, 0), - 'headline-medium-weight': - map.get($deps, 'md-ref-typeface', 'weight-regular'), - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.headline-small.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'headline-small': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-regular') - if($exclude-hardcoded-values, null, 1.5rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 2rem - ) map.get($deps, 'md-ref-typeface', 'brand') - ), - 'headline-small-font': map.get($deps, 'md-ref-typeface', 'brand'), - 'headline-small-line-height': if($exclude-hardcoded-values, null, 2rem), - 'headline-small-size': if($exclude-hardcoded-values, null, 1.5rem), - 'headline-small-tracking': if($exclude-hardcoded-values, null, 0), - 'headline-small-weight': map.get($deps, 'md-ref-typeface', 'weight-regular'), - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.label-large.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'label-large': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-medium') - if($exclude-hardcoded-values, null, 0.875rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 1.25rem - ) map.get($deps, 'md-ref-typeface', 'plain') - ), - 'label-large-font': map.get($deps, 'md-ref-typeface', 'plain'), - 'label-large-line-height': if($exclude-hardcoded-values, null, 1.25rem), - 'label-large-size': if($exclude-hardcoded-values, null, 0.875rem), - 'label-large-tracking': if($exclude-hardcoded-values, null, 0.006rem), - 'label-large-weight': map.get($deps, 'md-ref-typeface', 'weight-medium'), - 'label-large-weight-prominent': - map.get($deps, 'md-ref-typeface', 'weight-bold'), - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.label-medium.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'label-medium': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-medium') - if($exclude-hardcoded-values, null, 0.75rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 1rem - ) map.get($deps, 'md-ref-typeface', 'plain') - ), - 'label-medium-font': map.get($deps, 'md-ref-typeface', 'plain'), - 'label-medium-line-height': if($exclude-hardcoded-values, null, 1rem), - 'label-medium-size': if($exclude-hardcoded-values, null, 0.75rem), - 'label-medium-tracking': if($exclude-hardcoded-values, null, 0.031rem), - 'label-medium-weight': map.get($deps, 'md-ref-typeface', 'weight-medium'), - 'label-medium-weight-prominent': - map.get($deps, 'md-ref-typeface', 'weight-bold'), - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.label-small.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'label-small': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-medium') - if($exclude-hardcoded-values, null, 0.688rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 1rem - ) map.get($deps, 'md-ref-typeface', 'plain') - ), - 'label-small-font': map.get($deps, 'md-ref-typeface', 'plain'), - 'label-small-line-height': if($exclude-hardcoded-values, null, 1rem), - 'label-small-size': if($exclude-hardcoded-values, null, 0.688rem), - 'label-small-tracking': if($exclude-hardcoded-values, null, 0.031rem), - 'label-small-weight': map.get($deps, 'md-ref-typeface', 'weight-medium'), - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.title-large.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'title-large': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-regular') - if($exclude-hardcoded-values, null, 1.375rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 1.75rem - ) map.get($deps, 'md-ref-typeface', 'brand') - ), - 'title-large-font': map.get($deps, 'md-ref-typeface', 'brand'), - 'title-large-line-height': if($exclude-hardcoded-values, null, 1.75rem), - 'title-large-size': if($exclude-hardcoded-values, null, 1.375rem), - 'title-large-tracking': if($exclude-hardcoded-values, null, 0), - 'title-large-weight': map.get($deps, 'md-ref-typeface', 'weight-regular'), - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.title-medium.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'title-medium': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-medium') - if($exclude-hardcoded-values, null, 1rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 1.5rem - ) map.get($deps, 'md-ref-typeface', 'plain') - ), - 'title-medium-font': map.get($deps, 'md-ref-typeface', 'plain'), - 'title-medium-line-height': if($exclude-hardcoded-values, null, 1.5rem), - 'title-medium-size': if($exclude-hardcoded-values, null, 1rem), - 'title-medium-tracking': if($exclude-hardcoded-values, null, 0.009rem), - 'title-medium-weight': map.get($deps, 'md-ref-typeface', 'weight-medium'), - // Warning: risk of reduced fidelity from using this composite typography token. - // Tokens md.sys.typescale.title-small.tracking cannot be represented in the "font" - // property shorthand. Consider using the discrete properties instead. - 'title-small': - if( - $exclude-hardcoded-values, - null, - map.get($deps, 'md-ref-typeface', 'weight-medium') - if($exclude-hardcoded-values, null, 0.875rem) #{'/'} if( - $exclude-hardcoded-values, - null, - 1.25rem - ) map.get($deps, 'md-ref-typeface', 'plain') - ), - 'title-small-font': map.get($deps, 'md-ref-typeface', 'plain'), - 'title-small-line-height': if($exclude-hardcoded-values, null, 1.25rem), - 'title-small-size': if($exclude-hardcoded-values, null, 0.875rem), - 'title-small-tracking': if($exclude-hardcoded-values, null, 0.006rem), - 'title-small-weight': map.get($deps, 'md-ref-typeface', 'weight-medium') + body-large: $regular 1rem #{'/'} 1.5rem $plain, + body-large-font: $plain, + body-large-line-height: 1.5rem, + body-large-size: 1rem, + body-large-tracking: 0.031rem, + body-large-weight: $regular, + body-medium: $regular 0.875rem #{'/'} 1.25rem $plain, + body-medium-font: $plain, + body-medium-line-height: 1.25rem, + body-medium-size: 0.875rem, + body-medium-tracking: 0.016rem, + body-medium-weight: $regular, + body-small: $regular 0.75rem #{'/'} 1rem $plain, + body-small-font: $plain, + body-small-line-height: 1rem, + body-small-size: 0.75rem, + body-small-tracking: 0.025rem, + body-small-weight: $regular, + display-large: $regular 3.562rem #{'/'} 4rem $brand, + display-large-font: $brand, + display-large-line-height: 4rem, + display-large-size: 3.562rem, + display-large-tracking: -0.016rem, + display-large-weight: $regular, + display-medium: $regular 2.812rem #{'/'} 3.25rem $brand, + display-medium-font: $brand, + display-medium-line-height: 3.25rem, + display-medium-size: 2.812rem, + display-medium-tracking: 0, + display-medium-weight: $regular, + display-small: $regular 2.25rem #{'/'} 2.75rem $brand, + display-small-font: $brand, + display-small-line-height: 2.75rem, + display-small-size: 2.25rem, + display-small-tracking: 0, + display-small-weight: $regular, + headline-large: $regular 2rem #{'/'} 2.5rem $brand, + headline-large-font: $brand, + headline-large-line-height: 2.5rem, + headline-large-size: 2rem, + headline-large-tracking: 0, + headline-large-weight: $regular, + headline-medium: $regular 1.75rem #{'/'} 2.25rem $brand, + headline-medium-font: $brand, + headline-medium-line-height: 2.25rem, + headline-medium-size: 1.75rem, + headline-medium-tracking: 0, + headline-medium-weight: $regular, + headline-small: $regular 1.5rem #{'/'} 2rem $brand, + headline-small-font: $brand, + headline-small-line-height: 2rem, + headline-small-size: 1.5rem, + headline-small-tracking: 0, + headline-small-weight: $regular, + label-large: $medium 0.875rem #{'/'} 1.25rem $plain, + label-large-font: $plain, + label-large-line-height: 1.25rem, + label-large-size: 0.875rem, + label-large-tracking: 0.006rem, + label-large-weight: $medium, + label-large-weight-prominent: $bold, + label-medium: $medium 0.75rem #{'/'} 1rem $plain, + label-medium-font: $plain, + label-medium-line-height: 1rem, + label-medium-size: 0.75rem, + label-medium-tracking: 0.031rem, + label-medium-weight: $medium, + label-medium-weight-prominent: $bold, + label-small: $medium 0.688rem #{'/'} 1rem $plain, + label-small-font: $plain, + label-small-line-height: 1rem, + label-small-size: 0.688rem, + label-small-tracking: 0.031rem, + label-small-weight: $medium, + title-large: $regular 1.375rem #{'/'} 1.75rem $brand, + title-large-font: $brand, + title-large-line-height: 1.75rem, + title-large-size: 1.375rem, + title-large-tracking: 0, + title-large-weight: $regular, + title-medium: $medium 1rem #{'/'} 1.5rem $plain, + title-medium-font: $plain, + title-medium-line-height: 1.5rem, + title-medium-size: 1rem, + title-medium-tracking: 0.009rem, + title-medium-weight: $medium, + title-small: $medium 0.875rem #{'/'} 1.25rem $plain, + title-small-font: $plain, + title-small-line-height: 1.25rem, + title-small-size: 0.875rem, + title-small-tracking: 0.006rem, + title-small-weight: $medium ); @if ($_alternate-tokens) { $values: map.merge($values, ( - 'body-large-tracking': if($exclude-hardcoded-values, null, 0), - 'body-medium-tracking': if($exclude-hardcoded-values, null, 0), - 'body-small-tracking': if($exclude-hardcoded-values, null, 0.006rem), - 'display-large-tracking': if($exclude-hardcoded-values, null, 0), - 'label-large-tracking': if($exclude-hardcoded-values, null, 0), - 'label-medium-tracking': if($exclude-hardcoded-values, null, 0.006rem), - 'label-small-tracking': if($exclude-hardcoded-values, null, 0.006rem), - 'title-medium-tracking': if($exclude-hardcoded-values, null, 0), - 'title-small-tracking': if($exclude-hardcoded-values, null, 0), + body-large-tracking: 0, + body-medium-tracking: 0, + body-small-tracking: 0.006rem, + display-large-tracking: 0, + label-large-tracking: 0, + label-medium-tracking: 0.006rem, + label-small-tracking: 0.006rem, + title-medium-tracking: 0, + title-small-tracking: 0, )); } diff --git a/src/material/core/tokens/m3/_theme.scss b/src/material/core/tokens/m3/_theme.scss new file mode 100644 index 000000000000..66a8c8975f4d --- /dev/null +++ b/src/material/core/tokens/m3/_theme.scss @@ -0,0 +1,35 @@ +@use './md-sys-color'; +@use './md-sys-elevation'; +@use './md-sys-shape'; +@use './md-sys-state'; +@use './md-sys-typescale'; +@use 'sass:map'; + +// Return a new map where the values are the same as the provided map's +// keys, prefixed with "--mat-sys-". For example: +// (key1: '', key2: '') --> (key1: --mat-sys-key1, key2: --mat-sys-key2) +@function _create-system-app-vars-map($map) { + $new-map: (); + @each $key, $value in $map { + $new-map: map.set($new-map, $key, --mat-sys-#{$key}); + } + @return $new-map; +} + +$_sys-maps: ( + md-sys-color.md-sys-color-values-light(), + md-sys-typescale.md-sys-typescale-values(), + md-sys-elevation.md-sys-elevation-values(), + md-sys-state.md-sys-state-values(), + md-sys-shape.md-sys-shape-values(), + ( + neutral10: '', // Form field native select option text color + neutral-variant20: '', // Sidenav scrim (container background shadow when opened), + ), +); + +$_system: (density-scale: 0); +@each $sys-map in $_sys-maps { + $_system: map.merge($_system, _create-system-app-vars-map($sys-map)); +} +$sys-theme: (_mat-system: $_system); diff --git a/src/material/datepicker/README.md b/src/material/datepicker/README.md index 2ee9acdf6254..b972f67e5d28 100644 --- a/src/material/datepicker/README.md +++ b/src/material/datepicker/README.md @@ -1 +1 @@ -Please see the official documentation at https://material.angular.io/components/component/datepicker +Please see the official documentation at https://material.angular.dev/components/component/datepicker diff --git a/src/material/datepicker/_datepicker-theme.scss b/src/material/datepicker/_datepicker-theme.scss index fd817b98c68a..d961841d85d6 100644 --- a/src/material/datepicker/_datepicker-theme.scss +++ b/src/material/datepicker/_datepicker-theme.scss @@ -1,82 +1,45 @@ @use 'sass:color'; @use 'sass:map'; @use './m2-datepicker'; -@use '../core/theming/theming'; +@use './m3-datepicker'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; @use '../core/tokens/token-utils'; -@use '../core/style/sass-utils'; @use '../core/typography/typography'; @use '../button/icon-button-theme'; -// TODO(crisbeto): these variables aren't used anymore and should be removed. -$selected-today-box-shadow-width: 1px; -$selected-fade-amount: 0.6; -$range-fade-amount: 0.2; -$today-fade-amount: 0.2; -$calendar-body-font-size: 13px !default; -$calendar-weekday-table-font-size: 11px !default; - -@mixin _calendar-color($theme, $palette-name) { - $palette-color: inspection.get-theme-color($theme, $palette-name); - $range-color: m2-datepicker.private-get-range-background-color($palette-color); - $range-tokens: m2-datepicker.get-range-color-tokens($range-color); - $calendar-tokens: m2-datepicker.private-get-calendar-color-palette-color-tokens( - $theme, - $palette-name - ); - - @include token-utils.create-token-values-mixed( - m2-datepicker.$prefix, map.merge($calendar-tokens, $range-tokens)); -} - /// Outputs base theme styles (styles not dependent on the color, typography, or density settings) /// for the mat-datepicker. /// @param {Map} $theme The theme to generate base styles for. @mixin base($theme) { + $tokens: map.get(m2-datepicker.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-datepicker.$prefix, m2-datepicker.get-unthemable-tokens()); - } + $tokens: map.get(m3-datepicker.get-tokens($theme), base); } + + @include token-utils.values($tokens); } /// Outputs color theme styles for the mat-datepicker. /// @param {Map} $theme The theme to generate color styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the main selection: primary, secondary, tertiary, -/// or error (If not specified, default primary color will be used). -@mixin color($theme, $options...) { +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin color($theme, $color-variant: null) { + $tokens: map.get(m2-datepicker.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color), $options...); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-datepicker.$prefix, m2-datepicker.get-color-tokens($theme)); - } + $tokens: map.get(m3-datepicker.get-tokens($theme, $color-variant), color); + } - .mat-datepicker-content { - &.mat-accent { - @include _calendar-color($theme, accent); - } + @include token-utils.values($tokens); - &.mat-warn { - @include _calendar-color($theme, warn); - } - } - - .mat-datepicker-toggle-active { + @if inspection.get-theme-version($theme) != 1 { + .mat-datepicker-content, .mat-datepicker-toggle-active { &.mat-accent { - $accent-tokens: m2-datepicker.private-get-toggle-color-palette-color-tokens($theme, accent); - @include token-utils.create-token-values-mixed(m2-datepicker.$prefix, $accent-tokens); + $tokens: m2-datepicker.private-get-color-palette-color-tokens($theme, secondary); + @include token-utils.values($tokens); } &.mat-warn { - $warn-tokens: m2-datepicker.private-get-toggle-color-palette-color-tokens($theme, warn); - @include token-utils.create-token-values-mixed(m2-datepicker.$prefix, $warn-tokens); + $tokens: m2-datepicker.private-get-color-palette-color-tokens($theme, error); + @include token-utils.values($tokens); } } } @@ -85,43 +48,39 @@ $calendar-weekday-table-font-size: 11px !default; /// Outputs typography theme styles for the mat-datepicker. /// @param {Map} $theme The theme to generate typography styles for. @mixin typography($theme) { + $tokens: map.get(m2-datepicker.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-datepicker.$prefix, - m2-datepicker.get-typography-tokens($theme) - ); - } + $tokens: map.get(m3-datepicker.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } @mixin date-range-colors( $range-color, - $comparison-color: m2-datepicker.$private-default-comparison-color, - $overlap-color: m2-datepicker.$private-default-overlap-color, - $overlap-selected-color: - m2-datepicker.private-get-default-overlap-selected-color($overlap-color) + $comparison-color: rgba(#f9ab00, 0.2), + $overlap-color: #a8dab5, + $overlap-selected-color: color.adjust($overlap-color, $lightness: -30%) ) { - $tokens: m2-datepicker.get-range-color-tokens( - $range-color: $range-color, - $comparison-color: $comparison-color, - $overlap-color: $overlap-color, - $overlap-selected-color: $overlap-selected-color, - ); - - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed(m2-datepicker.$prefix, $tokens); - } + @include overrides(( + calendar-date-in-range-state-background-color: $range-color, + calendar-date-in-comparison-range-state-background-color: $comparison-color, + calendar-date-in-overlap-range-state-background-color: $overlap-color, + calendar-date-in-overlap-range-selected-state-background-color: $overlap-selected-color, + )); } /// Outputs density theme styles for the mat-datepicker. /// @param {Map} $theme The theme to generate density styles for. @mixin density($theme) { + $tokens: map.get(m2-datepicker.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - // There are no M3 density tokens for this component - } @else { + $tokens: map.get(m3-datepicker.get-tokens($theme), density); + } + + @include token-utils.values($tokens); + + @if inspection.get-theme-version($theme) != 1 { // TODO(crisbeto): move this into the structural styles // once the icon button density is switched to tokens. @@ -137,8 +96,8 @@ $calendar-weekday-table-font-size: 11px !default; @function _define-overrides() { @return ( ( - namespace: m2-datepicker.$prefix, - tokens: m2-datepicker.get-token-slots(), + namespace: datepicker, + tokens: token-utils.get-overrides(m3-datepicker.get-tokens(), datepicker) ), ); } @@ -146,38 +105,28 @@ $calendar-weekday-table-font-size: 11px !default; /// Outputs the CSS variable values for the given tokens. /// @param {Map} $tokens The token values to emit. @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } /// Outputs all (base, color, typography, and density) theme styles for the mat-datepicker. /// @param {Map} $theme The theme to generate styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the main selection: primary, secondary, tertiary, -/// or error (If not specified, default primary color will be used). -@mixin theme($theme, $options...) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-datepicker') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme), $options...); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin theme($theme, $color-variant: null) { + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme, $color-variant); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } } } - -@mixin _theme-from-tokens($tokens, $options...) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - $mat-datepicker-tokens: token-utils.get-tokens-for( - $tokens, m2-datepicker.$prefix, $options...); - @include token-utils.create-token-values(m2-datepicker.$prefix, $mat-datepicker-tokens); -} diff --git a/src/material/datepicker/_m2-datepicker.scss b/src/material/datepicker/_m2-datepicker.scss index 178bc3fdd26e..595b38d9482f 100644 --- a/src/material/datepicker/_m2-datepicker.scss +++ b/src/material/datepicker/_m2-datepicker.scss @@ -1,182 +1,81 @@ @use 'sass:color'; -@use 'sass:meta'; -@use 'sass:math'; -@use '../core/tokens/m2-utils'; -@use '../core/theming/inspection'; @use '../core/style/elevation'; -@use '../core/style/sass-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, datepicker); - -$_selected-fade-amount: 0.6; -$_today-fade-amount: 0.2; - -// Utility that produces a range background color from a specific color. -@function private-get-range-background-color($color) { - @return rgba($color, 0.2); -} - -// Utility that produces the overlap selected color from an overlap color. -@function private-get-default-overlap-selected-color($overlap-color) { - @return color.adjust($overlap-color, $lightness: -30%); -} - -// Default range comparison color. -$private-default-comparison-color: private-get-range-background-color(#f9ab00); +@use '../core/tokens/m3-utils'; +@use '../core/tokens/m2-utils'; +@use 'sass:map'; -// Default range overlap color. -$private-default-overlap-color: #a8dab5; +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { @return ( - calendar-container-shape: 4px, - calendar-container-touch-shape: 4px, - calendar-container-elevation-shadow: elevation.get-box-shadow(4), - calendar-container-touch-elevation-shadow: elevation.get-box-shadow(24), + base: ( + datepicker-calendar-container-shape: 4px, + datepicker-calendar-container-touch-shape: 4px, + datepicker-calendar-container-elevation-shadow: elevation.get-box-shadow(4), + datepicker-calendar-container-touch-elevation-shadow: elevation.get-box-shadow(24), + ), + color: private-get-color-palette-color-tokens($theme, primary), + typography: ( + // TODO(crisbeto): the typography tokens for other components set every typography dimension + // of an element (e.g. size, weight, line height, letter spacing). These tokens only set the + // values that were set in the previous theming API to reduce the amount of subtle screenshot + // differences. We should look into introducing the other tokens in a follow-up. + datepicker-calendar-text-font: map.get($system, body-large-font), + datepicker-calendar-text-size: 13px, + datepicker-calendar-body-label-text-size: map.get($system, label-small-size), + datepicker-calendar-body-label-text-weight: map.get($system, label-small-weight), + datepicker-calendar-period-button-text-size: map.get($system, label-small-size), + datepicker-calendar-period-button-text-weight: map.get($system, label-small-weight), + datepicker-calendar-header-text-size: 11px, + datepicker-calendar-header-text-weight: map.get($system, body-large-weight), + ), + density: (), ); } // Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - $inactive-icon-color: inspection.get-theme-color($theme, foreground, icon); - $text-color: inspection.get-theme-color($theme, foreground, text); - $secondary-text-color: inspection.get-theme-color($theme, foreground, secondary-text); - $disabled-text-color: inspection.get-theme-color($theme, foreground, disabled-text); - $divider-color: inspection.get-theme-color($theme, foreground, divider); - $hint-text-color: inspection.get-theme-color($theme, foreground, hint-text); - $preview-outline-color: $divider-color; - $today-disabled-outline-color: null; - $is-dark: inspection.get-theme-type($theme) == dark; - - $primary-color: inspection.get-theme-color($theme, primary); - $range-tokens: get-range-color-tokens(private-get-range-background-color($primary-color)); - $calendar-tokens: private-get-calendar-color-palette-color-tokens($theme, primary); - $toggle-tokens: private-get-toggle-color-palette-color-tokens($theme, primary); - - // The divider color is set under the assumption that it'll be used - // for a solid border, but because we're using a dashed border for the - // preview range, we need to bump its opacity to ensure that it's visible. - @if meta.type-of($preview-outline-color) == color { - $preview-outline-opacity: math.min(color.opacity($preview-outline-color) * 2, 1); - $preview-outline-color: rgba($preview-outline-color, $preview-outline-opacity); - } +@function private-get-color-palette-color-tokens($theme, $color-variant) { + $system: m2-utils.get-system($theme); + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); + $disabled: m3-utils.color-with-opacity(map.get($system, on-surface), 38%); - @if (meta.type-of($hint-text-color) == color) { - $today-disabled-outline-color: color.adjust($hint-text-color, $alpha: -$_today-fade-amount); - } - @else { - $today-disabled-outline-color: $disabled-text-color; - } - - @return sass-utils.merge-all($calendar-tokens, $toggle-tokens, $range-tokens, ( - toggle-icon-color: $inactive-icon-color, - calendar-body-label-text-color: $secondary-text-color, - calendar-period-button-text-color: inspection.get-theme-color($theme, foreground, text, 1), - calendar-period-button-icon-color: $inactive-icon-color, - calendar-navigation-button-icon-color: $inactive-icon-color, - calendar-header-divider-color: $divider-color, - calendar-header-text-color: $secondary-text-color, - - // Note: though it's not text, the border is a hint about the fact - // that this is today's date, so we use the hint color. - calendar-date-today-outline-color: $hint-text-color, - calendar-date-today-disabled-state-outline-color: $today-disabled-outline-color, - calendar-date-text-color: $text-color, - calendar-date-outline-color: transparent, - calendar-date-disabled-state-text-color: $disabled-text-color, - calendar-date-preview-state-outline-color: $preview-outline-color, - - range-input-separator-color: $text-color, - range-input-disabled-state-separator-color: $disabled-text-color, - range-input-disabled-state-text-color: $disabled-text-color, - - calendar-container-background-color: inspection.get-theme-color($theme, background, card), - calendar-container-text-color: $text-color, - )); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { @return ( - // TODO(crisbeto): the typography tokens for other components set every typography dimension of - // an element (e.g. size, weight, line height, letter spacing). These tokens only set the values - // that were set in the previous theming API to reduce the amount of subtle screenshot - // differences. We should look into introducing the other tokens in a follow-up. - calendar-text-font: inspection.get-theme-typography($theme, body-1, font-family), - calendar-text-size: 13px, - - calendar-body-label-text-size: inspection.get-theme-typography($theme, button, font-size), - calendar-body-label-text-weight: inspection.get-theme-typography($theme, button, font-weight), - - calendar-period-button-text-size: inspection.get-theme-typography($theme, button, font-size), - calendar-period-button-text-weight: inspection.get-theme-typography( - $theme, button, font-weight), - - calendar-header-text-size: 11px, - calendar-header-text-weight: inspection.get-theme-typography($theme, body-1, font-weight), - ); -} - -// Gets the tokens map that can be used to override the range colors. -@function get-range-color-tokens( - $range-color, - $comparison-color: $private-default-comparison-color, - $overlap-color: $private-default-overlap-color, - $overlap-selected-color: private-get-default-overlap-selected-color($overlap-color)) { - - @return ( - calendar-date-in-range-state-background-color: $range-color, - calendar-date-in-comparison-range-state-background-color: $comparison-color, - calendar-date-in-overlap-range-state-background-color: $overlap-color, - calendar-date-in-overlap-range-selected-state-background-color: $overlap-selected-color, - ); -} - -@function private-get-calendar-color-palette-color-tokens($theme, $palette-name) { - $palette-color: inspection.get-theme-color($theme, $palette-name); - $default-contrast: inspection.get-theme-color($theme, $palette-name, default-contrast); - $active-background-color: sass-utils.safe-color-change($palette-color, $alpha: 0.3); - $active-disabled-color: null; - - @if (meta.type-of($palette-color) == color) { - $active-disabled-color: color.adjust($palette-color, $alpha: -$_selected-fade-amount); - } - @else { - $active-disabled-color: inspection.get-theme-color($theme, foreground, disabled-button); - } - - @return ( - calendar-date-selected-state-text-color: $default-contrast, - calendar-date-selected-state-background-color: $palette-color, - calendar-date-selected-disabled-state-background-color: $active-disabled-color, - calendar-date-today-selected-state-outline-color: $default-contrast, - calendar-date-focus-state-background-color: $active-background-color, - calendar-date-hover-state-background-color: $active-background-color, - ); -} - -@function private-get-toggle-color-palette-color-tokens($theme, $palette-name) { - @return ( - toggle-active-state-icon-color: inspection.get-theme-color($theme, $palette-name, text), - ); -} - - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + datepicker-calendar-date-in-range-state-background-color: + m3-utils.color-with-opacity(map.get($system, primary), 20%), + datepicker-calendar-date-in-comparison-range-state-background-color: + m3-utils.color-with-opacity(map.get($system, secondary), 20%), + datepicker-calendar-date-in-overlap-range-state-background-color: #a8dab5, + datepicker-calendar-date-in-overlap-range-selected-state-background-color: + color.adjust(#a8dab5, $lightness: -30%), + datepicker-calendar-date-selected-state-text-color: map.get($system, on-primary), + datepicker-calendar-date-selected-state-background-color: map.get($system, primary), + datepicker-calendar-date-selected-disabled-state-background-color: + m3-utils.color-with-opacity(map.get($system, primary), 38%), + datepicker-calendar-date-today-selected-state-outline-color: map.get($system, on-primary), + datepicker-calendar-date-focus-state-background-color: m3-utils.color-with-opacity( + map.get($system, primary), map.get($system, focus-state-layer-opacity)), + datepicker-calendar-date-hover-state-background-color: m3-utils.color-with-opacity( + map.get($system, primary), map.get($system, hover-state-layer-opacity)), + + datepicker-toggle-active-state-icon-color: map.get($system, primary), + datepicker-toggle-icon-color: map.get($system, on-surface-variant), + datepicker-calendar-body-label-text-color: map.get($system, on-surface-variant), + datepicker-calendar-period-button-text-color: map.get($system, on-surface), + datepicker-calendar-period-button-icon-color: map.get($system, on-surface-variant), + datepicker-calendar-navigation-button-icon-color: map.get($system, on-surface-variant), + datepicker-calendar-header-divider-color: map.get($system, outline), + datepicker-calendar-header-text-color: map.get($system, on-surface-variant), + + datepicker-calendar-date-today-outline-color: map.get($system, on-surface-variant), + datepicker-calendar-date-today-disabled-state-outline-color: $disabled, + datepicker-calendar-date-text-color: map.get($system, on-surface), + datepicker-calendar-date-outline-color: transparent, + datepicker-calendar-date-disabled-state-text-color: $disabled, + datepicker-calendar-date-preview-state-outline-color: map.get($system, on-surface-variant), + datepicker-range-input-separator-color: map.get($system, on-surface), + datepicker-range-input-disabled-state-separator-color: $disabled, + datepicker-range-input-disabled-state-text-color: $disabled, + datepicker-calendar-container-background-color: map.get($system, surface), + datepicker-calendar-container-text-color: map.get($system, on-surface), ); } diff --git a/src/material/datepicker/_m3-datepicker.scss b/src/material/datepicker/_m3-datepicker.scss index 5b75e92704c1..faee749ee433 100644 --- a/src/material/datepicker/_m3-datepicker.scss +++ b/src/material/datepicker/_m3-datepicker.scss @@ -1,125 +1,77 @@ @use 'sass:map'; @use '../core/style/elevation'; -@use '../core/style/sass-utils'; @use '../core/tokens/m3-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, datepicker); +@use '../core/tokens/m3'; /// Generates custom tokens for the mat-datepicker. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-datepicker -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: ( - calendar-date-in-range-state-background-color: - map.get($systems, md-sys-color, primary-container), - calendar-date-in-comparison-range-state-background-color: - map.get($systems, md-sys-color, tertiary-container), - calendar-date-in-overlap-range-state-background-color: - map.get($systems, md-sys-color, secondary-container), - calendar-date-in-overlap-range-selected-state-background-color: - map.get($systems, md-sys-color, secondary), - calendar-date-selected-state-text-color: map.get($systems, md-sys-color, on-primary), - calendar-date-selected-state-background-color: map.get($systems, md-sys-color, primary), - calendar-date-selected-disabled-state-background-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: 0.38 - ), - calendar-date-today-selected-state-outline-color: map.get($systems, md-sys-color, primary), - calendar-date-focus-state-background-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: map.get($systems, md-sys-state, focus-state-layer-opacity) - ), - calendar-date-hover-state-background-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: map.get($systems, md-sys-state, hover-state-layer-opacity) - ), - toggle-active-state-icon-color: map.get($systems, md-sys-color, on-surface-variant), - toggle-icon-color: map.get($systems, md-sys-color, on-surface-variant), - calendar-body-label-text-color: map.get($systems, md-sys-color, on-surface), - calendar-period-button-text-color: map.get($systems, md-sys-color, on-surface-variant), - calendar-period-button-icon-color: map.get($systems, md-sys-color, on-surface-variant), - calendar-navigation-button-icon-color: map.get($systems, md-sys-color, on-surface-variant), - calendar-header-divider-color: m3-utils.hardcode(transparent, $exclude-hardcoded), - calendar-header-text-color: map.get($systems, md-sys-color, on-surface-variant), - calendar-date-today-outline-color: map.get($systems, md-sys-color, primary), - calendar-date-today-disabled-state-outline-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: 0.38 - ), - calendar-date-text-color: map.get($systems, md-sys-color, on-surface), - calendar-date-outline-color: m3-utils.hardcode(transparent, $exclude-hardcoded), - calendar-date-disabled-state-text-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: 0.38 +/// @param {String} $color-variant The color variant to use for the component +@function get-tokens($theme: m3.$sys-theme, $color-variant: null) { + $system: m3-utils.get-system($theme); + @if $color-variant { + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); + } + + @return ( + base: ( + datepicker-calendar-container-elevation-shadow:elevation.get-box-shadow(0), + datepicker-calendar-container-shape: map.get($system, corner-large), + datepicker-calendar-container-touch-elevation-shadow:elevation.get-box-shadow(0), + datepicker-calendar-container-touch-shape: map.get($system, corner-extra-large), ), - calendar-date-preview-state-outline-color: map.get($systems, md-sys-color, primary), - range-input-separator-color: map.get($systems, md-sys-color, on-surface), - range-input-disabled-state-separator-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: 0.38 + color: ( + datepicker-calendar-body-label-text-color: map.get($system, on-surface), + datepicker-calendar-container-background-color: map.get($system, surface-container-high), + datepicker-calendar-container-text-color: map.get($system, on-surface), + datepicker-calendar-date-disabled-state-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + datepicker-calendar-date-focus-state-background-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, focus-state-layer-opacity)), + datepicker-calendar-date-hover-state-background-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, hover-state-layer-opacity)), + datepicker-calendar-date-in-comparison-range-state-background-color: + map.get($system, tertiary-container), + datepicker-calendar-date-in-overlap-range-selected-state-background-color: + map.get($system, secondary), + datepicker-calendar-date-in-overlap-range-state-background-color: + map.get($system, secondary-container), + datepicker-calendar-date-in-range-state-background-color:map.get($system, primary-container), + datepicker-calendar-date-outline-color: transparent, + datepicker-calendar-date-preview-state-outline-color: map.get($system, primary), + datepicker-calendar-date-selected-disabled-state-background-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + datepicker-calendar-date-selected-state-background-color: map.get($system, primary), + datepicker-calendar-date-selected-state-text-color: map.get($system, on-primary), + datepicker-calendar-date-text-color: map.get($system, on-surface), + datepicker-calendar-date-today-disabled-state-outline-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + datepicker-calendar-date-today-outline-color: map.get($system, primary), + datepicker-calendar-date-today-selected-state-outline-color: map.get($system, primary), + datepicker-calendar-header-divider-color: transparent, + datepicker-calendar-header-text-color: map.get($system, on-surface-variant), + datepicker-calendar-navigation-button-icon-color: map.get($system, on-surface-variant), + datepicker-calendar-period-button-icon-color: map.get($system, on-surface-variant), + datepicker-calendar-period-button-text-color: map.get($system, on-surface-variant), + datepicker-range-input-disabled-state-separator-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + datepicker-range-input-disabled-state-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + datepicker-range-input-separator-color: map.get($system, on-surface), + datepicker-toggle-active-state-icon-color: map.get($system, on-surface-variant), + datepicker-toggle-icon-color: map.get($system, on-surface-variant), ), - range-input-disabled-state-text-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: 0.38 + typography: ( + datepicker-calendar-body-label-text-size: map.get($system, title-small-size), + datepicker-calendar-body-label-text-weight: map.get($system, title-small-weight), + datepicker-calendar-header-text-size: map.get($system, title-small-size), + datepicker-calendar-header-text-weight: map.get($system, title-small-weight), + datepicker-calendar-period-button-text-size: map.get($system, title-small-size), + datepicker-calendar-period-button-text-weight: map.get($system, title-small-weight), + datepicker-calendar-text-font: map.get($system, body-medium-font), + datepicker-calendar-text-size: map.get($system, body-medium-size), ), - calendar-container-background-color: map.get($systems, md-sys-color, surface-container-high), - calendar-container-text-color: map.get($systems, md-sys-color, on-surface), - calendar-container-elevation-shadow: - m3-utils.hardcode(elevation.get-box-shadow(0), $exclude-hardcoded), - calendar-container-touch-elevation-shadow: - m3-utils.hardcode(elevation.get-box-shadow(0), $exclude-hardcoded), - calendar-container-shape: map.get($systems, md-sys-shape, corner-large), - calendar-container-touch-shape: map.get($systems, md-sys-shape, corner-extra-large), - calendar-text-font: map.get($systems, md-sys-typescale, body-medium-font), - calendar-text-size: map.get($systems, md-sys-typescale, body-medium-size), - calendar-body-label-text-size: map.get($systems, md-sys-typescale, title-small-size), - calendar-body-label-text-weight: map.get($systems, md-sys-typescale, title-small-weight), - calendar-period-button-text-size: map.get($systems, md-sys-typescale, title-small-size), - calendar-period-button-text-weight: map.get($systems, md-sys-typescale, title-small-weight), - calendar-header-text-size: map.get($systems, md-sys-typescale, title-small-size), - calendar-header-text-weight: map.get($systems, md-sys-typescale, title-small-weight), + density: (), ); - $variant-tokens: ( - // Color variants: - primary: (), // Default, no overrides needed. - secondary: ( - calendar-date-in-range-state-background-color: - map.get($systems, md-sys-color, secondary-container), - calendar-date-selected-state-text-color: map.get($systems, md-sys-color, on-secondary), - calendar-date-selected-state-background-color: map.get($systems, md-sys-color, secondary), - calendar-date-today-selected-state-outline-color: map.get($systems, md-sys-color, secondary), - calendar-date-today-outline-color: map.get($systems, md-sys-color, secondary), - calendar-date-preview-state-outline-color: map.get($systems, md-sys-color, secondary), - calendar-date-in-overlap-range-state-background-color: - map.get($systems, md-sys-color, primary-container), - calendar-date-in-overlap-range-selected-state-background-color: - map.get($systems, md-sys-color, primary), - ), - tertiary: ( - calendar-date-in-range-state-background-color: - map.get($systems, md-sys-color, tertiary-container), - calendar-date-selected-state-text-color: map.get($systems, md-sys-color, on-tertiary), - calendar-date-selected-state-background-color: map.get($systems, md-sys-color, tertiary), - calendar-date-today-selected-state-outline-color: map.get($systems, md-sys-color, tertiary), - calendar-date-today-outline-color: map.get($systems, md-sys-color, tertiary), - calendar-date-preview-state-outline-color: map.get($systems, md-sys-color, tertiary), - calendar-date-in-comparison-range-state-background-color: - map.get($systems, md-sys-color, primary-container), - ), - error: ( - calendar-date-in-range-state-background-color: - map.get($systems, md-sys-color, error-container), - calendar-date-selected-state-text-color: map.get($systems, md-sys-color, on-error), - calendar-date-selected-state-background-color: map.get($systems, md-sys-color, error), - calendar-date-today-selected-state-outline-color: map.get($systems, md-sys-color, error), - calendar-date-today-outline-color: map.get($systems, md-sys-color, error), - calendar-date-preview-state-outline-color: map.get($systems, md-sys-color, error), - ) + $tokens: ( ); - - @return m3-utils.namespace($prefix, ($tokens, $variant-tokens), $token-slots); } diff --git a/src/material/datepicker/calendar-body.scss b/src/material/datepicker/calendar-body.scss index d27c6edcd58e..41ad2fea2e9b 100644 --- a/src/material/datepicker/calendar-body.scss +++ b/src/material/datepicker/calendar-body.scss @@ -3,7 +3,7 @@ @use '../core/style/button-common'; @use '../core/tokens/token-utils'; -@use './m2-datepicker'; +@use './m3-datepicker'; $calendar-body-label-padding-start: 5% !default; // We don't want the label to jump around when we switch between month and year views, so we use @@ -23,14 +23,13 @@ $calendar-body-cell-content-size: 100% - $calendar-body-cell-content-margin * 2 $calendar-range-end-body-cell-size: $calendar-body-cell-content-size + $calendar-body-cell-content-margin !default; -$token-prefix: m2-datepicker.$prefix; -$token-slots: m2-datepicker.get-token-slots(); +$fallbacks: m3-datepicker.get-tokens(); // Styles for a highlighted calendar cell (e.g. hovered or focused). @mixin _highlighted-cell($token-name) { & > .mat-calendar-body-cell-content { @include _unselected-cell { - background-color: token-utils.slot($token-name); + background-color: token-utils.slot($token-name, $fallbacks); } } } @@ -48,9 +47,7 @@ $token-slots: m2-datepicker.get-token-slots(); .mat-calendar-body-today { @include _unselected-cell { - @include token-utils.use-tokens($token-prefix, $token-slots) { - border-color: token-utils.slot(calendar-date-today-outline-color); - } + border-color: token-utils.slot(datepicker-calendar-date-today-outline-color, $fallbacks); } } @@ -61,11 +58,9 @@ $token-slots: m2-datepicker.get-token-slots(); padding-left: $calendar-body-label-side-padding; padding-right: $calendar-body-label-side-padding; - @include token-utils.use-tokens($token-prefix, $token-slots) { - font-size: token-utils.slot(calendar-body-label-text-size); - font-weight: token-utils.slot(calendar-body-label-text-weight); - color: token-utils.slot(calendar-body-label-text-color); - } + font-size: token-utils.slot(datepicker-calendar-body-label-text-size, $fallbacks); + font-weight: token-utils.slot(datepicker-calendar-body-label-text-weight, $fallbacks); + color: token-utils.slot(datepicker-calendar-body-label-text-color, $fallbacks); } // Label that is not rendered and removed from the accessibility tree. @@ -88,14 +83,11 @@ $token-slots: m2-datepicker.get-token-slots(); background: none; text-align: center; outline: none; - font-family: inherit; margin: 0; // Needs to be repeated here in order to override the user agent styles. - @include token-utils.use-tokens($token-prefix, $token-slots) { - font-family: token-utils.slot(calendar-text-font); - font-size: token-utils.slot(calendar-text-size); - } + font-family: token-utils.slot(datepicker-calendar-text-font, $fallbacks); + font-size: token-utils.slot(datepicker-calendar-text-size, $fallbacks); @include button-common.reset(); } @@ -188,9 +180,7 @@ $token-slots: m2-datepicker.get-token-slots(); } .mat-calendar-body-in-preview { - @include token-utils.use-tokens($token-prefix, $token-slots) { - color: token-utils.slot(calendar-date-preview-state-outline-color); - } + color: token-utils.slot(datepicker-calendar-date-preview-state-outline-color, $fallbacks); .mat-calendar-body-cell-preview { border-top: $calendar-body-preview-cell-border; @@ -219,17 +209,16 @@ $token-slots: m2-datepicker.get-token-slots(); .mat-calendar-body-disabled { cursor: default; - @include token-utils.use-tokens($token-prefix, $token-slots) { - & > .mat-calendar-body-cell-content { - @include _unselected-cell { - color: token-utils.slot(calendar-date-disabled-state-text-color); - } + & > .mat-calendar-body-cell-content { + @include _unselected-cell { + color: token-utils.slot(datepicker-calendar-date-disabled-state-text-color, $fallbacks); } + } - & > .mat-calendar-body-today { - @include _unselected-cell { - border-color: token-utils.slot(calendar-date-today-disabled-state-outline-color); - } + & > .mat-calendar-body-today { + @include _unselected-cell { + border-color: token-utils.slot(datepicker-calendar-date-today-disabled-state-outline-color, + $fallbacks); } } @@ -263,10 +252,8 @@ $token-slots: m2-datepicker.get-token-slots(); // Choosing a value clearly larger than the height ensures we get the correct capsule shape. border-radius: $calendar-body-cell-radius; - @include token-utils.use-tokens($token-prefix, $token-slots) { - color: token-utils.slot(calendar-date-text-color); - border-color: token-utils.slot(calendar-date-outline-color); - } + color: token-utils.slot(datepicker-calendar-date-text-color, $fallbacks); + border-color: token-utils.slot(datepicker-calendar-date-outline-color, $fallbacks); // Increase specificity because focus indicator styles are part of the `mat-core` mixin and can // potentially overwrite the absolute position of the container. @@ -280,76 +267,76 @@ $token-slots: m2-datepicker.get-token-slots(); } .mat-calendar-body-active { - @include token-utils.use-tokens($token-prefix, $token-slots) { - .cdk-keyboard-focused &, .cdk-program-focused & { - @include _highlighted-cell(calendar-date-focus-state-background-color); - } + .cdk-keyboard-focused &, .cdk-program-focused & { + @include _highlighted-cell(datepicker-calendar-date-focus-state-background-color); } } @media (hover: hover) { .mat-calendar-body-cell:not(.mat-calendar-body-disabled):hover { - @include token-utils.use-tokens($token-prefix, $token-slots) { - @include _highlighted-cell(calendar-date-hover-state-background-color); - } + @include _highlighted-cell(datepicker-calendar-date-hover-state-background-color); } } .mat-calendar-body-selected { - @include token-utils.use-tokens($token-prefix, $token-slots) { - background-color: token-utils.slot(calendar-date-selected-state-background-color); - color: token-utils.slot(calendar-date-selected-state-text-color); + background-color: token-utils.slot(datepicker-calendar-date-selected-state-background-color, + $fallbacks); + color: token-utils.slot(datepicker-calendar-date-selected-state-text-color, $fallbacks); - .mat-calendar-body-disabled > & { - background-color: token-utils.slot(calendar-date-selected-disabled-state-background-color); - } + .mat-calendar-body-disabled > & { + background-color: token-utils.slot( + datepicker-calendar-date-selected-disabled-state-background-color, $fallbacks); + } - &.mat-calendar-body-today { - $shadow: token-utils.slot(calendar-date-today-selected-state-outline-color); - box-shadow: inset 0 0 0 1px #{$shadow}; - } + &.mat-calendar-body-today { + $shadow: token-utils.slot(datepicker-calendar-date-today-selected-state-outline-color, + $fallbacks); + box-shadow: inset 0 0 0 1px #{$shadow}; } } -@include token-utils.use-tokens($token-prefix, $token-slots) { - $range-color: - token-utils.slot(calendar-date-in-range-state-background-color); - $comparison-color: - token-utils.slot(calendar-date-in-comparison-range-state-background-color); +$range-color: + token-utils.slot(datepicker-calendar-date-in-range-state-background-color, $fallbacks); +$comparison-color: + token-utils.slot(datepicker-calendar-date-in-comparison-range-state-background-color, $fallbacks); - .mat-calendar-body-in-range::before { - background: token-utils.slot(calendar-date-in-range-state-background-color); - } +.mat-calendar-body-in-range::before { + background: token-utils.slot(datepicker-calendar-date-in-range-state-background-color, + $fallbacks); +} - .mat-calendar-body-comparison-identical, - .mat-calendar-body-in-comparison-range::before { - background: token-utils.slot(calendar-date-in-comparison-range-state-background-color); - } +.mat-calendar-body-comparison-identical, +.mat-calendar-body-in-comparison-range::before { + background: token-utils.slot(datepicker-calendar-date-in-comparison-range-state-background-color, + $fallbacks); +} - .mat-calendar-body-comparison-identical, - .mat-calendar-body-in-comparison-range::before { - background: token-utils.slot(calendar-date-in-comparison-range-state-background-color); - } +.mat-calendar-body-comparison-identical, +.mat-calendar-body-in-comparison-range::before { + background: token-utils.slot(datepicker-calendar-date-in-comparison-range-state-background-color, + $fallbacks); +} - .mat-calendar-body-comparison-bridge-start::before, - [dir='rtl'] .mat-calendar-body-comparison-bridge-end::before { - background: linear-gradient(to right, $range-color 50%, $comparison-color 50%); - } +.mat-calendar-body-comparison-bridge-start::before, +[dir='rtl'] .mat-calendar-body-comparison-bridge-end::before { + background: linear-gradient(to right, $range-color 50%, $comparison-color 50%); +} - .mat-calendar-body-comparison-bridge-end::before, - [dir='rtl'] .mat-calendar-body-comparison-bridge-start::before { - background: linear-gradient(to left, $range-color 50%, $comparison-color 50%); - } +.mat-calendar-body-comparison-bridge-end::before, +[dir='rtl'] .mat-calendar-body-comparison-bridge-start::before { + background: linear-gradient(to left, $range-color 50%, $comparison-color 50%); +} - .mat-calendar-body-in-range > .mat-calendar-body-comparison-identical, - .mat-calendar-body-in-comparison-range.mat-calendar-body-in-range::after { - background: token-utils.slot(calendar-date-in-overlap-range-state-background-color); - } +.mat-calendar-body-in-range > .mat-calendar-body-comparison-identical, +.mat-calendar-body-in-comparison-range.mat-calendar-body-in-range::after { + background: token-utils.slot(datepicker-calendar-date-in-overlap-range-state-background-color, + $fallbacks); +} - .mat-calendar-body-comparison-identical.mat-calendar-body-selected, - .mat-calendar-body-in-comparison-range > .mat-calendar-body-selected { - background: token-utils.slot(calendar-date-in-overlap-range-selected-state-background-color); - } +.mat-calendar-body-comparison-identical.mat-calendar-body-selected, +.mat-calendar-body-in-comparison-range > .mat-calendar-body-selected { + background: token-utils.slot( + datepicker-calendar-date-in-overlap-range-selected-state-background-color, $fallbacks); } @include cdk.high-contrast { diff --git a/src/material/datepicker/calendar-header.spec.ts b/src/material/datepicker/calendar-header.spec.ts index c053e0ac453f..8b67d0d079e4 100644 --- a/src/material/datepicker/calendar-header.spec.ts +++ b/src/material/datepicker/calendar-header.spec.ts @@ -1,9 +1,9 @@ -import {Directionality} from '@angular/cdk/bidi'; +import {provideFakeDirectionality} from '@angular/cdk/testing/private'; import {Component} from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; -import {MatNativeDateModule, DateAdapter} from '../core'; -import {DEC, FEB, JAN} from '../testing'; import {By} from '@angular/platform-browser'; +import {DateAdapter, MatNativeDateModule} from '../core'; +import {DEC, FEB, JAN} from '../testing'; import {MatCalendar} from './calendar'; import {MatDatepickerIntl} from './datepicker-intl'; import {MatDatepickerModule} from './datepicker-module'; @@ -19,7 +19,7 @@ describe('MatCalendarHeader', () => { StandardCalendar, CalendarWithMinMaxDate, ], - providers: [MatDatepickerIntl, {provide: Directionality, useFactory: () => ({value: 'ltr'})}], + providers: [MatDatepickerIntl, provideFakeDirectionality('ltr')], }); })); diff --git a/src/material/datepicker/calendar.scss b/src/material/datepicker/calendar.scss index c64748efebbc..5ec4a394531a 100644 --- a/src/material/datepicker/calendar.scss +++ b/src/material/datepicker/calendar.scss @@ -2,7 +2,7 @@ @use '../core/focus-indicators/private'; @use '../core/tokens/token-utils'; -@use './m2-datepicker'; +@use './m3-datepicker'; $calendar-padding: 8px !default; $calendar-header-divider-width: 1px !default; @@ -15,8 +15,7 @@ $calendar-controls-side-margin: calc(33% / 7 - 16px); $calendar-arrow-size: 5px !default; $calendar-arrow-disabled-opacity: 0.5 !default; -$token-prefix: m2-datepicker.$prefix; -$token-slots: m2-datepicker.get-token-slots(); +$fallbacks: m3-datepicker.get-tokens(); .mat-calendar { display: block; @@ -24,10 +23,8 @@ $token-slots: m2-datepicker.get-token-slots(); // Prevents layout issues if the line height bleeds in from the body (see #29756). line-height: normal; - @include token-utils.use-tokens($token-prefix, $token-slots) { - font-family: token-utils.slot(calendar-text-font); - font-size: token-utils.slot(calendar-text-size); - } + font-family: token-utils.slot(datepicker-calendar-text-font, $fallbacks); + font-size: token-utils.slot(datepicker-calendar-text-size, $fallbacks); } .mat-calendar-header { @@ -53,11 +50,10 @@ $token-slots: m2-datepicker.get-token-slots(); min-width: 0; margin: 0 8px; - @include token-utils.use-tokens($token-prefix, $token-slots) { - font-size: token-utils.slot(calendar-period-button-text-size); - font-weight: token-utils.slot(calendar-period-button-text-weight); - --mat-button-text-label-text-color: #{token-utils.slot(calendar-period-button-text-color)}; - } + font-size: token-utils.slot(datepicker-calendar-period-button-text-size, $fallbacks); + font-weight: token-utils.slot(datepicker-calendar-period-button-text-weight, $fallbacks); + --mat-button-text-label-text-color: #{ + token-utils.slot(datepicker-calendar-period-button-text-color, $fallbacks)}; } .mat-calendar-arrow { @@ -67,9 +63,7 @@ $token-slots: m2-datepicker.get-token-slots(); margin: 0 0 0 $calendar-arrow-size; vertical-align: middle; - @include token-utils.use-tokens($token-prefix, $token-slots) { - fill: token-utils.slot(calendar-period-button-icon-color); - } + fill: token-utils.slot(datepicker-calendar-period-button-icon-color, $fallbacks); &.mat-calendar-invert { transform: rotate(180deg); @@ -87,11 +81,9 @@ $token-slots: m2-datepicker.get-token-slots(); .mat-calendar-previous-button, .mat-calendar-next-button { - @include token-utils.use-tokens($token-prefix, $token-slots) { - // Needs need a bit more specificity to avoid being overwritten by the .mat-icon-button. - .mat-datepicker-content &:not(.mat-mdc-button-disabled) { - color: token-utils.slot(calendar-navigation-button-icon-color); - } + // Needs need a bit more specificity to avoid being overwritten by the .mat-icon-button. + .mat-datepicker-content &:not(.mat-mdc-button-disabled) { + color: token-utils.slot(datepicker-calendar-navigation-button-icon-color, $fallbacks); } [dir='rtl'] & { @@ -108,12 +100,9 @@ $token-slots: m2-datepicker.get-token-slots(); .mat-calendar-table-header th { text-align: center; padding: 0 0 $calendar-padding 0; - - @include token-utils.use-tokens($token-prefix, $token-slots) { - color: token-utils.slot(calendar-header-text-color); - font-size: token-utils.slot(calendar-header-text-size); - font-weight: token-utils.slot(calendar-header-text-weight); - } + color: token-utils.slot(datepicker-calendar-header-text-color, $fallbacks); + font-size: token-utils.slot(datepicker-calendar-header-text-size, $fallbacks); + font-weight: token-utils.slot(datepicker-calendar-header-text-weight, $fallbacks); } .mat-calendar-table-header-divider { @@ -129,10 +118,7 @@ $token-slots: m2-datepicker.get-token-slots(); left: -$calendar-padding; right: -$calendar-padding; height: $calendar-header-divider-width; - - @include token-utils.use-tokens($token-prefix, $token-slots) { - background: token-utils.slot(calendar-header-divider-color); - } + background: token-utils.slot(datepicker-calendar-header-divider-color, $fallbacks); } } diff --git a/src/material/datepicker/calendar.spec.ts b/src/material/datepicker/calendar.spec.ts index 7ec08bdf71b1..76149d06b2c2 100644 --- a/src/material/datepicker/calendar.spec.ts +++ b/src/material/datepicker/calendar.spec.ts @@ -1,18 +1,17 @@ -import {Directionality} from '@angular/cdk/bidi'; import {ENTER, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes'; import { dispatchFakeEvent, dispatchKeyboardEvent, dispatchMouseEvent, + provideFakeDirectionality, } from '@angular/cdk/testing/private'; import {Component} from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; -import {DateAdapter, MatNativeDateModule} from '../core'; import {By} from '@angular/platform-browser'; +import {DateAdapter, provideNativeDateAdapter} from '../core'; import {DEC, FEB, JAN, JUL, NOV} from '../testing'; import {MatCalendar} from './calendar'; import {MatDatepickerIntl} from './datepicker-intl'; -import {MatDatepickerModule} from './datepicker-module'; describe('MatCalendar', () => { let adapter: DateAdapter; @@ -20,15 +19,7 @@ describe('MatCalendar', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [MatNativeDateModule, MatDatepickerModule], - providers: [MatDatepickerIntl, {provide: Directionality, useFactory: () => ({value: 'ltr'})}], - declarations: [ - // Test components. - StandardCalendar, - CalendarWithMinMax, - CalendarWithDateFilter, - CalendarWithSelectableMinDate, - ], + providers: [MatDatepickerIntl, provideFakeDirectionality('ltr'), provideNativeDateAdapter()], }); adapter = TestBed.inject(DateAdapter); @@ -650,7 +641,7 @@ describe('MatCalendar', () => { (yearSelected)="selectedYear=$event" (monthSelected)="selectedMonth=$event"> `, - standalone: false, + imports: [MatCalendar], }) class StandardCalendar { selected: Date; @@ -663,7 +654,7 @@ class StandardCalendar { template: ` `, - standalone: false, + imports: [MatCalendar], }) class CalendarWithMinMax { startAt: Date; @@ -676,7 +667,7 @@ class CalendarWithMinMax { `, - standalone: false, + imports: [MatCalendar], }) class CalendarWithDateFilter { selected: Date; @@ -696,7 +687,7 @@ class CalendarWithDateFilter { [minDate]="selected"> `, - standalone: false, + imports: [MatCalendar], }) class CalendarWithSelectableMinDate { startAt = new Date(2018, JUL, 0); diff --git a/src/material/datepicker/calendar.ts b/src/material/datepicker/calendar.ts index 48eaf22b7de7..f1d446f02ab7 100644 --- a/src/material/datepicker/calendar.ts +++ b/src/material/datepicker/calendar.ts @@ -13,6 +13,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + ElementRef, EventEmitter, Input, OnChanges, @@ -41,6 +42,7 @@ import {MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER, DateRange} from './date-select import {MatIconButton, MatButton} from '../button'; import {_IdGenerator, CdkMonitorFocus} from '@angular/cdk/a11y'; import {_CdkPrivateStyleLoader, _VisuallyHiddenLoader} from '@angular/cdk/private'; +import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform'; /** * Possible views for the calendar. @@ -62,68 +64,47 @@ export class MatCalendarHeader { calendar = inject>(MatCalendar); private _dateAdapter = inject>(DateAdapter, {optional: true})!; private _dateFormats = inject(MAT_DATE_FORMATS, {optional: true})!; + private _periodButtonText: string; + private _periodButtonDescription: string; + private _periodButtonLabel: string; + private _prevButtonLabel: string; + private _nextButtonLabel: string; constructor(...args: unknown[]); constructor() { inject(_CdkPrivateStyleLoader).load(_VisuallyHiddenLoader); const changeDetectorRef = inject(ChangeDetectorRef); - this.calendar.stateChanges.subscribe(() => changeDetectorRef.markForCheck()); + this._updateLabels(); + this.calendar.stateChanges.subscribe(() => { + this._updateLabels(); + changeDetectorRef.markForCheck(); + }); } /** The display text for the current calendar view. */ get periodButtonText(): string { - if (this.calendar.currentView == 'month') { - return this._dateAdapter - .format(this.calendar.activeDate, this._dateFormats.display.monthYearLabel) - .toLocaleUpperCase(); - } - if (this.calendar.currentView == 'year') { - return this._dateAdapter.getYearName(this.calendar.activeDate); - } - - return this._intl.formatYearRange(...this._formatMinAndMaxYearLabels()); + return this._periodButtonText; } /** The aria description for the current calendar view. */ get periodButtonDescription(): string { - if (this.calendar.currentView == 'month') { - return this._dateAdapter - .format(this.calendar.activeDate, this._dateFormats.display.monthYearLabel) - .toLocaleUpperCase(); - } - if (this.calendar.currentView == 'year') { - return this._dateAdapter.getYearName(this.calendar.activeDate); - } - - // Format a label for the window of years displayed in the multi-year calendar view. Use - // `formatYearRangeLabel` because it is TTS friendly. - return this._intl.formatYearRangeLabel(...this._formatMinAndMaxYearLabels()); + return this._periodButtonDescription; } /** The `aria-label` for changing the calendar view. */ get periodButtonLabel(): string { - return this.calendar.currentView == 'month' - ? this._intl.switchToMultiYearViewLabel - : this._intl.switchToMonthViewLabel; + return this._periodButtonLabel; } /** The label for the previous button. */ get prevButtonLabel(): string { - return { - 'month': this._intl.prevMonthLabel, - 'year': this._intl.prevYearLabel, - 'multi-year': this._intl.prevMultiYearLabel, - }[this.calendar.currentView]; + return this._prevButtonLabel; } /** The label for the next button. */ get nextButtonLabel(): string { - return { - 'month': this._intl.nextMonthLabel, - 'year': this._intl.nextYearLabel, - 'multi-year': this._intl.nextMultiYearLabel, - }[this.calendar.currentView]; + return this._nextButtonLabel; } /** Handles user clicks on the period label. */ @@ -170,6 +151,41 @@ export class MatCalendarHeader { ); } + /** Updates the labels for the various sections of the header. */ + private _updateLabels() { + const calendar = this.calendar; + const intl = this._intl; + const adapter = this._dateAdapter; + + if (calendar.currentView === 'month') { + this._periodButtonText = adapter + .format(calendar.activeDate, this._dateFormats.display.monthYearLabel) + .toLocaleUpperCase(); + this._periodButtonDescription = adapter + .format(calendar.activeDate, this._dateFormats.display.monthYearLabel) + .toLocaleUpperCase(); + this._periodButtonLabel = intl.switchToMultiYearViewLabel; + this._prevButtonLabel = intl.prevMonthLabel; + this._nextButtonLabel = intl.nextMonthLabel; + } else if (calendar.currentView === 'year') { + this._periodButtonText = adapter.getYearName(calendar.activeDate); + this._periodButtonDescription = adapter.getYearName(calendar.activeDate); + this._periodButtonLabel = intl.switchToMonthViewLabel; + this._prevButtonLabel = intl.prevYearLabel; + this._nextButtonLabel = intl.nextYearLabel; + } else { + this._periodButtonText = intl.formatYearRange(...this._formatMinAndMaxYearLabels()); + // Format a label for the window of years displayed in the multi-year calendar view. Use + // `formatYearRangeLabel` because it is TTS friendly. + this._periodButtonDescription = intl.formatYearRangeLabel( + ...this._formatMinAndMaxYearLabels(), + ); + this._periodButtonLabel = intl.switchToMonthViewLabel; + this._prevButtonLabel = intl.prevMultiYearLabel; + this._nextButtonLabel = intl.nextMultiYearLabel; + } + } + /** Whether the two dates represent the same view in the current view mode (month or year). */ private _isSameView(date1: D, date2: D): boolean { if (this.calendar.currentView == 'month') { @@ -241,6 +257,7 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes private _dateAdapter = inject>(DateAdapter, {optional: true})!; private _dateFormats = inject(MAT_DATE_FORMATS, {optional: true}); private _changeDetectorRef = inject(ChangeDetectorRef); + private _elementRef = inject>(ElementRef); /** An input indicating the type of the header component, if set. */ @Input() headerComponent: ComponentType; @@ -384,6 +401,7 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes this._moveFocusOnNextTick = true; this._changeDetectorRef.markForCheck(); if (viewChangedResult) { + this.stateChanges.next(); this.viewChanged.emit(viewChangedResult); } } @@ -457,9 +475,12 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes const view = this._getCurrentViewComponent(); if (view) { - // Schedule focus to be moved to the active date since re-rendering - // can blur the active cell. See #29265. - this._moveFocusOnNextTick = true; + // Schedule focus to be moved to the active date since re-rendering can blur the active + // cell (see #29265), however don't do so if focus is outside of the calendar, because it + // can steal away the user's attention (see #30635). + if (this._elementRef.nativeElement.contains(_getFocusedElementPierceShadowDom())) { + this._moveFocusOnNextTick = true; + } // We need to `detectChanges` manually here, because the `minDate`, `maxDate` etc. are // passed down to the view via data bindings which won't be up-to-date when we call `_init`. diff --git a/src/material/datepicker/date-range-input-parts.ts b/src/material/datepicker/date-range-input-parts.ts index 6bc39f0d527d..d65457b5b093 100644 --- a/src/material/datepicker/date-range-input-parts.ts +++ b/src/material/datepicker/date-range-input-parts.ts @@ -17,6 +17,7 @@ import { Input, OnInit, inject, + signal, } from '@angular/core'; import { AbstractControl, @@ -47,6 +48,7 @@ abstract class MatDateRangeInputPartBase override _elementRef = inject>(ElementRef); _defaultErrorStateMatcher = inject(ErrorStateMatcher); private _injector = inject(Injector); + private _rawValue = signal(''); _parentForm = inject(NgForm, {optional: true}); _parentFormGroup = inject(FormGroupDirective, {optional: true}); @@ -120,11 +122,13 @@ abstract class MatDateRangeInputPartBase // that whatever logic is in here has to be super lean or we risk destroying the performance. this.updateErrorState(); } + + this._rawValue.set(this._elementRef.nativeElement.value); } /** Gets whether the input is empty. */ isEmpty(): boolean { - return this._elementRef.nativeElement.value.length === 0; + return this._rawValue().length === 0; } /** Gets the placeholder of the input. */ @@ -139,9 +143,8 @@ abstract class MatDateRangeInputPartBase /** Gets the value that should be used when mirroring the input's size. */ getMirrorValue(): string { - const element = this._elementRef.nativeElement; - const value = element.value; - return value.length > 0 ? value : element.placeholder; + const value = this._rawValue(); + return value.length > 0 ? value : this._getPlaceholder(); } /** Refreshes the error state of the input. */ @@ -191,6 +194,7 @@ abstract class MatDateRangeInputPartBase : this._rangeInput._startInput ) as MatDateRangeInputPartBase | undefined; opposite?._validatorOnChange(); + this._rawValue.set(this._elementRef.nativeElement.value); } protected override _formatValue(value: D | null) { diff --git a/src/material/datepicker/date-range-input.scss b/src/material/datepicker/date-range-input.scss index cb58af0cfa70..0764e8719cf8 100644 --- a/src/material/datepicker/date-range-input.scss +++ b/src/material/datepicker/date-range-input.scss @@ -4,11 +4,13 @@ @use '../core/style/variables'; @use '../core/style/vendor-prefixes'; @use '../core/tokens/token-utils'; -@use './m2-datepicker'; +@use './m3-datepicker'; $date-range-input-separator-spacing: 4px; $date-range-input-part-max-width: calc(50% - #{$date-range-input-separator-spacing}); +$fallbacks: m3-datepicker.get-tokens(); + @mixin _placeholder-transition($property) { transition: #{$property} variables.$swift-ease-out-duration math.div(variables.$swift-ease-out-duration, 3) @@ -32,12 +34,10 @@ $date-range-input-part-max-width: calc(50% - #{$date-range-input-separator-spaci @include _placeholder-transition(opacity); margin: 0 $date-range-input-separator-spacing; - @include token-utils.use-tokens(m2-datepicker.$prefix, m2-datepicker.get-token-slots()) { - color: token-utils.slot(range-input-separator-color); + color: token-utils.slot(datepicker-range-input-separator-color, $fallbacks); - .mat-form-field-disabled & { - color: token-utils.slot(range-input-disabled-state-separator-color); - } + .mat-form-field-disabled & { + color: token-utils.slot(datepicker-range-input-disabled-state-separator-color, $fallbacks); } ._mat-animation-noopable & { @@ -100,11 +100,8 @@ $date-range-input-part-max-width: calc(50% - #{$date-range-input-separator-spaci @include _placeholder-transition(color); } - @include token-utils.use-tokens(m2-datepicker.$prefix, m2-datepicker.get-token-slots()) { - - &[disabled] { - color: token-utils.slot(range-input-disabled-state-text-color); - } + &[disabled] { + color: token-utils.slot(datepicker-range-input-disabled-state-text-color, $fallbacks); } .mat-form-field-hide-placeholder &, diff --git a/src/material/datepicker/date-range-input.spec.ts b/src/material/datepicker/date-range-input.spec.ts index e1a1f7d58362..e2e9abe945df 100644 --- a/src/material/datepicker/date-range-input.spec.ts +++ b/src/material/datepicker/date-range-input.spec.ts @@ -1,9 +1,12 @@ import {FocusMonitor} from '@angular/cdk/a11y'; -import {Direction, Directionality} from '@angular/cdk/bidi'; import {BACKSPACE, LEFT_ARROW, RIGHT_ARROW} from '@angular/cdk/keycodes'; import {OverlayContainer} from '@angular/cdk/overlay'; -import {dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing/private'; -import {Component, Directive, ElementRef, Provider, signal, Type, ViewChild} from '@angular/core'; +import { + dispatchFakeEvent, + dispatchKeyboardEvent, + provideFakeDirectionality, +} from '@angular/cdk/testing/private'; +import {Component, Directive, ElementRef, Provider, Type, ViewChild} from '@angular/core'; import {ComponentFixture, fakeAsync, flush, TestBed, tick} from '@angular/core/testing'; import { FormControl, @@ -829,15 +832,7 @@ describe('MatDateRangeInput', () => { }); it('moves focus between fields with arrow keys when cursor is at edge (RTL)', () => { - class RTL extends Directionality { - override readonly valueSignal = signal('rtl'); - } - const fixture = createComponent(StandardRangePicker, [ - { - provide: Directionality, - useFactory: () => new RTL(null), - }, - ]); + const fixture = createComponent(StandardRangePicker, [provideFakeDirectionality('rtl')]); fixture.detectChanges(); const {start, end} = fixture.componentInstance; diff --git a/src/material/datepicker/datepicker-actions.spec.ts b/src/material/datepicker/datepicker-actions.spec.ts index a513b7440efa..88e5fa4be4c2 100644 --- a/src/material/datepicker/datepicker-actions.spec.ts +++ b/src/material/datepicker/datepicker-actions.spec.ts @@ -1,25 +1,19 @@ import {Component, ElementRef, Type, ViewChild} from '@angular/core'; import {ComponentFixture, TestBed, fakeAsync, flush, tick} from '@angular/core/testing'; -import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; -import {MATERIAL_ANIMATIONS, MatNativeDateModule} from '../core'; -import {MatFormFieldModule} from '../form-field'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {MATERIAL_ANIMATIONS, provideNativeDateAdapter} from '../core'; import {MatInputModule} from '../input'; import {MatDatepicker} from './datepicker'; import {MatDatepickerModule} from './datepicker-module'; +import {MatButtonModule} from '../button'; describe('MatDatepickerActions', () => { function createComponent(component: Type): ComponentFixture { TestBed.configureTestingModule({ - declarations: [component], - imports: [ - FormsModule, - MatDatepickerModule, - MatFormFieldModule, - MatInputModule, - ReactiveFormsModule, - MatNativeDateModule, + providers: [ + {provide: MATERIAL_ANIMATIONS, useValue: {animationsDisabled: true}}, + provideNativeDateAdapter(), ], - providers: [{provide: MATERIAL_ANIMATIONS, useValue: {animationsDisabled: true}}], }); return TestBed.createComponent(component); @@ -303,7 +297,7 @@ describe('MatDatepickerActions', () => { `, - standalone: false, + imports: [MatDatepickerModule, MatInputModule, MatButtonModule, ReactiveFormsModule], }) class DatepickerWithActions { @ViewChild(MatDatepicker) datepicker: MatDatepicker; diff --git a/src/material/datepicker/datepicker-base.ts b/src/material/datepicker/datepicker-base.ts index 18408d50158d..35075750eaa9 100644 --- a/src/material/datepicker/datepicker-base.ts +++ b/src/material/datepicker/datepicker-base.ts @@ -165,10 +165,10 @@ export class MatDatepickerContent> /** * Theme color of the internal calendar. This API is supported in M2 themes - * only, it has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/datepicker/styling. + * only, it has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/datepicker/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() color: ThemePalette; @@ -352,7 +352,7 @@ export interface MatDatepickerControl { stateChanges: Observable; } -/** A datepicker that can be attached to a {@link MatDatepickerControl}. */ +/** A datepicker that can be attached to a `MatDatepickerControl`. */ export interface MatDatepickerPanel< C extends MatDatepickerControl, S, @@ -362,10 +362,10 @@ export interface MatDatepickerPanel< closedStream: EventEmitter; /** * Color palette to use on the datepicker's calendar. This API is supported in M2 themes only, it - * has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/datepicker/styling. + * has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/datepicker/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ color: ThemePalette; /** The input element the datepicker is associated with. */ @@ -426,10 +426,10 @@ export abstract class MatDatepickerBase< /** * Theme color of the datepicker's calendar. This API is supported in M2 themes only, it - * has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/datepicker/styling. + * has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/datepicker/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() get color(): ThemePalette { diff --git a/src/material/datepicker/datepicker-content.scss b/src/material/datepicker/datepicker-content.scss index ce68ddc2f2b5..9af13a593af4 100644 --- a/src/material/datepicker/datepicker-content.scss +++ b/src/material/datepicker/datepicker-content.scss @@ -1,6 +1,8 @@ -@use './m2-datepicker'; +@use './m3-datepicker'; @use '../core/tokens/token-utils'; +$fallbacks: m3-datepicker.get-tokens(); + $calendar-padding: 8px; $non-touch-calendar-cell-size: 40px; $non-touch-calendar-width: @@ -60,15 +62,10 @@ $touch-max-height: 788px; .mat-datepicker-content { display: block; - border-radius: 4px; - - @include token-utils.use-tokens( - m2-datepicker.$prefix, m2-datepicker.get-token-slots()) { - background-color: token-utils.slot(calendar-container-background-color); - color: token-utils.slot(calendar-container-text-color); - box-shadow: token-utils.slot(calendar-container-elevation-shadow); - border-radius: token-utils.slot(calendar-container-shape); - } + background-color: token-utils.slot(datepicker-calendar-container-background-color, $fallbacks); + color: token-utils.slot(datepicker-calendar-container-text-color, $fallbacks); + box-shadow: token-utils.slot(datepicker-calendar-container-elevation-shadow, $fallbacks); + border-radius: token-utils.slot(datepicker-calendar-container-shape, $fallbacks); &.mat-datepicker-content-animations-enabled { animation: _mat-datepicker-content-dropdown-enter 120ms cubic-bezier(0, 0, 0.2, 1); @@ -113,12 +110,8 @@ $touch-max-height: 788px; .mat-datepicker-content-touch { display: block; max-height: 80vh; - - @include token-utils.use-tokens( - m2-datepicker.$prefix, m2-datepicker.get-token-slots()) { - box-shadow: token-utils.slot(calendar-container-touch-elevation-shadow); - border-radius: token-utils.slot(calendar-container-touch-shape); - } + box-shadow: token-utils.slot(datepicker-calendar-container-touch-elevation-shadow, $fallbacks); + border-radius: token-utils.slot(datepicker-calendar-container-touch-shape, $fallbacks); // Allows for the screen reader close button to be seen in touch UI mode. position: relative; diff --git a/src/material/datepicker/datepicker-toggle.scss b/src/material/datepicker/datepicker-toggle.scss index 68de3278d185..11fc04332a9d 100644 --- a/src/material/datepicker/datepicker-toggle.scss +++ b/src/material/datepicker/datepicker-toggle.scss @@ -1,21 +1,18 @@ @use '@angular/cdk'; -@use './m2-datepicker'; +@use './m3-datepicker'; @use '../core/tokens/token-utils'; +$fallbacks: m3-datepicker.get-tokens(); + // We support the case where the form field is disabled, but the datepicker is not. // MDC sets `pointer-events: none` on disabled form fields which prevents clicks on the toggle. .mat-datepicker-toggle { pointer-events: auto; - - @include token-utils.use-tokens(m2-datepicker.$prefix, m2-datepicker.get-token-slots()) { - color: token-utils.slot(toggle-icon-color); - } + color: token-utils.slot(datepicker-toggle-icon-color, $fallbacks); } .mat-datepicker-toggle-active { - @include token-utils.use-tokens(m2-datepicker.$prefix, m2-datepicker.get-token-slots()) { - color: token-utils.slot(toggle-active-state-icon-color); - } + color: token-utils.slot(datepicker-toggle-active-state-icon-color, $fallbacks); } @include cdk.high-contrast { diff --git a/src/material/datepicker/datepicker.spec.ts b/src/material/datepicker/datepicker.spec.ts index 5991d9cc51cd..e2fefcad062f 100644 --- a/src/material/datepicker/datepicker.spec.ts +++ b/src/material/datepicker/datepicker.spec.ts @@ -1,4 +1,4 @@ -import {Directionality} from '@angular/cdk/bidi'; +import {Direction} from '@angular/cdk/bidi'; import { DOWN_ARROW, ENTER, @@ -18,6 +18,7 @@ import { dispatchFakeEvent, dispatchKeyboardEvent, dispatchMouseEvent, + provideFakeDirectionality, typeInElement, } from '@angular/cdk/testing/private'; import { @@ -28,6 +29,7 @@ import { Type, ViewChild, ViewEncapsulation, + signal, } from '@angular/core'; import {ComponentFixture, TestBed, fakeAsync, flush, tick} from '@angular/core/testing'; import { @@ -38,21 +40,21 @@ import { ReactiveFormsModule, Validator, } from '@angular/forms'; -import {MAT_DATE_LOCALE, MATERIAL_ANIMATIONS, MatNativeDateModule, NativeDateModule} from '../core'; -import {MatFormField, MatFormFieldModule} from '../form-field'; -import {MatInputModule} from '../input'; import {By} from '@angular/platform-browser'; import {Subject} from 'rxjs'; +import {MATERIAL_ANIMATIONS, MAT_DATE_LOCALE, MatNativeDateModule, NativeDateModule} from '../core'; +import {MatFormField} from '../form-field'; +import {MatInputModule} from '../input'; import {DEC, JAN, JUL, JUN, SEP} from '../testing'; import {MatDatepicker} from './datepicker'; import {DatepickerDropdownPositionX, DatepickerDropdownPositionY} from './datepicker-base'; import {MatDatepickerInput} from './datepicker-input'; -import {MatDatepickerToggle} from './datepicker-toggle'; +import {MatDatepickerToggle, MatDatepickerToggleIcon} from './datepicker-toggle'; import { MAT_DATEPICKER_SCROLL_STRATEGY, + MatCalendarHeader, MatDateSelectionModel, MatDatepickerIntl, - MatDatepickerModule, } from './index'; describe('MatDatepicker', () => { @@ -63,22 +65,13 @@ describe('MatDatepicker', () => { component: Type, imports: Type[] = [], providers: Provider[] = [], - declarations: Type[] = [], ): ComponentFixture { TestBed.configureTestingModule({ - imports: [ - FormsModule, - MatDatepickerModule, - MatFormFieldModule, - MatInputModule, - ReactiveFormsModule, - ...imports, - ], + imports, providers: [ ...providers, {provide: MATERIAL_ANIMATIONS, useValue: {animationsDisabled: true}}, ], - declarations: [component, ...declarations], }); return TestBed.createComponent(component); @@ -2085,12 +2078,7 @@ describe('MatDatepicker', () => { const fixture = createComponent( StandardDatepicker, [MatNativeDateModule], - [ - { - provide: Directionality, - useValue: {value: 'rtl'}, - }, - ], + [provideFakeDirectionality('rtl')], ); fixture.detectChanges(); @@ -2104,16 +2092,11 @@ describe('MatDatepicker', () => { })); it('should update the popup direction if the directionality value changes', fakeAsync(() => { - const dirProvider = {value: 'ltr'}; + const dir = signal('ltr'); const fixture = createComponent( StandardDatepicker, [MatNativeDateModule], - [ - { - provide: Directionality, - useFactory: () => dirProvider, - }, - ], + [provideFakeDirectionality(dir)], ); fixture.detectChanges(); @@ -2129,7 +2112,7 @@ describe('MatDatepicker', () => { fixture.detectChanges(); flush(); - dirProvider.value = 'rtl'; + dir.set('rtl'); fixture.componentInstance.datepicker.open(); fixture.detectChanges(); tick(); @@ -2143,12 +2126,7 @@ describe('MatDatepicker', () => { const fixture = createComponent( StandardDatepicker, [MatNativeDateModule], - [ - { - provide: Directionality, - useValue: {value: 'rtl'}, - }, - ], + [provideFakeDirectionality('rtl')], ); fixture.componentInstance.touch = true; @@ -2373,12 +2351,7 @@ describe('MatDatepicker', () => { let testComponent: DatepickerWithCustomHeader; beforeEach(fakeAsync(() => { - fixture = createComponent( - DatepickerWithCustomHeader, - [MatNativeDateModule], - [], - [CustomHeaderForDatepicker], - ); + fixture = createComponent(DatepickerWithCustomHeader, [MatNativeDateModule]); fixture.detectChanges(); testComponent = fixture.componentInstance; })); @@ -2409,12 +2382,7 @@ describe('MatDatepicker', () => { }); it('should not trigger validators if new date object for same date is set for `min`', () => { - const fixture = createComponent( - DatepickerInputWithCustomValidator, - [MatNativeDateModule], - undefined, - [CustomValidator], - ); + const fixture = createComponent(DatepickerInputWithCustomValidator, [MatNativeDateModule]); fixture.detectChanges(); const minDate = new Date(2019, 0, 1); const validator = fixture.componentInstance.validator; @@ -2433,12 +2401,7 @@ describe('MatDatepicker', () => { }); it('should not trigger validators if new date object for same date is set for `max`', () => { - const fixture = createComponent( - DatepickerInputWithCustomValidator, - [MatNativeDateModule], - undefined, - [CustomValidator], - ); + const fixture = createComponent(DatepickerInputWithCustomValidator, [MatNativeDateModule]); fixture.detectChanges(); const maxDate = new Date(2120, 0, 1); const validator = fixture.componentInstance.validator; @@ -2573,7 +2536,7 @@ const inputFixedWidthStyles = ` [yPosition]="yPosition"> `, styles: inputFixedWidthStyles, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker], }) class StandardDatepicker { opened = false; @@ -2592,13 +2555,13 @@ class StandardDatepicker { template: ` `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker], }) class MultiInputDatepicker {} @Component({ template: ``, - standalone: false, + imports: [MatDatepicker], }) class NoInputDatepicker { @ViewChild('d') datepicker: MatDatepicker; @@ -2609,7 +2572,7 @@ class NoInputDatepicker { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker], }) class DatepickerWithStartAt { date = new Date(2020, JAN, 1); @@ -2622,7 +2585,7 @@ class DatepickerWithStartAt { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker], }) class DatepickerWithStartViewYear { date = new Date(2020, JAN, 1); @@ -2637,7 +2600,7 @@ class DatepickerWithStartViewYear { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker], }) class DatepickerWithStartViewMultiYear { date = new Date(2020, JAN, 1); @@ -2651,7 +2614,7 @@ class DatepickerWithStartViewMultiYear { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker, FormsModule], }) class DatepickerWithNgModel { selected: Date | null = null; @@ -2665,7 +2628,7 @@ class DatepickerWithNgModel { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker, MatDatepickerToggle, ReactiveFormsModule], }) class DatepickerWithFormControl { formControl = new FormControl(null); @@ -2680,7 +2643,7 @@ class DatepickerWithFormControl { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker, MatDatepickerToggle], }) class DatepickerWithToggle { @ViewChild('d') datepicker: MatDatepicker; @@ -2697,7 +2660,7 @@ class DatepickerWithToggle { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker, MatDatepickerToggle], }) class DatepickerWithToggleInShadowDom extends DatepickerWithToggle {} @@ -2709,19 +2672,19 @@ class DatepickerWithToggleInShadowDom extends DatepickerWithToggle {} `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker, MatDatepickerToggle], }) class DatepickerWithCustomIcon {} @Component({ template: ` - - Pick a date - - - + + Pick a date + + + `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker, MatInputModule], }) class FormFieldDatepicker { @ViewChild('d') datepicker: MatDatepicker; @@ -2735,7 +2698,7 @@ class FormFieldDatepicker { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker, MatDatepickerToggle, FormsModule], }) class DatepickerWithMinAndMaxValidation { @ViewChild('d') datepicker: MatDatepicker; @@ -2751,7 +2714,7 @@ class DatepickerWithMinAndMaxValidation { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker, MatDatepickerToggle, FormsModule], }) class DatepickerWithFilterAndValidation { @ViewChild('d') datepicker: MatDatepicker; @@ -2766,17 +2729,14 @@ class DatepickerWithFilterAndValidation { (dateChange)="onDateChange()" (dateInput)="onDateInput()"> `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker], }) class DatepickerWithChangeAndInputEvents { @ViewChild('d') datepicker: MatDatepicker; onChange() {} - onInput() {} - onDateChange() {} - onDateInput() {} } @@ -2785,7 +2745,7 @@ class DatepickerWithChangeAndInputEvents { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker, FormsModule], }) class DatepickerWithi18n { date: Date | null = new Date(2010, JAN, 1); @@ -2798,7 +2758,7 @@ class DatepickerWithi18n { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker, FormsModule], }) class DatepickerWithISOStrings { value = new Date(2017, JUN, 1).toISOString(); @@ -2814,7 +2774,7 @@ class DatepickerWithISOStrings { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker, FormsModule], }) class DatepickerWithEvents { selected: Date | null = null; @@ -2828,7 +2788,7 @@ class DatepickerWithEvents { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker], }) class DatepickerOpeningOnFocus { @ViewChild(MatDatepicker) datepicker: MatDatepicker; @@ -2839,7 +2799,7 @@ class DatepickerOpeningOnFocus { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker], }) class DatepickerWithCustomHeader { @ViewChild('ch') datepicker: MatDatepicker; @@ -2851,7 +2811,7 @@ class DatepickerWithCustomHeader {
    Custom element
    `, - standalone: false, + imports: [MatCalendarHeader], }) class CustomHeaderForDatepicker {} @@ -2860,7 +2820,7 @@ class CustomHeaderForDatepicker {} `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker], }) class DelayedDatepicker { @ViewChild('d') datepicker: MatDatepicker; @@ -2877,25 +2837,21 @@ class DelayedDatepicker { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker, MatDatepickerToggle, MatDatepickerToggleIcon], }) class DatepickerWithTabindexOnToggle { disabled = false; } @Component({ - template: ` - - `, - standalone: false, + template: ``, + imports: [MatDatepickerToggle], }) class DatepickerToggleWithNoDatepicker {} @Component({ - template: ` - - `, - standalone: false, + template: ``, + imports: [MatDatepickerInput], }) class DatepickerInputWithNoDatepicker {} @@ -2908,7 +2864,6 @@ class DatepickerInputWithNoDatepicker {} multi: true, }, ], - standalone: false, }) class CustomValidator implements Validator { validate = jasmine.createSpy('validate spy').and.returnValue(null); @@ -2919,7 +2874,7 @@ class CustomValidator implements Validator { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker, CustomValidator, FormsModule], }) class DatepickerInputWithCustomValidator { @ViewChild(CustomValidator) validator: CustomValidator; @@ -2933,10 +2888,10 @@ class DatepickerInputWithCustomValidator { `, - standalone: false, + imports: [MatDatepickerInput, MatDatepicker], }) class PanelClassDatepicker { date = new Date(0); - panelClass: any; + panelClass: string | string[] | undefined; @ViewChild('d') datepicker: MatDatepicker; } diff --git a/src/material/datepicker/month-view.spec.ts b/src/material/datepicker/month-view.spec.ts index 3d4ccf41f4b2..acd0bbe82c79 100644 --- a/src/material/datepicker/month-view.spec.ts +++ b/src/material/datepicker/month-view.spec.ts @@ -1,4 +1,4 @@ -import {Direction, Directionality} from '@angular/cdk/bidi'; +import {Direction} from '@angular/cdk/bidi'; import { DOWN_ARROW, END, @@ -18,11 +18,12 @@ import { dispatchFakeEvent, dispatchKeyboardEvent, dispatchMouseEvent, + provideFakeDirectionality, } from '@angular/cdk/testing/private'; -import {Component} from '@angular/core'; +import {Component, signal, WritableSignal} from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; -import {MAT_DATE_FORMATS, MatNativeDateModule} from '../core'; import {By} from '@angular/platform-browser'; +import {MAT_DATE_FORMATS, MatNativeDateModule} from '../core'; import {DEC, FEB, JAN, MAR, NOV} from '../testing'; import {MatCalendarBody, MatCalendarUserEvent} from './calendar-body'; import { @@ -34,9 +35,11 @@ import {MatMonthView} from './month-view'; describe('MatMonthView', () => { describe('standard providers', () => { - let dir: {value: Direction}; + let dir: WritableSignal; beforeEach(waitForAsync(() => { + dir = signal('ltr'); + TestBed.configureTestingModule({ imports: [ MatNativeDateModule, @@ -48,7 +51,7 @@ describe('MatMonthView', () => { MonthViewWithDateClass, ], providers: [ - {provide: Directionality, useFactory: () => (dir = {value: 'ltr'})}, + provideFakeDirectionality(dir), {provide: MAT_DATE_RANGE_SELECTION_STRATEGY, useClass: DefaultMatCalendarRangeStrategy}, ], }); @@ -264,7 +267,7 @@ describe('MatMonthView', () => { '.mat-calendar-body', ) as HTMLElement; expect(calendarBodyEl).not.toBeNull(); - dir.value = 'ltr'; + dir.set('ltr'); fixture.componentInstance.date = new Date(2017, JAN, 5); fixture.changeDetectorRef.markForCheck(); dispatchFakeEvent(calendarBodyEl, 'focus'); @@ -287,7 +290,7 @@ describe('MatMonthView', () => { }); it('should increment date on left arrow press in rtl', () => { - dir.value = 'rtl'; + dir.set('rtl'); dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); fixture.detectChanges(); @@ -313,7 +316,7 @@ describe('MatMonthView', () => { }); it('should decrement date on right arrow press in rtl', () => { - dir.value = 'rtl'; + dir.set('rtl'); dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); fixture.detectChanges(); @@ -813,7 +816,7 @@ describe('MatMonthView', () => { MonthViewWithDateClass, ], providers: [ - {provide: Directionality, useFactory: () => ({value: 'ltr'})}, + provideFakeDirectionality('ltr'), {provide: MAT_DATE_RANGE_SELECTION_STRATEGY, useClass: DefaultMatCalendarRangeStrategy}, { provide: MAT_DATE_FORMATS, diff --git a/src/material/datepicker/multi-year-view.spec.ts b/src/material/datepicker/multi-year-view.spec.ts index 7228dc7d2796..ac12a39cb30c 100644 --- a/src/material/datepicker/multi-year-view.spec.ts +++ b/src/material/datepicker/multi-year-view.spec.ts @@ -1,4 +1,4 @@ -import {Direction, Directionality} from '@angular/cdk/bidi'; +import {Direction} from '@angular/cdk/bidi'; import { DOWN_ARROW, END, @@ -9,19 +9,25 @@ import { RIGHT_ARROW, UP_ARROW, } from '@angular/cdk/keycodes'; -import {dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing/private'; -import {Component, ViewChild} from '@angular/core'; +import { + dispatchFakeEvent, + dispatchKeyboardEvent, + provideFakeDirectionality, +} from '@angular/cdk/testing/private'; +import {Component, signal, ViewChild, WritableSignal} from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; -import {MatNativeDateModule} from '../core'; import {By} from '@angular/platform-browser'; +import {MatNativeDateModule} from '../core'; import {JAN, MAR} from '../testing'; import {MatCalendarBody} from './calendar-body'; import {MatMultiYearView, yearsPerPage, yearsPerRow} from './multi-year-view'; describe('MatMultiYearView', () => { - let dir: {value: Direction}; + let dir: WritableSignal; beforeEach(waitForAsync(() => { + dir = signal('ltr'); + TestBed.configureTestingModule({ imports: [ MatNativeDateModule, @@ -33,7 +39,7 @@ describe('MatMultiYearView', () => { MultiYearViewWithMinMaxDate, MultiYearViewWithDateClass, ], - providers: [{provide: Directionality, useFactory: () => (dir = {value: 'ltr'})}], + providers: [provideFakeDirectionality(dir)], }); })); @@ -111,7 +117,7 @@ describe('MatMultiYearView', () => { '.mat-calendar-body', ) as HTMLElement; expect(calendarBodyEl).not.toBeNull(); - dir.value = 'ltr'; + dir.set('ltr'); fixture.componentInstance.date = new Date(2017, JAN, 3); fixture.changeDetectorRef.markForCheck(); dispatchFakeEvent(calendarBodyEl, 'focus'); diff --git a/src/material/datepicker/testing/date-range-input-harness.spec.ts b/src/material/datepicker/testing/date-range-input-harness.spec.ts index 5396975fe711..cb1a9b5356d9 100644 --- a/src/material/datepicker/testing/date-range-input-harness.spec.ts +++ b/src/material/datepicker/testing/date-range-input-harness.spec.ts @@ -11,6 +11,7 @@ import { MatEndDate, MatStartDate, } from '../../datepicker'; +import {MatFormFieldModule} from '../../form-field'; import {MatCalendarHarness} from './calendar-harness'; import { MatDateRangeInputHarness, @@ -34,7 +35,14 @@ describe('matDateRangeInputHarness', () => { it('should load all date range input harnesses', async () => { const inputs = await loader.getAllHarnesses(MatDateRangeInputHarness); - expect(inputs.length).toBe(2); + expect(inputs.length).toBe(3); + }); + + it('should load date range input with a specific label', async () => { + const inputs = await loader.getAllHarnesses( + MatDateRangeInputHarness.with({label: 'Date range'}), + ); + expect(inputs.length).toBe(1); }); it('should get whether the input is disabled', async () => { @@ -261,6 +269,14 @@ describe('matDateRangeInputHarness', () => { + + + Date range + + + + + `, imports: [ MatNativeDateModule, @@ -268,6 +284,7 @@ describe('matDateRangeInputHarness', () => { MatStartDate, MatEndDate, MatDateRangePicker, + MatFormFieldModule, FormsModule, ], }) diff --git a/src/material/datepicker/testing/date-range-input-harness.ts b/src/material/datepicker/testing/date-range-input-harness.ts index c2a12d8dc040..4504717007fe 100644 --- a/src/material/datepicker/testing/date-range-input-harness.ts +++ b/src/material/datepicker/testing/date-range-input-harness.ts @@ -48,6 +48,8 @@ export class MatEndDateHarness extends MatDatepickerInputHarnessBase { export class MatDateRangeInputHarness extends DatepickerTriggerHarnessBase { static hostSelector = '.mat-date-range-input'; + private readonly _floatingLabelSelector = '.mdc-floating-label'; + /** * Gets a `HarnessPredicate` that can be used to search for a `MatDateRangeInputHarness` * that meets certain criteria. @@ -57,11 +59,13 @@ export class MatDateRangeInputHarness extends DatepickerTriggerHarnessBase { static with( options: DateRangeInputHarnessFilters = {}, ): HarnessPredicate { - return new HarnessPredicate(MatDateRangeInputHarness, options).addOption( - 'value', - options.value, - (harness, value) => HarnessPredicate.stringMatches(harness.getValue(), value), - ); + return new HarnessPredicate(MatDateRangeInputHarness, options) + .addOption('value', options.value, (harness, value) => + HarnessPredicate.stringMatches(harness.getValue(), value), + ) + .addOption('label', options.label, (harness, label) => { + return HarnessPredicate.stringMatches(harness.getLabel(), label); + }); } /** Gets the combined value of the start and end inputs, including the separator. */ @@ -87,6 +91,31 @@ export class MatDateRangeInputHarness extends DatepickerTriggerHarnessBase { return this.locatorFor(MatEndDateHarness)(); } + /** Gets the floating label text for the range input, if it exists. */ + async getLabel(): Promise { + // Copied from MatFormFieldControlHarnessBase since this class cannot extend two classes + const documentRootLocator = this.documentRootLocatorFactory(); + const labelId = await (await this.host()).getAttribute('aria-labelledby'); + const hostId = await (await this.host()).getAttribute('id'); + + if (labelId) { + // First option, try to fetch the label using the `aria-labelledby` + // attribute. + const labelEl = await documentRootLocator.locatorForOptional( + `${this._floatingLabelSelector}[id="${labelId}"]`, + )(); + return labelEl ? labelEl.text() : null; + } else if (hostId) { + // Fallback option, try to match the id of the input with the `for` + // attribute of the label. + const labelEl = await documentRootLocator.locatorForOptional( + `${this._floatingLabelSelector}[for="${hostId}"]`, + )(); + return labelEl ? labelEl.text() : null; + } + return null; + } + /** Gets the separator text between the values of the two inputs. */ async getSeparator(): Promise { return (await this.locatorFor('.mat-date-range-input-separator')()).text(); diff --git a/src/material/datepicker/testing/datepicker-harness-filters.ts b/src/material/datepicker/testing/datepicker-harness-filters.ts index 4245ad9625ef..90e750fd7ad2 100644 --- a/src/material/datepicker/testing/datepicker-harness-filters.ts +++ b/src/material/datepicker/testing/datepicker-harness-filters.ts @@ -6,10 +6,11 @@ * found in the LICENSE file at https://angular.dev/license */ +import {MatFormFieldControlHarnessFilters} from '@angular/material/form-field/testing/control'; import {BaseHarnessFilters} from '@angular/cdk/testing'; /** A set of criteria that can be used to filter a list of datepicker input instances. */ -export interface DatepickerInputHarnessFilters extends BaseHarnessFilters { +export interface DatepickerInputHarnessFilters extends MatFormFieldControlHarnessFilters { /** Filters based on the value of the input. */ value?: string | RegExp; /** Filters based on the placeholder text of the input. */ @@ -43,7 +44,7 @@ export interface CalendarCellHarnessFilters extends BaseHarnessFilters { } /** A set of criteria that can be used to filter a list of date range input instances. */ -export interface DateRangeInputHarnessFilters extends BaseHarnessFilters { +export interface DateRangeInputHarnessFilters extends MatFormFieldControlHarnessFilters { /** Filters based on the value of the input. */ value?: string | RegExp; } diff --git a/src/material/datepicker/testing/datepicker-input-harness-base.ts b/src/material/datepicker/testing/datepicker-input-harness-base.ts index 6e736928836f..e2a41fa70e76 100644 --- a/src/material/datepicker/testing/datepicker-input-harness-base.ts +++ b/src/material/datepicker/testing/datepicker-input-harness-base.ts @@ -7,7 +7,7 @@ */ import {ComponentHarnessConstructor, HarnessPredicate} from '@angular/cdk/testing'; -import {MatFormFieldControlHarness} from '../../form-field/testing/control'; +import {MatFormFieldControlHarnessBase} from '@angular/material/form-field/testing/control'; import {DatepickerInputHarnessFilters} from './datepicker-harness-filters'; /** Sets up the filter predicates for a datepicker input harness. */ @@ -21,11 +21,14 @@ export function getInputPredicate( }) .addOption('placeholder', options.placeholder, (harness, placeholder) => { return HarnessPredicate.stringMatches(harness.getPlaceholder(), placeholder); + }) + .addOption('label', options.label, (harness, label) => { + return HarnessPredicate.stringMatches(harness.getLabel(), label); }); } /** Base class for datepicker input harnesses. */ -export abstract class MatDatepickerInputHarnessBase extends MatFormFieldControlHarness { +export abstract class MatDatepickerInputHarnessBase extends MatFormFieldControlHarnessBase { /** Whether the input is disabled. */ async isDisabled(): Promise { return (await this.host()).getProperty('disabled'); diff --git a/src/material/datepicker/testing/datepicker-input-harness.spec.ts b/src/material/datepicker/testing/datepicker-input-harness.spec.ts index 8cf8739c62a8..72aa47c88353 100644 --- a/src/material/datepicker/testing/datepicker-input-harness.spec.ts +++ b/src/material/datepicker/testing/datepicker-input-harness.spec.ts @@ -5,6 +5,8 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {FormsModule} from '@angular/forms'; import {DateAdapter, MATERIAL_ANIMATIONS, MatNativeDateModule} from '../../core'; import {MatDatepickerModule} from '../../datepicker'; +import {MatFormFieldModule} from '../../form-field'; +import {MatInputModule} from '../../input'; import {MatCalendarHarness} from './calendar-harness'; import {MatDatepickerInputHarness} from './datepicker-input-harness'; @@ -27,6 +29,13 @@ describe('MatDatepickerInputHarness', () => { expect(inputs.length).toBe(2); }); + it('should load datepicker input with a specific label', async () => { + const selects = await loader.getAllHarnesses( + MatDatepickerInputHarness.with({label: 'Pick a date'}), + ); + expect(selects.length).toBe(1); + }); + it('should filter inputs based on their value', async () => { fixture.componentInstance.date = new Date(2020, 0, 1, 12, 0, 0); fixture.changeDetectorRef.markForCheck(); @@ -187,21 +196,31 @@ describe('MatDatepickerInputHarness', () => { @Component({ template: ` - - + + Pick a date + + + + `, - imports: [MatNativeDateModule, MatDatepickerModule, FormsModule], + imports: [ + MatNativeDateModule, + MatDatepickerModule, + MatFormFieldModule, + MatInputModule, + FormsModule, + ], }) class DatepickerInputHarnessTest { date: Date | null = null; diff --git a/src/material/datepicker/year-view.spec.ts b/src/material/datepicker/year-view.spec.ts index 6ed6c2f17bca..c30d9ae5a385 100644 --- a/src/material/datepicker/year-view.spec.ts +++ b/src/material/datepicker/year-view.spec.ts @@ -1,4 +1,4 @@ -import {Direction, Directionality} from '@angular/cdk/bidi'; +import {Direction} from '@angular/cdk/bidi'; import { DOWN_ARROW, END, @@ -9,19 +9,25 @@ import { RIGHT_ARROW, UP_ARROW, } from '@angular/cdk/keycodes'; -import {dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing/private'; -import {Component, ViewChild} from '@angular/core'; +import { + dispatchFakeEvent, + dispatchKeyboardEvent, + provideFakeDirectionality, +} from '@angular/cdk/testing/private'; +import {Component, signal, ViewChild, WritableSignal} from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; -import {MatNativeDateModule} from '../core'; import {By} from '@angular/platform-browser'; +import {MatNativeDateModule} from '../core'; import {AUG, DEC, FEB, JAN, JUL, JUN, MAR, MAY, NOV, OCT, SEP} from '../testing'; import {MatCalendarBody} from './calendar-body'; import {MatYearView} from './year-view'; describe('MatYearView', () => { - let dir: {value: Direction}; + let dir: WritableSignal; beforeEach(waitForAsync(() => { + dir = signal('ltr'); + TestBed.configureTestingModule({ imports: [ MatNativeDateModule, @@ -32,7 +38,7 @@ describe('MatYearView', () => { YearViewWithDateFilter, YearViewWithDateClass, ], - providers: [{provide: Directionality, useFactory: () => (dir = {value: 'ltr'})}], + providers: [provideFakeDirectionality(dir)], }); })); @@ -126,7 +132,7 @@ describe('MatYearView', () => { '.mat-calendar-body', ) as HTMLElement; expect(calendarBodyEl).not.toBeNull(); - dir.value = 'ltr'; + dir.set('ltr'); fixture.componentInstance.date = new Date(2017, JAN, 5); fixture.changeDetectorRef.markForCheck(); dispatchFakeEvent(calendarBodyEl, 'focus'); @@ -146,7 +152,7 @@ describe('MatYearView', () => { }); it('should increment month on left arrow press in rtl', () => { - dir.value = 'rtl'; + dir.set('rtl'); dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); fixture.detectChanges(); @@ -172,7 +178,7 @@ describe('MatYearView', () => { }); it('should decrement month on right arrow press in rtl', () => { - dir.value = 'rtl'; + dir.set('rtl'); dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); fixture.detectChanges(); diff --git a/src/material/dialog/README.md b/src/material/dialog/README.md index b4753f6be5b7..f550eedab7c2 100644 --- a/src/material/dialog/README.md +++ b/src/material/dialog/README.md @@ -1 +1 @@ -Please see the official documentation at https://material.angular.io/components/component/dialog +Please see the official documentation at https://material.angular.dev/components/component/dialog diff --git a/src/material/dialog/_dialog-theme.scss b/src/material/dialog/_dialog-theme.scss index fd28b6990657..2e0d8f88918f 100644 --- a/src/material/dialog/_dialog-theme.scss +++ b/src/material/dialog/_dialog-theme.scss @@ -1,94 +1,76 @@ @use 'sass:map'; -@use '../core/style/sass-utils'; @use './m2-dialog'; +@use './m3-dialog'; @use '../core/tokens/token-utils'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; @use '../core/typography/typography'; @mixin base($theme) { + $tokens: map.get(m2-dialog.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - // Add default values for tokens not related to color, typography, or density. - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-dialog.$prefix, m2-dialog.get-unthemable-tokens()); - } + $tokens: map.get(m3-dialog.get-tokens($theme), base); } + + @include token-utils.values($tokens); } @mixin color($theme) { + $tokens: map.get(m2-dialog.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-dialog.$prefix, m2-dialog.get-color-tokens($theme)); - } + $tokens: map.get(m3-dialog.get-tokens($theme), color); } + + @include token-utils.values($tokens); } @mixin typography($theme) { + $tokens: map.get(m2-dialog.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-dialog.$prefix, m2-dialog.get-typography-tokens($theme)); - } + $tokens: map.get(m3-dialog.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } @mixin density($theme) { + $tokens: map.get(m2-dialog.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - // There are no M3 density tokens for this component - } @else { + $tokens: map.get(m3-dialog.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-dialog.$prefix, - tokens: m2-dialog.get-token-slots(), + namespace: dialog, + tokens: token-utils.get-overrides(m3-dialog.get-tokens(), dialog) ), ); } @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } @mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-dialog') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - @include token-utils.create-token-values( - m2-dialog.$prefix, - map.get($tokens, m2-dialog.$prefix) - ); } } diff --git a/src/material/dialog/_m2-dialog.scss b/src/material/dialog/_m2-dialog.scss index 40c641fb5f27..0aefeccbaefb 100644 --- a/src/material/dialog/_m2-dialog.scss +++ b/src/material/dialog/_m2-dialog.scss @@ -1,77 +1,43 @@ -@use '../core/tokens/m2-utils'; @use '../core/style/elevation'; -@use '../core/style/sass-utils'; -@use '../core/theming/inspection'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, dialog); - -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - @return ( - container-shape: 4px, - container-elevation-shadow: elevation.get-box-shadow(24), - container-max-width: 80vw, - container-small-max-width: 80vw, - container-min-width: 0, - actions-alignment: start, - actions-padding: 8px, - content-padding: 20px 24px, - with-actions-content-padding: 20px 24px, - - // Note: the CSS class is `title` while the M2 tokens call it `subhead`. - // M3 calls it `headline` which is what we're aligning with. - headline-padding: 0 24px 9px, - ); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - $is-dark: inspection.get-theme-type($theme) == dark; - - // Ideally we would derive all values directly from the theme, but it causes a lot of regressions - // internally. For now we fall back to the old hardcoded behavior only for internal apps. - $text-base: if(m2-utils.$private-is-internal-build, - if($is-dark, #fff, #000), - inspection.get-theme-color($theme, foreground, text, 1) - ); +@use '../core/tokens/m2-utils'; +@use 'sass:map'; - @return ( - container-color: inspection.get-theme-color($theme, background, dialog), - subhead-color: sass-utils.safe-color-change($text-base, $alpha: 0.87), - supporting-text-color: sass-utils.safe-color-change($text-base, $alpha: 0.6), - ); -} +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { @return ( - subhead-font: inspection.get-theme-typography($theme, headline-6, font-family), - subhead-line-height: inspection.get-theme-typography($theme, headline-6, line-height), - subhead-size: inspection.get-theme-typography($theme, headline-6, font-size), - subhead-weight: inspection.get-theme-typography($theme, headline-6, font-weight), - subhead-tracking: inspection.get-theme-typography($theme, headline-6, letter-spacing), - supporting-text-font: inspection.get-theme-typography($theme, body-1, font-family), - supporting-text-line-height: inspection.get-theme-typography($theme, body-1, line-height), - supporting-text-size: inspection.get-theme-typography($theme, body-1, font-size), - supporting-text-weight: inspection.get-theme-typography($theme, body-1, font-weight), - supporting-text-tracking: inspection.get-theme-typography($theme, body-1, letter-spacing), - ); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + base: ( + dialog-container-shape: 4px, + dialog-container-elevation-shadow: elevation.get-box-shadow(24), + dialog-container-max-width: 80vw, + dialog-container-small-max-width: 80vw, + dialog-container-min-width: 0, + dialog-actions-alignment: start, + dialog-actions-padding: 8px, + dialog-content-padding: 20px 24px, + dialog-with-actions-content-padding: 20px 24px, + + // Note: the CSS class is `title` while the M2 tokens call it `subhead`. + // M3 calls it `headline` which is what we're aligning with. + dialog-headline-padding: 0 24px 9px, + ), + color: ( + dialog-container-color: map.get($system, surface), + dialog-subhead-color: map.get($system, on-surface), + dialog-supporting-text-color: map.get($system, on-surface-variant), + ), + typography: ( + dialog-subhead-font: map.get($system, title-small-font), + dialog-subhead-line-height: map.get($system, title-small-line-height), + dialog-subhead-size: map.get($system, title-small-size), + dialog-subhead-weight: map.get($system, title-small-weight), + dialog-subhead-tracking: map.get($system, title-small-tracking), + dialog-supporting-text-font: map.get($system, body-large-font), + dialog-supporting-text-line-height: map.get($system, body-large-line-height), + dialog-supporting-text-size: map.get($system, body-large-size), + dialog-supporting-text-weight: map.get($system, body-large-weight), + dialog-supporting-text-tracking: map.get($system, body-large-tracking), + ), + density: (), ); } diff --git a/src/material/dialog/_m3-dialog.scss b/src/material/dialog/_m3-dialog.scss index 23f6a4e613ac..d9b924c2f8f3 100644 --- a/src/material/dialog/_m3-dialog.scss +++ b/src/material/dialog/_m3-dialog.scss @@ -1,47 +1,47 @@ @use '../core/tokens/m3-utils'; @use 'sass:map'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, dialog); +@use '../core/tokens/m3'; /// Generates custom tokens for the dialog. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the dialog -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); + // headline-padding: // The vertical padding values are a bit weird, because MDC uses a `::before` pseudo // element to size the title which in turn means that we can't set a specific padding // and get the exact same space out of it. These values were determined through // trial and error so that the first line of text is 24px from the top and the bottom // of the text is 16px from the content under it. - $tokens: ( - actions-alignment: m3-utils.hardcode(flex-end, $exclude-hardcoded), - actions-padding: m3-utils.hardcode(16px 24px, $exclude-hardcoded), - container-color: map.get($systems, md-sys-color, surface), - container-elevation-shadow: m3-utils.hardcode(none, $exclude-hardcoded), - container-max-width: m3-utils.hardcode(560px, $exclude-hardcoded), - container-min-width: m3-utils.hardcode(280px, $exclude-hardcoded), - container-shape: map.get($systems, md-sys-shape, corner-extra-large), - container-small-max-width: - m3-utils.hardcode(calc(100vw - 32px), $exclude-hardcoded), - content-padding: m3-utils.hardcode(20px 24px, $exclude-hardcoded), - headline-padding: m3-utils.hardcode(6px 24px 13px, $exclude-hardcoded), - subhead-color: map.get($systems, md-sys-color, on-surface), - subhead-font: map.get($systems, md-sys-typescale, headline-small-font), - subhead-line-height: map.get($systems, md-sys-typescale, headline-small-line-height), - subhead-size: map.get($systems, md-sys-typescale, headline-small-size), - subhead-tracking: map.get($systems, md-sys-typescale, headline-small-tracking), - subhead-weight: map.get($systems, md-sys-typescale, headline-small-weight), - supporting-text-color: map.get($systems, md-sys-color, on-surface-variant), - supporting-text-font: map.get($systems, md-sys-typescale, body-medium-font), - supporting-text-line-height: map.get($systems, md-sys-typescale, body-medium-line-height), - supporting-text-size: map.get($systems, md-sys-typescale, body-medium-size), - supporting-text-tracking: map.get($systems, md-sys-typescale, body-medium-tracking), - supporting-text-weight: map.get($systems, md-sys-typescale, body-medium-weight), - with-actions-content-padding: m3-utils.hardcode(20px 24px 0, $exclude-hardcoded), + @return ( + base: ( + dialog-actions-alignment: flex-end, + dialog-actions-padding: 16px 24px, + dialog-container-elevation-shadow: none, + dialog-container-max-width: 560px, + dialog-container-min-width: 280px, + dialog-container-shape: map.get($system, corner-extra-large), + dialog-container-small-max-width: calc(100vw - 32px), + dialog-content-padding: 20px 24px, + dialog-headline-padding: 6px 24px 13px, + dialog-with-actions-content-padding: 20px 24px 0, + ), + color: ( + dialog-container-color: map.get($system, surface), + dialog-subhead-color: map.get($system, on-surface), + dialog-supporting-text-color: map.get($system, on-surface-variant), + ), + typography: ( + dialog-subhead-font: map.get($system, headline-small-font), + dialog-subhead-line-height: map.get($system, headline-small-line-height), + dialog-subhead-size: map.get($system, headline-small-size), + dialog-subhead-tracking: map.get($system, headline-small-tracking), + dialog-subhead-weight: map.get($system, headline-small-weight), + dialog-supporting-text-font: map.get($system, body-medium-font), + dialog-supporting-text-line-height: map.get($system, body-medium-line-height), + dialog-supporting-text-size: map.get($system, body-medium-size), + dialog-supporting-text-tracking: map.get($system, body-medium-tracking), + dialog-supporting-text-weight: map.get($system, body-medium-weight), + ), + density: (), ); - - @return m3-utils.namespace($prefix, $tokens, $token-slots); } diff --git a/src/material/dialog/dialog-config.ts b/src/material/dialog/dialog-config.ts index a684362fbcc9..0d04af221c67 100644 --- a/src/material/dialog/dialog-config.ts +++ b/src/material/dialog/dialog-config.ts @@ -92,7 +92,7 @@ export class MatDialogConfig { /** Min-height of the dialog. If a number is provided, assumes pixel units. */ minHeight?: number | string; - /** Max-width of the dialog. If a number is provided, assumes pixel units. Defaults to 80vw. */ + /** Max-width of the dialog. If a number is provided, assumes pixel units. */ maxWidth?: number | string; /** Max-height of the dialog. If a number is provided, assumes pixel units. */ diff --git a/src/material/dialog/dialog.scss b/src/material/dialog/dialog.scss index 7b89b5dc9007..a5a58edf73de 100644 --- a/src/material/dialog/dialog.scss +++ b/src/material/dialog/dialog.scss @@ -1,5 +1,5 @@ @use '@angular/cdk'; -@use './m2-dialog'; +@use './m3-dialog'; @use '../core/tokens/token-utils'; @use '../core/style/variables'; @@ -15,13 +15,7 @@ $mat-dialog-button-horizontal-margin: 8px !default; // Fixes b/323588333 $mat-dialog-container-max-height: 95vh !default; -// Whether to emit fallback values for the structural styles. Previously MDC was emitting -// paddings in the static styles which meant that users would get them even if they didn't -// include the `dialog-base`. Eventually we should clean up the usages of this flag. -$_emit-fallbacks: true; - -$token-prefix: m2-dialog.$prefix; -$token-slots: m2-dialog.get-token-slots(); +$fallbacks: m3-dialog.get-tokens(); // Note that we disable fallback declarations, but we don't disable fallback // values, because there are a lot of internal apps that don't include a proper @@ -42,13 +36,11 @@ $token-slots: m2-dialog.get-token-slots(); // This needs extra specificity so it doesn't get overwritten by the CDK structural styles. .cdk-overlay-pane.mat-mdc-dialog-panel { - @include token-utils.use-tokens($token-prefix, $token-slots) { - max-width: token-utils.slot(container-max-width, $_emit-fallbacks); - min-width: token-utils.slot(container-min-width, $_emit-fallbacks); + max-width: token-utils.slot(dialog-container-max-width, $fallbacks); + min-width: token-utils.slot(dialog-container-min-width, $fallbacks); - @media (variables.$xsmall) { - max-width: token-utils.slot(container-small-max-width, $_emit-fallbacks); - } + @media (variables.$xsmall) { + max-width: token-utils.slot(dialog-container-small-max-width, $fallbacks); } } @@ -98,14 +90,10 @@ $token-slots: m2-dialog.get-token-slots(); min-width: inherit; max-width: inherit; - @include token-utils.use-tokens($token-prefix, $token-slots) { - box-shadow: token-utils.slot(container-elevation-shadow, $_emit-fallbacks); - } + box-shadow: token-utils.slot(dialog-container-elevation-shadow, $fallbacks, none); - @include token-utils.use-tokens($token-prefix, $token-slots) { - border-radius: token-utils.slot(container-shape, $_emit-fallbacks); - background-color: token-utils.slot(container-color, $_emit-fallbacks); - } + border-radius: token-utils.slot(dialog-container-shape, $fallbacks, 4px); + background-color: token-utils.slot(dialog-container-color, $fallbacks, white); [dir='rtl'] & { text-align: right; @@ -142,9 +130,7 @@ $token-slots: m2-dialog.get-token-slots(); box-sizing: border-box; margin: 0 0 1px; - @include token-utils.use-tokens($token-prefix, $token-slots) { - padding: token-utils.slot(headline-padding, $_emit-fallbacks); - } + padding: token-utils.slot(dialog-headline-padding, $fallbacks, 6px 24px 13px); // This was used by MDC to set the text baseline. We should figure out a way to // remove it, because it can introduce unnecessary whitespace at the beginning @@ -163,14 +149,12 @@ $token-slots: m2-dialog.get-token-slots(); // Nested to maintain the old specificity. .mat-mdc-dialog-container & { - @include token-utils.use-tokens($token-prefix, $token-slots) { - color: token-utils.slot(subhead-color, $_emit-fallbacks); - font-family: token-utils.slot(subhead-font, if($_emit-fallbacks, inherit, null)); - line-height: token-utils.slot(subhead-line-height, $_emit-fallbacks); - font-size: token-utils.slot(subhead-size, $_emit-fallbacks); - font-weight: token-utils.slot(subhead-weight, $_emit-fallbacks); - letter-spacing: token-utils.slot(subhead-tracking, $_emit-fallbacks); - } + color: token-utils.slot(dialog-subhead-color, $fallbacks, rgba(0, 0, 0, 0.87)); + font-family: token-utils.slot(dialog-subhead-font, $fallbacks, inherit); + line-height: token-utils.slot(dialog-subhead-line-height, $fallbacks, 1.5rem); + font-size: token-utils.slot(dialog-subhead-size, $fallbacks, 1rem); + font-weight: token-utils.slot(dialog-subhead-weight, $fallbacks, 400); + letter-spacing: token-utils.slot(dialog-subhead-tracking, $fallbacks, 0.03125em); } } @@ -192,27 +176,23 @@ $token-slots: m2-dialog.get-token-slots(); // Nested to maintain the old specificity. .mat-mdc-dialog-container & { - @include token-utils.use-tokens($token-prefix, $token-slots) { - color: token-utils.slot(supporting-text-color, $_emit-fallbacks); - font-family: token-utils.slot(supporting-text-font, if($_emit-fallbacks, inherit, null)); - line-height: token-utils.slot(supporting-text-line-height, $_emit-fallbacks); - font-size: token-utils.slot(supporting-text-size, $_emit-fallbacks); - font-weight: token-utils.slot(supporting-text-weight, $_emit-fallbacks); - letter-spacing: token-utils.slot(supporting-text-tracking, $_emit-fallbacks); - } + color: token-utils.slot(dialog-supporting-text-color, $fallbacks, rgba(0, 0, 0, 0.6)); + font-family: token-utils.slot(dialog-supporting-text-font, $fallbacks, inherit); + line-height: token-utils.slot(dialog-supporting-text-line-height, $fallbacks, 1.5rem); + font-size: token-utils.slot(dialog-supporting-text-size, $fallbacks, 1rem); + font-weight: token-utils.slot(dialog-supporting-text-weight, $fallbacks, 400); + letter-spacing: token-utils.slot(dialog-supporting-text-tracking, $fallbacks, 0.03125em); } - @include token-utils.use-tokens($token-prefix, $token-slots) { - // These styles need a bit more specificity. - .mat-mdc-dialog-container & { - padding: token-utils.slot(content-padding, $_emit-fallbacks); - } + // These styles need a bit more specificity. + .mat-mdc-dialog-container & { + padding: token-utils.slot(dialog-content-padding, $fallbacks, 20px 24px 0); + } - // Note: we can achieve this with a `:has` selector, but it results in an - // increased specificity which breaks a lot of internal clients. - .mat-mdc-dialog-container-with-actions & { - padding: token-utils.slot(with-actions-content-padding, $_emit-fallbacks); - } + // Note: we can achieve this with a `:has` selector, but it results in an + // increased specificity which breaks a lot of internal clients. + .mat-mdc-dialog-container-with-actions & { + padding: token-utils.slot(dialog-with-actions-content-padding, $fallbacks, 20px 24px 0); } .mat-mdc-dialog-container .mat-mdc-dialog-title + & { @@ -226,19 +206,15 @@ $token-slots: m2-dialog.get-token-slots(); flex-shrink: 0; flex-wrap: wrap; align-items: center; - justify-content: flex-end; box-sizing: border-box; min-height: 52px; margin: 0; - padding: 8px; border-top: 1px solid transparent; // For backwards compatibility, actions align at start by default. MDC usually // aligns actions at the end of the container. - @include token-utils.use-tokens($token-prefix, $token-slots) { - padding: token-utils.slot(actions-padding, $_emit-fallbacks); - justify-content: token-utils.slot(actions-alignment, $_emit-fallbacks); - } + padding: token-utils.slot(dialog-actions-padding, $fallbacks, 16px 24px); + justify-content: token-utils.slot(dialog-actions-alignment, $fallbacks, flex-end); @include cdk.high-contrast { border-top-color: CanvasText; diff --git a/src/material/dialog/dialog.spec.ts b/src/material/dialog/dialog.spec.ts index 96bbb691f6c5..d7cec2f24432 100644 --- a/src/material/dialog/dialog.spec.ts +++ b/src/material/dialog/dialog.spec.ts @@ -2287,7 +2287,6 @@ class DirectiveWithViewContainer { @Component({ changeDetection: ChangeDetectionStrategy.OnPush, template: 'hello', - standalone: false, }) class ComponentWithOnPushViewContainer { viewContainerRef = inject(ViewContainerRef); @@ -2429,7 +2428,6 @@ class DialogWithoutFocusableElements {} @Component({ template: ``, encapsulation: ViewEncapsulation.ShadowDom, - standalone: false, }) class ShadowDomComponent {} diff --git a/src/material/dialog/testing/dialog-opener.ts b/src/material/dialog/testing/dialog-opener.ts index 68cff6c9269f..6f2fdf81a47c 100644 --- a/src/material/dialog/testing/dialog-opener.ts +++ b/src/material/dialog/testing/dialog-opener.ts @@ -17,8 +17,8 @@ import { inject, } from '@angular/core'; import {MatDialog, MatDialogConfig, MatDialogModule, MatDialogRef} from '../../dialog'; -import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {Subscription} from 'rxjs'; +import {AnimationsConfig, MATERIAL_ANIMATIONS} from '@angular/material/core'; /** Test component that immediately opens a dialog when bootstrapped. */ @Component({ @@ -63,12 +63,12 @@ export class MatTestDialogOpener implements OnDestroy throw new Error(`MatTestDialogOpener does not have a component provided.`); } - this.dialogRef = this._ngZone.run(() => - this.dialog.open( - MatTestDialogOpener.component as ComponentType, - MatTestDialogOpener.config || {}, - ), - ); + this.dialogRef = this._ngZone.run(() => { + const config = {...(MatTestDialogOpener.config || {})}; + config.enterAnimationDuration = 0; + config.exitAnimationDuration = 0; + return this.dialog.open(MatTestDialogOpener.component as ComponentType, config); + }); this._afterClosedSubscription = this.dialogRef.afterClosed().subscribe(result => { this.closedResult = result; }); @@ -82,6 +82,14 @@ export class MatTestDialogOpener implements OnDestroy } @NgModule({ - imports: [MatDialogModule, NoopAnimationsModule, MatTestDialogOpener], + imports: [MatDialogModule, MatTestDialogOpener], + providers: [ + { + provide: MATERIAL_ANIMATIONS, + useValue: { + animationsDisabled: true, + } as AnimationsConfig, + }, + ], }) export class MatTestDialogOpenerModule {} diff --git a/src/material/divider/README.md b/src/material/divider/README.md index 0ccb4d491be6..b7de9b27a82b 100644 --- a/src/material/divider/README.md +++ b/src/material/divider/README.md @@ -1 +1 @@ -Please see the official documentation at https://material.angular.io/components/component/divider \ No newline at end of file +Please see the official documentation at https://material.angular.dev/components/component/divider diff --git a/src/material/divider/_divider-theme.scss b/src/material/divider/_divider-theme.scss index fb079fabc028..3aac3203cf31 100644 --- a/src/material/divider/_divider-theme.scss +++ b/src/material/divider/_divider-theme.scss @@ -1,88 +1,75 @@ @use 'sass:map'; -@use '../core/style/sass-utils'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; @use '../core/tokens/token-utils'; @use './m2-divider'; +@use './m3-divider'; @mixin base($theme) { + $tokens: map.get(m2-divider.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-divider.$prefix, m2-divider.get-unthemable-tokens()); - } + $tokens: map.get(m3-divider.get-tokens($theme), base); } + + @include token-utils.values($tokens); } @mixin color($theme) { + $tokens: map.get(m2-divider.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-divider.$prefix, m2-divider.get-color-tokens($theme)); - } + $tokens: map.get(m3-divider.get-tokens($theme), color); } + + @include token-utils.values($tokens); } @mixin typography($theme) { + $tokens: map.get(m2-divider.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { + $tokens: map.get(m3-divider.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } @mixin density($theme) { + $tokens: map.get(m2-divider.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - // There are no M3 density tokens for this component - } @else { + $tokens: map.get(m3-divider.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-divider.$prefix, - tokens: m2-divider.get-token-slots(), + namespace: divider, + tokens: token-utils.get-overrides(m3-divider.get-tokens(), divider) ), ); } @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } @mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-divider') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - @include token-utils.create-token-values( - m2-divider.$prefix, - map.get($tokens, m2-divider.$prefix) - ); } } diff --git a/src/material/divider/_m2-divider.scss b/src/material/divider/_m2-divider.scss index 6940edbf1d65..045fcba92606 100644 --- a/src/material/divider/_m2-divider.scss +++ b/src/material/divider/_m2-divider.scss @@ -1,42 +1,17 @@ @use '../core/tokens/m2-utils'; -@use '../core/theming/inspection'; -@use '../core/style/sass-utils'; +@use 'sass:map'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, divider); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { @return ( - width: 1px - ); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - @return ( - color: inspection.get-theme-color($theme, foreground, divider) - ); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return (); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + base: ( + divider-width: 1px + ), + color: ( + divider-color: map.get($system, outline) + ), + typography: (), + density: (), ); } diff --git a/src/material/divider/_m3-divider.scss b/src/material/divider/_m3-divider.scss index 2a1b605846c4..bd86da2d98a5 100644 --- a/src/material/divider/_m3-divider.scss +++ b/src/material/divider/_m3-divider.scss @@ -1,19 +1,19 @@ @use 'sass:map'; @use '../core/tokens/m3-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, divider); +@use '../core/tokens/m3'; /// Generates custom tokens for the mat-divider. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-divider -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: ( - width: m3-utils.hardcode(1px, $exclude-hardcoded), - color: map.get($systems, md-sys-color, outline), - ); +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); - @return m3-utils.namespace($prefix, $tokens, $token-slots); + @return ( + base: ( + divider-width: 1px, + ), + color: ( + divider-color: map.get($system, outline), + ), + typography: (), + density: (), + ); } diff --git a/src/material/divider/divider.scss b/src/material/divider/divider.scss index bbde097ef05d..6dd6271d2eb5 100644 --- a/src/material/divider/divider.scss +++ b/src/material/divider/divider.scss @@ -1,22 +1,22 @@ @use '../core/tokens/token-utils'; -@use './m2-divider'; +@use './m3-divider'; $inset-margin: 80px; +$fallbacks: m3-divider.get-tokens(); + .mat-divider { display: block; margin: 0; border-top-style: solid; - @include token-utils.use-tokens(m2-divider.$prefix, m2-divider.get-token-slots()) { - border-top-color: token-utils.slot(color); - border-top-width: token-utils.slot(width); + border-top-color: token-utils.slot(divider-color, $fallbacks); + border-top-width: token-utils.slot(divider-width, $fallbacks); - &.mat-divider-vertical { - border-top: 0; - border-right-style: solid; - border-right-color: token-utils.slot(color); - border-right-width: token-utils.slot(width); - } + &.mat-divider-vertical { + border-top: 0; + border-right-style: solid; + border-right-color: token-utils.slot(divider-color, $fallbacks); + border-right-width: token-utils.slot(divider-width, $fallbacks); } &.mat-divider-inset { diff --git a/src/material/expansion/_expansion-theme.scss b/src/material/expansion/_expansion-theme.scss index 251fc806e543..086e8789bde3 100644 --- a/src/material/expansion/_expansion-theme.scss +++ b/src/material/expansion/_expansion-theme.scss @@ -1,105 +1,76 @@ @use 'sass:map'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; @use '../core/typography/typography'; -@use '../core/style/sass-utils'; @use '../core/tokens/token-utils'; @use './m2-expansion'; @use './m3-expansion'; @mixin base($theme) { + $tokens: map.get(m2-expansion.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-expansion.$prefix, m2-expansion.get-unthemable-tokens()); - } + $tokens: map.get(m3-expansion.get-tokens($theme), base); } + + @include token-utils.values($tokens); } @mixin color($theme) { + $tokens: map.get(m2-expansion.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-expansion.$prefix, m2-expansion.get-color-tokens($theme)); - } + $tokens: map.get(m3-expansion.get-tokens($theme), color); } + + @include token-utils.values($tokens); } @mixin typography($theme) { + $tokens: map.get(m2-expansion.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - // TODO(mmalerba): Stop calling this and resolve resulting screen diffs. - $theme: inspection.private-get-typography-back-compat-theme($theme); - - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-expansion.$prefix, m2-expansion.get-typography-tokens($theme)); - } + $tokens: map.get(m3-expansion.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } @mixin density($theme) { + $tokens: map.get(m2-expansion.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - @include token-utils.create-token-values( - m3-expansion.$prefix, m3-expansion.get-density-tokens($theme)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-expansion.$prefix, m2-expansion.get-density-tokens($theme)); - } + $tokens: map.get(m3-expansion.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-expansion.$prefix, - tokens: m2-expansion.get-token-slots(), + namespace: expansion, + tokens: token-utils.get-overrides(m3-expansion.get-tokens(), expansion) ), ); } @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } @mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-expansion') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - @if inspection.theme-has($theme, density) { - @include density($theme); - } - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - @include token-utils.create-token-values( - m2-expansion.$prefix, - map.get($tokens, m2-expansion.$prefix) - ); } } diff --git a/src/material/expansion/_m2-expansion.scss b/src/material/expansion/_m2-expansion.scss index 4a33f72367e1..4224d7ddc353 100644 --- a/src/material/expansion/_m2-expansion.scss +++ b/src/material/expansion/_m2-expansion.scss @@ -1,88 +1,64 @@ @use 'sass:map'; -@use '../core/tokens/m2-utils'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; -@use '../core/style/sass-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, expansion); - -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - @return ( - container-shape: 4px, - legacy-header-indicator-display: inline-block, - header-indicator-display: none, - ); -} +@use '../core/tokens/m2-utils'; +@use '../core/tokens/m3-utils'; +@use '../core/style/elevation'; -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - @return ( - container-background-color: inspection.get-theme-color($theme, background, card), - container-text-color: inspection.get-theme-color($theme, foreground, text), - actions-divider-color: inspection.get-theme-color($theme, foreground, divider), - header-hover-state-layer-color: inspection.get-theme-color($theme, background, hover), - header-focus-state-layer-color: inspection.get-theme-color($theme, background, hover), - header-disabled-state-text-color: inspection.get-theme-color( - $theme, foreground, disabled-button), - header-text-color: inspection.get-theme-color($theme, foreground, text), - header-description-color: inspection.get-theme-color($theme, foreground, secondary-text), - header-indicator-color: inspection.get-theme-color($theme, foreground, secondary-text), - ); -} +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); + $density-scale: theming.clamp-density(map.get($system, density-scale), -3); -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { @return ( - header-text-font: inspection.get-theme-typography($theme, subtitle-2, font-family), - header-text-size: inspection.get-theme-typography($theme, subtitle-2, font-size), - header-text-weight: inspection.get-theme-typography($theme, subtitle-2, font-weight), + base: ( + expansion-container-shape: 4px, + expansion-container-elevation-shadow: elevation.get-box-shadow(2), + expansion-legacy-header-indicator-display: inline-block, + expansion-header-indicator-display: none, + ), + color: ( + expansion-container-background-color: map.get($system, surface), + expansion-container-text-color: map.get($system, on-surface), + expansion-actions-divider-color: map.get($system, outline), + expansion-header-hover-state-layer-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, hover-state-layer-opacity)), + expansion-header-focus-state-layer-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, focus-state-layer-opacity)), + expansion-header-disabled-state-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + expansion-header-text-color: map.get($system, on-surface), + expansion-header-description-color: map.get($system, on-surface-variant), + expansion-header-indicator-color: map.get($system, on-surface-variant), + ), + typography: ( + expansion-header-text-font: map.get($system, label-medium-font), + expansion-header-text-size: map.get($system, label-medium-size), + expansion-header-text-weight: map.get($system, label-medium-weight), - // TODO(crisbeto): these two properties weren't set at all before the introduction of tokens, - // but it's inconsistent not to provide them since the container sets all of them. Eventually - // we should change the values to use come from `subtitle-2`. - header-text-line-height: inherit, - header-text-tracking: inherit, - - container-text-font: inspection.get-theme-typography($theme, body-2, font-family), - container-text-line-height: inspection.get-theme-typography($theme, body-2, line-height), - container-text-size: inspection.get-theme-typography($theme, body-2, font-size), - container-text-tracking: inspection.get-theme-typography($theme, body-2, letter-spacing), - container-text-weight: inspection.get-theme-typography($theme, body-2, font-weight), - ); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $scale: theming.clamp-density(inspection.get-theme-density($theme), -3); - $collapsed-scale: ( - 0: 48px, - -1: 44px, - -2: 40px, - -3: 36px, - ); - $expanded-scale: ( - 0: 64px, - -1: 60px, - -2: 56px, - -3: 48px, - ); - - @return ( - header-collapsed-state-height: map.get($collapsed-scale, $scale), - header-expanded-state-height: map.get($expanded-scale, $scale), - ); -} + // TODO(crisbeto): these two properties weren't set at all before the introduction of tokens, + // but it's inconsistent not to provide them since the container sets all of them. Eventually + // we should change the values to use come from `subtitle-2`. + expansion-header-text-line-height: inherit, + expansion-header-text-tracking: inherit, -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + expansion-container-text-font: map.get($system, body-medium-font), + expansion-container-text-line-height: map.get($system, body-medium-line-height), + expansion-container-text-size: map.get($system, body-medium-size), + expansion-container-text-tracking: map.get($system, body-medium-tracking), + expansion-container-text-weight: map.get($system, body-medium-weight), + ), + density: ( + expansion-header-collapsed-state-height: map.get(( + 0: 48px, + -1: 44px, + -2: 40px, + -3: 36px, + ), $density-scale), + expansion-header-expanded-state-height: map.get(( + 0: 64px, + -1: 60px, + -2: 56px, + -3: 48px, + ), $density-scale), + ), ); } diff --git a/src/material/expansion/_m3-expansion.scss b/src/material/expansion/_m3-expansion.scss index f0299de8eab4..4ae185f87754 100644 --- a/src/material/expansion/_m3-expansion.scss +++ b/src/material/expansion/_m3-expansion.scss @@ -1,55 +1,61 @@ @use 'sass:map'; @use 'sass:list'; -@use '../core/style/sass-utils'; @use '../core/tokens/m3-utils'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; +@use '../core/tokens/m3'; +@use '../core/style/elevation'; // The prefix used to generate the fully qualified name for tokens in this file. $prefix: (mat, expansion); /// Generates custom tokens for the mat-expansion. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-expansion -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: sass-utils.merge-all( - m3-utils.generate-typography-tokens($systems, header-text, title-medium), - m3-utils.generate-typography-tokens($systems, container-text, body-large), - ( - container-shape: m3-utils.hardcode(12px, $exclude-hardcoded), - legacy-header-indicator-display: m3-utils.hardcode(none, $exclude-hardcoded), - header-indicator-display: m3-utils.hardcode(inline-block, $exclude-hardcoded), - container-background-color: map.get($systems, md-sys-color, surface), - container-text-color: map.get($systems, md-sys-color, on-surface), - actions-divider-color: map.get($systems, md-sys-color, outline), - header-hover-state-layer-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: map.get($systems, md-sys-state, hover-state-layer-opacity) - ), - header-focus-state-layer-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), - $alpha: map.get($systems, md-sys-state, focus-state-layer-opacity) - ), - header-disabled-state-text-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - header-text-color: map.get($systems, md-sys-color, on-surface), - header-description-color: map.get($systems, md-sys-color, on-surface-variant), - header-indicator-color: map.get($systems, md-sys-color, on-surface-variant), - ) - ); +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); - @return m3-utils.namespace($prefix, $tokens, $token-slots); + @return ( + base: ( + expansion-container-shape: 12px, + expansion-header-indicator-display: inline-block, + expansion-legacy-header-indicator-display: none, + expansion-container-elevation-shadow: elevation.get-box-shadow(2), + ), + color: ( + expansion-actions-divider-color: map.get($system, outline), + expansion-container-background-color: map.get($system, surface), + expansion-container-text-color: map.get($system, on-surface), + expansion-header-description-color: map.get($system, on-surface-variant), + expansion-header-disabled-state-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + expansion-header-focus-state-layer-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, focus-state-layer-opacity)), + expansion-header-hover-state-layer-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, hover-state-layer-opacity)), + expansion-header-indicator-color: map.get($system, on-surface-variant), + expansion-header-text-color: map.get($system, on-surface), + ), + typography: ( + expansion-container-text-font: map.get($system, body-large-font), + expansion-container-text-line-height: map.get($system, body-large-line-height), + expansion-container-text-size: map.get($system, body-large-size), + expansion-container-text-tracking: map.get($system, body-large-tracking), + expansion-container-text-weight: map.get($system, body-large-weight), + expansion-header-text-font: map.get($system, title-medium-font), + expansion-header-text-line-height: map.get($system, title-medium-line-height), + expansion-header-text-size: map.get($system, title-medium-size), + expansion-header-text-tracking: map.get($system, title-medium-tracking), + expansion-header-text-weight: map.get($system, title-medium-weight), + ), + density: get-density-tokens(map.get($system, density-scale)), + ); } // Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $scale: theming.clamp-density(inspection.get-theme-density($theme), -3); +@function get-density-tokens($scale) { + $scale: theming.clamp-density($scale, -3); $index: ($scale * -1) + 1; @return ( - header-collapsed-state-height: list.nth((48px, 44px, 40px, 36px), $index), - header-expanded-state-height: list.nth((64px, 60px, 56px, 48px), $index), + expansion-header-collapsed-state-height: list.nth((48px, 44px, 40px, 36px), $index), + expansion-header-expanded-state-height: list.nth((64px, 60px, 56px, 48px), $index), ); } diff --git a/src/material/expansion/accordion.spec.ts b/src/material/expansion/accordion.spec.ts index 1cb2987d6c6b..b1ce7dac589c 100644 --- a/src/material/expansion/accordion.spec.ts +++ b/src/material/expansion/accordion.spec.ts @@ -19,17 +19,6 @@ describe('MatAccordion', () => { let focusMonitor: FocusMonitor; beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - MatExpansionModule, - AccordionWithHideToggle, - AccordionWithTogglePosition, - NestedPanel, - SetOfItems, - NestedAccordions, - ], - }); - focusMonitor = TestBed.inject(FocusMonitor); })); diff --git a/src/material/expansion/expansion-panel-header.scss b/src/material/expansion/expansion-panel-header.scss index c9a9af2826bc..8cb8369c4e05 100644 --- a/src/material/expansion/expansion-panel-header.scss +++ b/src/material/expansion/expansion-panel-header.scss @@ -1,10 +1,9 @@ @use '@angular/cdk'; -@use './m2-expansion'; +@use './m3-expansion'; @use '../core/tokens/token-utils'; @use './expansion-variables'; -$token-prefix: m2-expansion.$prefix; -$token-slots: m2-expansion.get-token-slots(); +$fallbacks: m3-expansion.get-tokens(); .mat-expansion-panel-header { display: flex; @@ -22,40 +21,38 @@ $token-slots: m2-expansion.get-token-slots(); border-radius: inherit; } - @include token-utils.use-tokens($token-prefix, $token-slots) { - height: token-utils.slot(header-collapsed-state-height); - font-family: token-utils.slot(header-text-font); - font-size: token-utils.slot(header-text-size); - font-weight: token-utils.slot(header-text-weight); - line-height: token-utils.slot(header-text-line-height); - letter-spacing: token-utils.slot(header-text-tracking); + height: token-utils.slot(expansion-header-collapsed-state-height, $fallbacks); + font-family: token-utils.slot(expansion-header-text-font, $fallbacks); + font-size: token-utils.slot(expansion-header-text-size, $fallbacks); + font-weight: token-utils.slot(expansion-header-text-weight, $fallbacks); + line-height: token-utils.slot(expansion-header-text-line-height, $fallbacks); + letter-spacing: token-utils.slot(expansion-header-text-tracking, $fallbacks); - &.mat-expanded { - height: token-utils.slot(header-expanded-state-height); - } + &.mat-expanded { + height: token-utils.slot(expansion-header-expanded-state-height, $fallbacks); + } - &[aria-disabled='true'] { - color: token-utils.slot(header-disabled-state-text-color); - } + &[aria-disabled='true'] { + color: token-utils.slot(expansion-header-disabled-state-text-color, $fallbacks); + } - &:not([aria-disabled='true']) { - cursor: pointer; + &:not([aria-disabled='true']) { + cursor: pointer; - .mat-expansion-panel:not(.mat-expanded) &:hover { - background: token-utils.slot(header-hover-state-layer-color); + .mat-expansion-panel:not(.mat-expanded) &:hover { + background: token-utils.slot(expansion-header-hover-state-layer-color, $fallbacks); - // Disable the hover on touch devices since it can appear like it is stuck. We can't use - // `@media (hover)` above, because the desktop support browser support isn't great. - @media (hover: none) { - background: token-utils.slot(container-background-color); - } + // Disable the hover on touch devices since it can appear like it is stuck. We can't use + // `@media (hover)` above, because the desktop support browser support isn't great. + @media (hover: none) { + background: token-utils.slot(expansion-container-background-color, $fallbacks); } + } - // The `.mat-expansion-panel` here is redundant, but we need the additional specificity. - .mat-expansion-panel &.cdk-keyboard-focused, - .mat-expansion-panel &.cdk-program-focused { - background: token-utils.slot(header-focus-state-layer-color); - } + // The `.mat-expansion-panel` here is redundant, but we need the additional specificity. + .mat-expansion-panel &.cdk-keyboard-focused, + .mat-expansion-panel &.cdk-program-focused { + background: token-utils.slot(expansion-header-focus-state-layer-color, $fallbacks); } } @@ -115,9 +112,7 @@ $token-slots: m2-expansion.get-token-slots(); } .mat-expansion-panel-header-title { - @include token-utils.use-tokens($token-prefix, $token-slots) { - color: token-utils.slot(header-text-color); - } + color: token-utils.slot(expansion-header-text-color, $fallbacks); } .mat-expansion-panel-header-title, @@ -141,9 +136,7 @@ $token-slots: m2-expansion.get-token-slots(); .mat-expansion-panel-header-description { flex-grow: 2; - @include token-utils.use-tokens($token-prefix, $token-slots) { - color: token-utils.slot(header-description-color); - } + color: token-utils.slot(expansion-header-description-color, $fallbacks); } // Creates the expansion indicator arrow. Done using ::after @@ -161,16 +154,12 @@ $token-slots: m2-expansion.get-token-slots(); border-style: solid; border-width: 0 2px 2px 0; content: ''; - display: inline-block; padding: 3px; transform: rotate(45deg); vertical-align: middle; - @include token-utils.use-tokens($token-prefix, $token-slots) { - color: token-utils.slot(header-indicator-color); - display: token-utils.slot(legacy-header-indicator-display, - inline-block); - } + color: token-utils.slot(expansion-header-indicator-color, $fallbacks); + display: token-utils.slot(expansion-legacy-header-indicator-display, $fallbacks, inline-block); } svg { @@ -185,10 +174,8 @@ $token-slots: m2-expansion.get-token-slots(); // Ideally we'd make the container `inline-flex`, but that affects M2. vertical-align: middle; - @include token-utils.use-tokens($token-prefix, $token-slots) { - fill: token-utils.slot(header-indicator-color); - display: token-utils.slot(header-indicator-display, none); - } + fill: token-utils.slot(expansion-header-indicator-color, $fallbacks); + display: token-utils.slot(expansion-header-indicator-display, $fallbacks, none); } } diff --git a/src/material/expansion/expansion-panel.scss b/src/material/expansion/expansion-panel.scss index 99f62abbdab1..1ede142a605b 100644 --- a/src/material/expansion/expansion-panel.scss +++ b/src/material/expansion/expansion-panel.scss @@ -1,12 +1,11 @@ @use '@angular/cdk'; -@use './m2-expansion'; +@use './m3-expansion'; @use '../core/tokens/token-utils'; @use '../core/style/variables'; @use '../core/style/elevation'; -$token-prefix: m2-expansion.$prefix; -$token-slots: m2-expansion.get-token-slots(); +$fallbacks: m3-expansion.get-tokens(); .mat-expansion-panel { box-sizing: content-box; @@ -23,29 +22,27 @@ $token-slots: m2-expansion.get-token-slots(); // focus indicator relatively positions the header. position: relative; - @include token-utils.use-tokens($token-prefix, $token-slots) { - background: token-utils.slot(container-background-color); - color: token-utils.slot(container-text-color); - border-radius: token-utils.slot(container-shape); - } + background: token-utils.slot(expansion-container-background-color, $fallbacks); + color: token-utils.slot(expansion-container-text-color, $fallbacks); + border-radius: token-utils.slot(expansion-container-shape, $fallbacks); - @include elevation.overridable-elevation(2); + &:not([class*='#{elevation.$prefix}']) { + box-shadow: token-utils.slot(expansion-container-elevation-shadow, $fallbacks); + } .mat-accordion & { &:not(.mat-expanded), &:not(.mat-expansion-panel-spacing) { border-radius: 0; } - @include token-utils.use-tokens($token-prefix, $token-slots) { - &:first-of-type { - border-top-right-radius: token-utils.slot(container-shape); - border-top-left-radius: token-utils.slot(container-shape); - } + &:first-of-type { + border-top-right-radius: token-utils.slot(expansion-container-shape, $fallbacks); + border-top-left-radius: token-utils.slot(expansion-container-shape, $fallbacks); + } - &:last-of-type { - border-bottom-right-radius: token-utils.slot(container-shape); - border-bottom-left-radius: token-utils.slot(container-shape); - } + &:last-of-type { + border-bottom-right-radius: token-utils.slot(expansion-container-shape, $fallbacks); + border-bottom-left-radius: token-utils.slot(expansion-container-shape, $fallbacks); } } @@ -105,13 +102,11 @@ $token-slots: m2-expansion.get-token-slots(); visibility: visible; } - @include token-utils.use-tokens($token-prefix, $token-slots) { - font-family: token-utils.slot(container-text-font); - font-size: token-utils.slot(container-text-size); - font-weight: token-utils.slot(container-text-weight); - line-height: token-utils.slot(container-text-line-height); - letter-spacing: token-utils.slot(container-text-tracking); - } + font-family: token-utils.slot(expansion-container-text-font, $fallbacks); + font-size: token-utils.slot(expansion-container-text-size, $fallbacks); + font-weight: token-utils.slot(expansion-container-text-weight, $fallbacks); + line-height: token-utils.slot(expansion-container-text-line-height, $fallbacks); + letter-spacing: token-utils.slot(expansion-container-text-tracking, $fallbacks); } .mat-expansion-panel-body { @@ -140,9 +135,7 @@ $token-slots: m2-expansion.get-token-slots(); justify-content: flex-end; padding: 16px 8px 16px 24px; - @include token-utils.use-tokens($token-prefix, $token-slots) { - border-top-color: token-utils.slot(actions-divider-color); - } + border-top-color: token-utils.slot(expansion-actions-divider-color, $fallbacks); .mat-button-base, .mat-mdc-button-base { margin-left: 8px; diff --git a/src/material/form-field/BUILD.bazel b/src/material/form-field/BUILD.bazel index ccfdbc352d11..9b2d6f066816 100644 --- a/src/material/form-field/BUILD.bazel +++ b/src/material/form-field/BUILD.bazel @@ -39,6 +39,7 @@ sass_library( "_form-field-theme.scss", ], deps = [ + ":m2", "//src/material/core/style:sass_utils", "//src/material/core/theming", "//src/material/core/theming:_inspection", diff --git a/src/material/form-field/README.md b/src/material/form-field/README.md index 7a1234123cc2..6f04996ade63 100644 --- a/src/material/form-field/README.md +++ b/src/material/form-field/README.md @@ -1 +1 @@ -Please see the official documentation at https://material.angular.io/components/component/form-field +Please see the official documentation at https://material.angular.dev/components/component/form-field diff --git a/src/material/form-field/_form-field-focus-overlay.scss b/src/material/form-field/_form-field-focus-overlay.scss index 02e0d96a205b..81f1f6ab1c37 100644 --- a/src/material/form-field/_form-field-focus-overlay.scss +++ b/src/material/form-field/_form-field-focus-overlay.scss @@ -1,7 +1,10 @@ -@use './m2-form-field'; +@use './m3-form-field'; @use '../core/tokens/token-utils'; @use '../core/style/layout-common'; +$fallbacks: m3-form-field.get-tokens(); + + // MDC text-field used to use their ripple in order to show a focus and hover effect for // text-fields. This is unnecessary because the ripples bring in a lot of unnecessary // styles and runtime code while the actual goal for the text-field is simply showing a @@ -15,16 +18,14 @@ opacity: 0; pointer-events: none; // Make sure we don't block click on the prefix/suffix. - @include token-utils.use-tokens(m2-form-field.$prefix, m2-form-field.get-token-slots()) { - background-color: token-utils.slot(state-layer-color); + background-color: token-utils.slot(form-field-state-layer-color, $fallbacks); - .mat-mdc-text-field-wrapper:hover & { - opacity: token-utils.slot(hover-state-layer-opacity); - } + .mat-mdc-text-field-wrapper:hover & { + opacity: token-utils.slot(form-field-hover-state-layer-opacity, $fallbacks); + } - .mat-mdc-form-field.mat-focused & { - opacity: token-utils.slot(focus-state-layer-opacity); - } + .mat-mdc-form-field.mat-focused & { + opacity: token-utils.slot(form-field-focus-state-layer-opacity, $fallbacks); } } } diff --git a/src/material/form-field/_form-field-native-select.scss b/src/material/form-field/_form-field-native-select.scss index 16279807959d..f41291505ee5 100644 --- a/src/material/form-field/_form-field-native-select.scss +++ b/src/material/form-field/_form-field-native-select.scss @@ -1,7 +1,9 @@ @use 'sass:math'; -@use './m2-form-field'; +@use './m3-form-field'; @use '../core/tokens/token-utils'; +$fallbacks: m3-form-field.get-tokens(); + // Width of the Material Design form-field select arrow. $mat-form-field-select-arrow-width: 10px; // Height of the Material Design form-field select arrow. @@ -30,14 +32,12 @@ $mat-form-field-select-horizontal-end-padding: $mat-form-field-select-arrow-widt } &:not(.mat-mdc-native-select-inline) { - @include token-utils.use-tokens(m2-form-field.$prefix, m2-form-field.get-token-slots()) { - option { - color: token-utils.slot(select-option-text-color); - } + option { + color: token-utils.slot(form-field-select-option-text-color, $fallbacks); + } - option:disabled { - color: token-utils.slot(select-disabled-option-text-color); - } + option:disabled { + color: token-utils.slot(form-field-select-disabled-option-text-color, $fallbacks); } } } @@ -62,9 +62,7 @@ $mat-form-field-select-horizontal-end-padding: $mat-form-field-select-arrow-widt // Make the arrow non-clickable so the user can click on the form control under it. pointer-events: none; - @include token-utils.use-tokens(m2-form-field.$prefix, m2-form-field.get-token-slots()) { - color: token-utils.slot(enabled-select-arrow-color); - } + color: token-utils.slot(form-field-enabled-select-arrow-color, $fallbacks); [dir='rtl'] & { right: auto; @@ -72,14 +70,12 @@ $mat-form-field-select-horizontal-end-padding: $mat-form-field-select-arrow-widt } } - @include token-utils.use-tokens(m2-form-field.$prefix, m2-form-field.get-token-slots()) { - &.mat-focused .mat-mdc-form-field-infix::after { - color: token-utils.slot(focus-select-arrow-color); - } + &.mat-focused .mat-mdc-form-field-infix::after { + color: token-utils.slot(form-field-focus-select-arrow-color, $fallbacks); + } - &.mat-form-field-disabled .mat-mdc-form-field-infix::after { - color: token-utils.slot(disabled-select-arrow-color); - } + &.mat-form-field-disabled .mat-mdc-form-field-infix::after { + color: token-utils.slot(form-field-disabled-select-arrow-color, $fallbacks); } // Add padding on the end of the native select so that the content does not diff --git a/src/material/form-field/_form-field-subscript.scss b/src/material/form-field/_form-field-subscript.scss index ddd61049ee1e..b3e06145400c 100644 --- a/src/material/form-field/_form-field-subscript.scss +++ b/src/material/form-field/_form-field-subscript.scss @@ -1,7 +1,9 @@ -@use './m2-form-field'; +@use './m3-form-field'; @use '../core/style/vendor-prefixes'; @use '../core/tokens/token-utils'; +$fallbacks: m3-form-field.get-tokens(); + @mixin private-form-field-subscript() { @keyframes _mat-form-field-subscript-animation { from { @@ -71,24 +73,18 @@ // Single error message displayed beneath the form field underline. .mat-mdc-form-field-error { display: block; - - @include token-utils.use-tokens(m2-form-field.$prefix, m2-form-field.get-token-slots()) { - color: token-utils.slot(error-text-color); - } + color: token-utils.slot(form-field-error-text-color, $fallbacks); } // The subscript wrapper has a minimum height to avoid that the form-field // jumps when hints or errors are displayed. .mat-mdc-form-field-subscript-wrapper, .mat-mdc-form-field-bottom-align::before { - @include token-utils.use-tokens( - m2-form-field.$prefix, m2-form-field.get-token-slots()) { - @include vendor-prefixes.smooth-font(); - font-family: token-utils.slot(subscript-text-font); - line-height: token-utils.slot(subscript-text-line-height); - font-size: token-utils.slot(subscript-text-size); - letter-spacing: token-utils.slot(subscript-text-tracking); - font-weight: token-utils.slot(subscript-text-weight); - } + @include vendor-prefixes.smooth-font(); + font-family: token-utils.slot(form-field-subscript-text-font, $fallbacks); + line-height: token-utils.slot(form-field-subscript-text-line-height, $fallbacks); + font-size: token-utils.slot(form-field-subscript-text-size, $fallbacks); + letter-spacing: token-utils.slot(form-field-subscript-text-tracking, $fallbacks); + font-weight: token-utils.slot(form-field-subscript-text-weight, $fallbacks); } } diff --git a/src/material/form-field/_form-field-theme.scss b/src/material/form-field/_form-field-theme.scss index bedb1810841d..fc272690e6f4 100644 --- a/src/material/form-field/_form-field-theme.scss +++ b/src/material/form-field/_form-field-theme.scss @@ -1,99 +1,67 @@ @use './m2-form-field'; @use './m3-form-field'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; @use '../core/typography/typography'; -@use '../core/style/sass-utils'; @use '../core/tokens/token-utils'; +@use 'sass:map'; -/// Outputs base theme styles (styles not dependent on the color, typography, or density settings) -/// for the mat-form-field. -/// @param {Map} $theme The theme to generate base styles for. @mixin base($theme) { + $tokens: map.get(m2-form-field.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-form-field.$prefix, - m2-form-field.get-unthemable-tokens() - ); - } + $tokens: map.get(m3-form-field.get-tokens($theme), base); } + + @include token-utils.values($tokens); } /// Outputs color theme styles for the mat-form-field. /// @param {Map} $theme The theme to generate color styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the form field: primary, secondary, tertiary, -/// or error (If not specified, default primary color will be used). -@mixin color($theme, $options...) { +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin color($theme, $color-variant: null) { + $tokens: map.get(m2-form-field.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color), $options...); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-form-field.$prefix, - m2-form-field.get-color-tokens($theme) - ); - } + $tokens: map.get(m3-form-field.get-tokens($theme, $color-variant), color); + } + @include token-utils.values($tokens); + + @if inspection.get-theme-version($theme) != 1 { .mat-mdc-form-field.mat-accent { - @include token-utils.create-token-values-mixed( - m2-form-field.$prefix, - m2-form-field.private-get-color-palette-color-tokens($theme, accent) - ); + $tokens: m2-form-field.private-get-color-palette-color-tokens($theme, secondary); + @include token-utils.values($tokens); } .mat-mdc-form-field.mat-warn { - @include token-utils.create-token-values-mixed( - m2-form-field.$prefix, - m2-form-field.private-get-color-palette-color-tokens($theme, warn) - ); + $tokens: m2-form-field.private-get-color-palette-color-tokens($theme, error); + @include token-utils.values($tokens); } } } -/// Outputs typography theme styles for the mat-form-field. -/// @param {Map} $theme The theme to generate typography styles for. @mixin typography($theme) { + $tokens: map.get(m2-form-field.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-form-field.$prefix, - m2-form-field.get-typography-tokens($theme) - ); - } + $tokens: map.get(m3-form-field.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } -/// Outputs density theme styles for the mat-form-field. -/// @param {Map} $theme The theme to generate density styles for. @mixin density($theme) { + $tokens: map.get(m2-form-field.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - @include token-utils.create-token-values( - m3-form-field.$prefix, m3-form-field.get-density-tokens($theme)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-form-field.$prefix, - m2-form-field.get-density-tokens($theme) - ); - } + $tokens: map.get(m3-form-field.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { - $form-field-tokens: m2-form-field.get-token-slots(); - @return ( ( - namespace: m2-form-field.$prefix, - tokens: $form-field-tokens, + namespace: form-field, + tokens: token-utils.get-overrides(m3-form-field.get-tokens(), form-field), ), ); } @@ -101,44 +69,28 @@ /// Outputs the CSS variable values for the given tokens. /// @param {Map} $tokens The token values to emit. @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } /// Outputs all (base, color, typography, and density) theme styles for the mat-form-field. /// @param {Map} $theme The theme to generate styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the form field: primary, secondary, tertiary, -/// or error (If not specified, default primary color will be used). -@mixin theme($theme, $options...) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-form-field') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme), $options...); - @if inspection.theme-has($theme, density) { - @include density($theme); - } - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin theme($theme, $color-variant: null) { + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme, $color-variant); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } } } - -@mixin _theme-from-tokens($tokens, $options...) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - $mat-form-field-tokens: token-utils.get-tokens-for( - $tokens, - m2-form-field.$prefix, - $options... - ); - @include token-utils.create-token-values(m2-form-field.$prefix, $mat-form-field-tokens); -} diff --git a/src/material/form-field/_m2-form-field.scss b/src/material/form-field/_m2-form-field.scss index 11b92b0d6cfd..1a3731bd8aee 100644 --- a/src/material/form-field/_m2-form-field.scss +++ b/src/material/form-field/_m2-form-field.scss @@ -1,188 +1,165 @@ @use '../core/m2/palette' as m2-palette; -@use '../core/style/sass-utils'; +@use '../core/tokens/m2-utils'; +@use '../core/tokens/m3-utils'; @use '../core/theming/inspection'; @use '../core/theming/theming'; -@use '../core/tokens/m2-utils'; @use 'sass:color'; @use 'sass:map'; @use 'sass:math'; @use 'sass:meta'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, form-field); - -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - @return ( - filled-active-indicator-height: 1px, - filled-focus-active-indicator-height: 2px, - filled-container-shape: 4px, - outlined-outline-width: 1px, - outlined-focus-outline-width: 2px, - outlined-container-shape: 4px, - ); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - $is-dark: inspection.get-theme-type($theme) == dark; - $surface: inspection.get-theme-color($theme, background, card); - $warn-color: inspection.get-theme-color($theme, warn); - $color-tokens: private-get-color-palette-color-tokens($theme, primary); - $on-surface: if($is-dark, #fff, #000); - - // Ideally we would derive all values directly from the theme, but it causes a lot of regressions - // internally. For now we fall back to the old hardcoded behavior only for internal apps. - $on-surface: if($is-dark, #fff, #000); - $text-color-base: if(m2-utils.$private-is-internal-build, $on-surface, - inspection.get-theme-color($theme, foreground, text, 1)); - $disabled-text-color-base: if(m2-utils.$private-is-internal-build, $on-surface, - inspection.get-theme-color($theme, foreground, disabled-text, 1)); - $divider-base: if(m2-utils.$private-is-internal-build, $on-surface, - inspection.get-theme-color($theme, foreground, divider, 1)); - - @return map.merge($color-tokens, ( - // MDC has a token for the enabled placeholder, but not for the disabled one. - disabled-input-text-placeholder-color: - inspection.get-theme-color($theme, foreground, icon, 0.38), - state-layer-color: inspection.get-theme-color($theme, foreground, base, 0.87), - error-text-color: inspection.get-theme-color($theme, warn), - - // On dark themes we set the native `select` color to some shade of white, - // however the color propagates to all of the `option` elements, which are - // always on a white background inside the dropdown, causing them to blend in. - // Since we can't change background of the dropdown, we need to explicitly - // reset the color of the options to something dark. - select-option-text-color: if($is-dark, m2-palette.$dark-primary-text, inherit), - // Note the spelling of the `GrayText` here which is a system color. See: - // https://developer.mozilla.org/en-US/docs/Web/CSS/system-color - select-disabled-option-text-color: if($is-dark, m2-palette.$dark-disabled-text, GrayText), - - // These tokens are necessary for M3. MDC has them built in already, but: - // 1. They are too specific, breaking a lot of internal clients. - // 2. The larger selectors result in a larger bundle. - // Note: MDC has tokens for all the various states of the icons. Some of them are ommitted, - // because they resolve to the same value (e.g. focus and base states for the leading icon - // are the same). - leading-icon-color: unset, - disabled-leading-icon-color: unset, - trailing-icon-color: unset, - disabled-trailing-icon-color: unset, - error-focus-trailing-icon-color: unset, - error-hover-trailing-icon-color: unset, - error-trailing-icon-color: unset, - enabled-select-arrow-color: inspection.get-theme-color($theme, foreground, icon, 0.54), - disabled-select-arrow-color: inspection.get-theme-color($theme, foreground, icon, 0.38), - hover-state-layer-opacity: if($is-dark, 0.08, 0.04), - focus-state-layer-opacity: if($is-dark, 0.24, 0.08), - filled-container-color: _variable-safe-mix($on-surface, $surface, 4%), - filled-disabled-container-color: _variable-safe-mix($on-surface, $surface, 2%), - filled-label-text-color: sass-utils.safe-color-change($text-color-base, $alpha: 0.6), - filled-hover-label-text-color: sass-utils.safe-color-change($text-color-base, $alpha: 0.6), - filled-disabled-label-text-color: - sass-utils.safe-color-change($disabled-text-color-base, $alpha: 0.38), - filled-input-text-color: sass-utils.safe-color-change($text-color-base, $alpha: 0.87), - filled-disabled-input-text-color: - sass-utils.safe-color-change($disabled-text-color-base, $alpha: 0.38), - filled-input-text-placeholder-color: - sass-utils.safe-color-change($text-color-base, $alpha: 0.6), - filled-error-hover-label-text-color: $warn-color, - filled-error-focus-label-text-color: $warn-color, - filled-error-label-text-color: $warn-color, - filled-error-caret-color: $warn-color, - filled-active-indicator-color: sass-utils.safe-color-change($divider-base, $alpha: 0.42), - filled-disabled-active-indicator-color: - sass-utils.safe-color-change($divider-base, $alpha: 0.06), - filled-hover-active-indicator-color: sass-utils.safe-color-change($divider-base, $alpha: 0.87), - filled-error-active-indicator-color: $warn-color, - filled-error-focus-active-indicator-color: $warn-color, - filled-error-hover-active-indicator-color: $warn-color, - outlined-label-text-color: sass-utils.safe-color-change($text-color-base, $alpha: 0.6), - outlined-hover-label-text-color: sass-utils.safe-color-change($text-color-base, $alpha: 0.6), - outlined-disabled-label-text-color: - sass-utils.safe-color-change($disabled-text-color-base, $alpha: 0.38), - outlined-input-text-color: sass-utils.safe-color-change($text-color-base, $alpha: 0.87), - outlined-disabled-input-text-color: - sass-utils.safe-color-change($disabled-text-color-base, $alpha: 0.38), - outlined-input-text-placeholder-color: - sass-utils.safe-color-change($text-color-base, $alpha: 0.6), - outlined-error-caret-color: $warn-color, - outlined-error-focus-label-text-color: $warn-color, - outlined-error-label-text-color: $warn-color, - outlined-error-hover-label-text-color: $warn-color, - outlined-outline-color: sass-utils.safe-color-change($divider-base, $alpha: 0.38), - outlined-disabled-outline-color: sass-utils.safe-color-change($divider-base, $alpha: 0.06), - outlined-hover-outline-color: sass-utils.safe-color-change($divider-base, $alpha: 0.87), - outlined-error-focus-outline-color: $warn-color, - outlined-error-hover-outline-color: $warn-color, - outlined-error-outline-color: $warn-color, - )); -} +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); + $disabled: m3-utils.color-with-opacity(map.get($system, on-surface), 38%); -@function _variable-safe-mix($first-color, $second-color, $amount) { - @if (meta.type-of($first-color) == color and meta.type-of($second-color) == color) { - @return color.mix($first-color, $second-color, $amount); + // TODO: Use system colors instead of checking theme type + $is-dark: false; + @if (meta.type-of($theme) == map and map.get($theme, color)) { + $is-dark: inspection.get-theme-type($theme) == dark; } - @return $first-color; -} - -// Generates the mapping for the properties that change based on the form field color. -@function private-get-color-palette-color-tokens($theme, $palette-name) { - $palette-color: inspection.get-theme-color($theme, $palette-name); @return ( - focus-select-arrow-color: sass-utils.safe-color-change($palette-color, $alpha: 0.87), - filled-caret-color: $palette-color, - filled-focus-active-indicator-color: $palette-color, - filled-focus-label-text-color: sass-utils.safe-color-change($palette-color, $alpha: 0.87), - outlined-caret-color: $palette-color, - outlined-focus-outline-color: $palette-color, - outlined-focus-label-text-color: sass-utils.safe-color-change($palette-color, $alpha: 0.87), + base: ( + form-field-filled-active-indicator-height: 1px, + form-field-filled-focus-active-indicator-height: 2px, + form-field-filled-container-shape: 4px, + form-field-outlined-outline-width: 1px, + form-field-outlined-focus-outline-width: 2px, + form-field-outlined-container-shape: 4px, + ), + color: map.merge(private-get-color-palette-color-tokens($theme, primary), ( + // MDC has a token for the enabled placeholder, but not for the disabled one. + form-field-disabled-input-text-placeholder-color: $disabled, + form-field-state-layer-color: map.get($system, on-surface), + form-field-error-text-color: map.get($system, error), + + // On dark themes we set the native `select` color to some shade of white, + // however the color propagates to all of the `option` elements, which are + // always on a white background inside the dropdown, causing them to blend in. + // Since we can't change background of the dropdown, we need to explicitly + // reset the color of the options to something dark. + form-field-select-option-text-color: if($is-dark, m2-palette.$dark-primary-text, inherit), + // Note the spelling of the `GrayText` here which is a system color. See: + // https://developer.mozilla.org/en-US/docs/Web/CSS/system-color + form-field-select-disabled-option-text-color: + if($is-dark, m2-palette.$dark-disabled-text, GrayText), + + // These tokens are necessary for M3. MDC has them built in already, but: + // 1. They are too specific, breaking a lot of internal clients. + // 2. The larger selectors result in a larger bundle. + // Note: MDC has tokens for all the various states of the icons. Some of them are ommitted, + // because they resolve to the same value (e.g. focus and base states for the leading icon + // are the same). + form-field-leading-icon-color: unset, + form-field-disabled-leading-icon-color: unset, + form-field-trailing-icon-color: unset, + form-field-disabled-trailing-icon-color: unset, + form-field-error-focus-trailing-icon-color: unset, + form-field-error-hover-trailing-icon-color: unset, + form-field-error-trailing-icon-color: unset, + form-field-enabled-select-arrow-color: map.get($system, on-surface-variant), + form-field-disabled-select-arrow-color: $disabled, + form-field-hover-state-layer-opacity: map.get($system, hover-state-layer-opacity), + form-field-focus-state-layer-opacity: map.get($system, focus-state-layer-opacity), + form-field-filled-container-color: map.get($system, surface-variant), + form-field-filled-disabled-container-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 4%), + form-field-filled-label-text-color: map.get($system, on-surface-variant), + form-field-filled-hover-label-text-color: map.get($system, on-surface-variant), + form-field-filled-disabled-label-text-color: $disabled, + form-field-filled-input-text-color: map.get($system, on-surface), + form-field-filled-disabled-input-text-color: $disabled, + form-field-filled-input-text-placeholder-color: map.get($system, on-surface-variant), + form-field-filled-error-hover-label-text-color: map.get($system, error), + form-field-filled-error-focus-label-text-color: map.get($system, error), + form-field-filled-error-label-text-color: map.get($system, error), + form-field-filled-error-caret-color: map.get($system, error), + form-field-filled-active-indicator-color: map.get($system, on-surface-variant), + form-field-filled-disabled-active-indicator-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 12%), + form-field-filled-hover-active-indicator-color: map.get($system, on-surface), + form-field-filled-error-active-indicator-color: map.get($system, error), + form-field-filled-error-focus-active-indicator-color: map.get($system, error), + form-field-filled-error-hover-active-indicator-color: map.get($system, error), + form-field-outlined-label-text-color: map.get($system, on-surface-variant), + form-field-outlined-hover-label-text-color: map.get($system, on-surface), + form-field-outlined-disabled-label-text-color: $disabled, + form-field-outlined-input-text-color: map.get($system, on-surface), + form-field-outlined-disabled-input-text-color: $disabled, + form-field-outlined-input-text-placeholder-color: map.get($system, on-surface-variant), + form-field-outlined-error-caret-color: map.get($system, error), + form-field-outlined-error-focus-label-text-color: map.get($system, error), + form-field-outlined-error-label-text-color: map.get($system, error), + form-field-outlined-error-hover-label-text-color: map.get($system, error), + form-field-outlined-outline-color: map.get($system, outline-variant), + form-field-outlined-disabled-outline-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 12%), + form-field-outlined-hover-outline-color: map.get($system, on-surface), + form-field-outlined-error-focus-outline-color: map.get($system, error), + form-field-outlined-error-hover-outline-color: map.get($system, error), + form-field-outlined-error-outline-color: map.get($system, error), + )), + typography: ( + // MDC uses `subtitle1` for the input value, placeholder and floating label. The spec + // shows `body1` for text fields though, so we manually override the typography. + // Note: Form controls inherit the typography from the parent form field. + form-field-container-text-font: map.get($system, body-large-font), + form-field-container-text-line-height: map.get($system, body-large-line-height), + form-field-container-text-size: map.get($system, body-large-size), + form-field-container-text-tracking: map.get($system, body-large-tracking), + form-field-container-text-weight: map.get($system, body-large-weight), + + // In container styles, we updated the floating label to use the `body-1` typography level. + // The MDC notched outline overrides this accidentally (only when the label floats) to a + // `rem`-based value. This results in different label widths when floated/docked and then + // breaks the notch width as it has been measured in the docked state (where `body-1` is + // applied). We try to unset these styles set by the `mdc-notched-outline`: + // https://github.com/material-components/material-components-web/blob/master/packages/mdc-notched-outline/_mixins.scss#L272-L292. + // This is why we can't use their `label-text-populated-size` token and we have to declare + // our own version of it. + form-field-outlined-label-text-populated-size: map.get($system, body-large-size), + + form-field-subscript-text-font: map.get($system, body-small-font), + form-field-subscript-text-line-height: map.get($system, body-small-line-height), + form-field-subscript-text-size: map.get($system, body-small-size), + form-field-subscript-text-tracking: map.get($system, body-small-tracking), + form-field-subscript-text-weight: map.get($system, body-small-weight), + form-field-filled-label-text-font: map.get($system, body-large-font), + form-field-filled-label-text-size: map.get($system, body-large-size), + form-field-filled-label-text-tracking: map.get($system, body-large-tracking), + form-field-filled-label-text-weight: map.get($system, body-large-weight), + form-field-outlined-label-text-font: map.get($system, body-large-font), + form-field-outlined-label-text-size: map.get($system, body-large-size), + form-field-outlined-label-text-tracking: map.get($system, body-large-tracking), + form-field-outlined-label-text-weight: map.get($system, body-large-weight), + ), + density: get-density-tokens($theme), ); } -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return ( - // MDC uses `subtitle1` for the input value, placeholder and floating label. The spec - // shows `body1` for text fields though, so we manually override the typography. - // Note: Form controls inherit the typography from the parent form field. - container-text-font: inspection.get-theme-typography($theme, body-1, font-family), - container-text-line-height: inspection.get-theme-typography($theme, body-1, line-height), - container-text-size: inspection.get-theme-typography($theme, body-1, font-size), - container-text-tracking: inspection.get-theme-typography($theme, body-1, letter-spacing), - container-text-weight: inspection.get-theme-typography($theme, body-1, font-weight), - - // In the container styles, we updated the floating label to use the `body-1` typography level. - // The MDC notched outline overrides this accidentally (only when the label floats) to a - // `rem`-based value. This results in different label widths when floated/docked and ultimately - // breaks the notch width as it has been measured in the docked state (where `body-1` is - // applied). We try to unset these styles set by the `mdc-notched-outline`: - // https://github.com/material-components/material-components-web/blob/master/packages/mdc-notched-outline/_mixins.scss#L272-L292. - // This is why we can't use their `label-text-populated-size` token and we have to declare - // our own version of it. - outlined-label-text-populated-size: inspection.get-theme-typography($theme, body-1, font-size), +// Generates the mapping for the properties that change based on the form field color. +@function private-get-color-palette-color-tokens($theme, $color-variant) { + $system: m2-utils.get-system($theme); + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); - subscript-text-font: inspection.get-theme-typography($theme, caption, font-family), - subscript-text-line-height: inspection.get-theme-typography($theme, caption, line-height), - subscript-text-size: inspection.get-theme-typography($theme, caption, font-size), - subscript-text-tracking: inspection.get-theme-typography($theme, caption, letter-spacing), - subscript-text-weight: inspection.get-theme-typography($theme, caption, font-weight), - filled-label-text-font: inspection.get-theme-typography($theme, body-1, font-family), - filled-label-text-size: inspection.get-theme-typography($theme, body-1, font-size), - filled-label-text-tracking: inspection.get-theme-typography($theme, body-1, letter-spacing), - filled-label-text-weight: inspection.get-theme-typography($theme, body-1, font-weight), - outlined-label-text-font: inspection.get-theme-typography($theme, body-1, font-family), - outlined-label-text-size: inspection.get-theme-typography($theme, body-1, font-size), - outlined-label-text-tracking: inspection.get-theme-typography($theme, body-1, letter-spacing), - outlined-label-text-weight: inspection.get-theme-typography($theme, body-1, font-weight), + @return ( + form-field-focus-select-arrow-color: + m3-utils.color-with-opacity(map.get($system, primary), 87%), + form-field-filled-caret-color: map.get($system, primary), + form-field-filled-focus-active-indicator-color: map.get($system, primary), + form-field-filled-focus-label-text-color: + m3-utils.color-with-opacity(map.get($system, primary), 87%), + form-field-outlined-caret-color: map.get($system, primary), + form-field-outlined-focus-outline-color: map.get($system, primary), + form-field-outlined-focus-label-text-color: + m3-utils.color-with-opacity(map.get($system, primary), 87%), ); } // Tokens that can be configured through Angular Material's density theming API. @function get-density-tokens($theme) { - $density-scale: theming.clamp-density(inspection.get-theme-density($theme), -5); + $system: m2-utils.get-system($theme); + $density-scale: theming.clamp-density(map.get($system, density-scale), -5); $size-scale: ( 0: 56px, -1: 52px, @@ -224,23 +201,12 @@ $prefix: (mat, form-field); $vertical-padding: 16px - $vertical-deduction; @return ( - container-height: $height, - filled-label-display: if($hide-label, none, block), - container-vertical-padding: $vertical-padding, - filled-with-label-container-padding-top: + form-field-container-height: $height, + form-field-filled-label-display: if($hide-label, none, block), + form-field-container-vertical-padding: $vertical-padding, + form-field-filled-with-label-container-padding-top: if($hide-label, $vertical-padding, $filled-with-label-padding-top), - filled-with-label-container-padding-bottom: + form-field-filled-with-label-container-padding-bottom: if($hide-label, $vertical-padding, $filled-with-label-padding-bottom), ); } - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) - ); -} diff --git a/src/material/form-field/_m3-form-field.scss b/src/material/form-field/_m3-form-field.scss index 7cc6e124feb2..7d6b1bbfd36b 100644 --- a/src/material/form-field/_m3-form-field.scss +++ b/src/material/form-field/_m3-form-field.scss @@ -1,153 +1,137 @@ @use 'sass:map'; @use 'sass:list'; -@use '../core/style/sass-utils'; @use '../core/tokens/m3-utils'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; +@use '../core/tokens/m3'; // The prefix used to generate the fully qualified name for tokens in this file. $prefix: (mat, form-field); /// Generates custom tokens for the mat-form-field. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-form-field -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: sass-utils.merge-all( - m3-utils.generate-typography-tokens($systems, container-text, body-large), - m3-utils.generate-typography-tokens($systems, subscript-text, body-small), - ( - disabled-input-text-placeholder-color: - sass-utils.safe-color-change(map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - state-layer-color: map.get($systems, md-sys-color, on-surface), - error-text-color: map.get($systems, md-sys-color, error), - select-option-text-color: map.get($systems, md-ref-palette, neutral10), - select-disabled-option-text-color: - sass-utils.safe-color-change(map.get($systems, md-ref-palette, neutral10), $alpha: 0.38), - enabled-select-arrow-color: map.get($systems, md-sys-color, on-surface-variant), - disabled-select-arrow-color: - sass-utils.safe-color-change(map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - hover-state-layer-opacity: map.get($systems, md-sys-state, hover-state-layer-opacity), - focus-state-layer-opacity: m3-utils.hardcode(0, $exclude-hardcoded), - focus-select-arrow-color: map.get($systems, md-sys-color, primary), - leading-icon-color: map.get($systems, md-sys-color, on-surface-variant), - disabled-leading-icon-color: - sass-utils.safe-color-change(map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - trailing-icon-color: map.get($systems, md-sys-color, on-surface-variant), - disabled-trailing-icon-color: - sass-utils.safe-color-change(map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - error-focus-trailing-icon-color: map.get($systems, md-sys-color, error), - error-hover-trailing-icon-color: map.get($systems, md-sys-color, on-error-container), - error-trailing-icon-color: map.get($systems, md-sys-color, error), - filled-active-indicator-color: map.get($systems, md-sys-color, on-surface-variant), - filled-active-indicator-height: if($exclude-hardcoded, null, 1px), - filled-caret-color: map.get($systems, md-sys-color, primary), - filled-container-color: map.get($systems, md-sys-color, surface-variant), - filled-container-shape: map.get($systems, md-sys-shape, corner-extra-small), - filled-disabled-active-indicator-color: - sass-utils.safe-color-change(map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - filled-disabled-container-color: - sass-utils.safe-color-change(map.get($systems, md-sys-color, on-surface), $alpha: 0.04), - filled-disabled-input-text-color: - sass-utils.safe-color-change(map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - filled-disabled-label-text-color: - sass-utils.safe-color-change(map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - filled-error-active-indicator-color: map.get($systems, md-sys-color, error), - filled-error-focus-active-indicator-color: map.get($systems, md-sys-color, error), - filled-error-focus-label-text-color: map.get($systems, md-sys-color, error), - filled-error-hover-active-indicator-color: - map.get($systems, md-sys-color, on-error-container), - filled-error-hover-label-text-color: map.get($systems, md-sys-color, on-error-container), - filled-error-label-text-color: map.get($systems, md-sys-color, error), - filled-focus-active-indicator-color: map.get($systems, md-sys-color, primary), - filled-focus-active-indicator-height: if($exclude-hardcoded, null, 2px), - filled-focus-label-text-color: map.get($systems, md-sys-color, primary), - filled-hover-active-indicator-color: map.get($systems, md-sys-color, on-surface), - filled-hover-label-text-color: map.get($systems, md-sys-color, on-surface-variant), - filled-input-text-color: map.get($systems, md-sys-color, on-surface), - filled-input-text-placeholder-color: map.get($systems, md-sys-color, on-surface-variant), - filled-label-text-color: map.get($systems, md-sys-color, on-surface-variant), - filled-label-text-font: map.get($systems, md-sys-typescale, body-large-font), - filled-label-text-size: map.get($systems, md-sys-typescale, body-large-size), - filled-label-text-tracking: map.get($systems, md-sys-typescale, body-large-tracking), - filled-label-text-weight: map.get($systems, md-sys-typescale, body-large-weight), - outlined-caret-color: map.get($systems, md-sys-color, primary), - outlined-container-shape: map.get($systems, md-sys-shape, corner-extra-small), - outlined-disabled-input-text-color: - sass-utils.safe-color-change(map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - outlined-disabled-label-text-color: - sass-utils.safe-color-change(map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - outlined-disabled-outline-color: - sass-utils.safe-color-change(map.get($systems, md-sys-color, on-surface), $alpha: 0.12), - outlined-error-focus-label-text-color: map.get($systems, md-sys-color, error), - outlined-error-focus-outline-color: map.get($systems, md-sys-color, error), - outlined-error-hover-label-text-color: map.get($systems, md-sys-color, on-error-container), - outlined-error-hover-outline-color: map.get($systems, md-sys-color, on-error-container), - outlined-error-label-text-color: map.get($systems, md-sys-color, error), - outlined-error-outline-color: map.get($systems, md-sys-color, error), - outlined-focus-label-text-color: map.get($systems, md-sys-color, primary), - outlined-focus-outline-color: map.get($systems, md-sys-color, primary), - outlined-focus-outline-width: if($exclude-hardcoded, null, 2px), - outlined-hover-label-text-color: map.get($systems, md-sys-color, on-surface), - outlined-hover-outline-color: map.get($systems, md-sys-color, on-surface), - outlined-input-text-color: map.get($systems, md-sys-color, on-surface), - outlined-input-text-placeholder-color: map.get($systems, md-sys-color, on-surface-variant), - outlined-label-text-color: map.get($systems, md-sys-color, on-surface-variant), - outlined-label-text-font: map.get($systems, md-sys-typescale, body-large-font), - outlined-label-text-size: map.get($systems, md-sys-typescale, body-large-size), - outlined-label-text-tracking: map.get($systems, md-sys-typescale, body-large-tracking), - outlined-label-text-weight: map.get($systems, md-sys-typescale, body-large-weight), - outlined-outline-color: map.get($systems, md-sys-color, outline), - outlined-outline-width: if($exclude-hardcoded, null, 1px), - ) - ); +/// @param {String} $color-variant The color variant to use for the component +@function get-tokens($theme: m3.$sys-theme, $color-variant: null) { + $system: m3-utils.get-system($theme); + @if $color-variant { + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); + } - $variant-tokens: ( - // Color variants: - primary: (), // Default, no overrides needed. - secondary: ( - focus-select-arrow-color: map.get($systems, md-sys-color, secondary), - filled-caret-color: map.get($systems, md-sys-color, secondary), - filled-focus-active-indicator-color: map.get($systems, md-sys-color, secondary), - filled-focus-label-text-color: map.get($systems, md-sys-color, secondary), - outlined-caret-color: map.get($systems, md-sys-color, secondary), - outlined-focus-label-text-color: map.get($systems, md-sys-color, secondary), - outlined-focus-outline-color: map.get($systems, md-sys-color, secondary), + @return ( + base: ( + form-field-filled-active-indicator-height: 1px, + form-field-filled-focus-active-indicator-height: 2px, + form-field-filled-container-shape: map.get($system, corner-extra-small), + form-field-outlined-outline-width: 1px, + form-field-outlined-focus-outline-width: 2px, + form-field-outlined-container-shape: map.get($system, corner-extra-small), ), - tertiary: ( - focus-select-arrow-color: map.get($systems, md-sys-color, tertiary), - filled-caret-color: map.get($systems, md-sys-color, tertiary), - filled-focus-active-indicator-color: map.get($systems, md-sys-color, tertiary), - filled-focus-label-text-color: map.get($systems, md-sys-color, tertiary), - outlined-caret-color: map.get($systems, md-sys-color, tertiary), - outlined-focus-label-text-color: map.get($systems, md-sys-color, tertiary), - outlined-focus-outline-color: map.get($systems, md-sys-color, tertiary), + color: ( + form-field-disabled-input-text-placeholder-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + form-field-disabled-leading-icon-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + form-field-disabled-select-arrow-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + form-field-disabled-trailing-icon-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + form-field-enabled-select-arrow-color: map.get($system, on-surface-variant), + form-field-error-focus-trailing-icon-color: map.get($system, error), + form-field-error-hover-trailing-icon-color: map.get($system, on-error-container), + form-field-error-text-color: map.get($system, error), + form-field-error-trailing-icon-color: map.get($system, error), + form-field-filled-active-indicator-color: map.get($system, on-surface-variant), + form-field-filled-caret-color: map.get($system, primary), + form-field-filled-container-color: map.get($system, surface-variant), + form-field-filled-disabled-active-indicator-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + form-field-filled-disabled-container-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 4%), + form-field-filled-disabled-input-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + form-field-filled-disabled-label-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + form-field-filled-error-active-indicator-color: map.get($system, error), + form-field-filled-error-caret-color: map.get($system, error), + form-field-filled-error-focus-active-indicator-color: map.get($system, error), + form-field-filled-error-focus-label-text-color: map.get($system, error), + form-field-filled-error-hover-active-indicator-color: map.get($system, on-error-container), + form-field-filled-error-hover-label-text-color: map.get($system, on-error-container), + form-field-filled-error-label-text-color: map.get($system, error), + form-field-filled-focus-active-indicator-color: map.get($system, primary), + form-field-filled-focus-label-text-color: map.get($system, primary), + form-field-filled-hover-active-indicator-color: map.get($system, on-surface), + form-field-filled-hover-label-text-color: map.get($system, on-surface-variant), + form-field-filled-input-text-color: map.get($system, on-surface), + form-field-filled-input-text-placeholder-color: map.get($system, on-surface-variant), + form-field-filled-label-text-color: map.get($system, on-surface-variant), + form-field-focus-select-arrow-color: map.get($system, primary), + form-field-focus-state-layer-opacity: 0, + form-field-hover-state-layer-opacity: map.get($system, hover-state-layer-opacity), + form-field-leading-icon-color: map.get($system, on-surface-variant), + form-field-outlined-caret-color: map.get($system, primary), + form-field-outlined-disabled-input-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + form-field-outlined-disabled-label-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + form-field-outlined-disabled-outline-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 12%), + form-field-outlined-error-caret-color: map.get($system, error), + form-field-outlined-error-focus-label-text-color: map.get($system, error), + form-field-outlined-error-focus-outline-color: map.get($system, error), + form-field-outlined-error-hover-label-text-color: map.get($system, on-error-container), + form-field-outlined-error-hover-outline-color: map.get($system, on-error-container), + form-field-outlined-error-label-text-color: map.get($system, error), + form-field-outlined-error-outline-color: map.get($system, error), + form-field-outlined-focus-label-text-color: map.get($system, primary), + form-field-outlined-focus-outline-color: map.get($system, primary), + form-field-outlined-hover-label-text-color: map.get($system, on-surface), + form-field-outlined-hover-outline-color: map.get($system, on-surface), + form-field-outlined-input-text-color: map.get($system, on-surface), + form-field-outlined-input-text-placeholder-color: map.get($system, on-surface-variant), + form-field-outlined-label-text-color: map.get($system, on-surface-variant), + form-field-outlined-outline-color: map.get($system, outline), + form-field-select-disabled-option-text-color: + m3-utils.color-with-opacity(map.get($system, neutral10), 38%), + form-field-select-option-text-color: map.get($system, neutral10), + form-field-state-layer-color: map.get($system, on-surface), + form-field-trailing-icon-color: map.get($system, on-surface-variant), ), - error: ( - focus-select-arrow-color: map.get($systems, md-sys-color, error), - filled-caret-color: map.get($systems, md-sys-color, error), - filled-focus-active-indicator-color: map.get($systems, md-sys-color, error), - filled-focus-label-text-color: map.get($systems, md-sys-color, error), - outlined-caret-color: map.get($systems, md-sys-color, error), - outlined-focus-label-text-color: map.get($systems, md-sys-color, error), - outlined-focus-outline-color: map.get($systems, md-sys-color, error), - ) + typography: ( + form-field-container-text-font: map.get($system, body-large-font), + form-field-container-text-line-height: map.get($system, body-large-line-height), + form-field-container-text-size: map.get($system, body-large-size), + form-field-container-text-tracking: map.get($system, body-large-tracking), + form-field-container-text-weight: map.get($system, body-large-weight), + form-field-subscript-text-font: map.get($system, body-small-font), + form-field-subscript-text-line-height: map.get($system, body-small-line-height), + form-field-subscript-text-size: map.get($system, body-small-size), + form-field-subscript-text-tracking: map.get($system, body-small-tracking), + form-field-subscript-text-weight: map.get($system, body-small-weight), + form-field-outlined-label-text-font: map.get($system, body-large-font), + form-field-outlined-label-text-size: map.get($system, body-large-size), + form-field-outlined-label-text-tracking: map.get($system, body-large-tracking), + form-field-outlined-label-text-weight: map.get($system, body-large-weight), + form-field-filled-label-text-font: map.get($system, body-large-font), + form-field-filled-label-text-size: map.get($system, body-large-size), + form-field-filled-label-text-tracking: map.get($system, body-large-tracking), + form-field-filled-label-text-weight: map.get($system, body-large-weight), + form-field-outlined-label-text-populated-size: null, + ), + density: get-density-tokens(map.get($system, density-scale)), ); - - @return m3-utils.namespace($prefix, ($tokens, $variant-tokens), $token-slots); } // Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $scale: theming.clamp-density(inspection.get-theme-density($theme), -5); +@function get-density-tokens($scale) { + $scale: theming.clamp-density($scale, -5); $index: ($scale * -1) + 1; @return ( - container-height: list.nth((56px, 52px, 48px, 44px, 40px, 36px), $index), - filled-label-display: list.nth((block, block, none, none, none, none), $index), - container-vertical-padding: list.nth((16px, 14px, 12px, 10px, 8px, 6px), $index), - filled-with-label-container-padding-top: list.nth((24px, 22px, 12px, 10px, 8px, 6px), $index), - filled-with-label-container-padding-bottom: list.nth((8px, 6px, 12px, 10px, 8px, 6px), $index), + form-field-container-height: list.nth((56px, 52px, 48px, 44px, 40px, 36px), $index), + form-field-filled-label-display: list.nth((block, block, none, none, none, none), $index), + form-field-container-vertical-padding: list.nth((16px, 14px, 12px, 10px, 8px, 6px), $index), + form-field-filled-with-label-container-padding-top: + list.nth((24px, 22px, 12px, 10px, 8px, 6px), $index), + form-field-filled-with-label-container-padding-bottom: + list.nth((8px, 6px, 12px, 10px, 8px, 6px), $index), ); } diff --git a/src/material/form-field/_mdc-text-field-density-overrides.scss b/src/material/form-field/_mdc-text-field-density-overrides.scss index 248c159d5245..efc14d0e19b6 100644 --- a/src/material/form-field/_mdc-text-field-density-overrides.scss +++ b/src/material/form-field/_mdc-text-field-density-overrides.scss @@ -1,6 +1,8 @@ -@use './m2-form-field'; +@use './m3-form-field'; @use '../core/tokens/token-utils'; +$fallbacks: m3-form-field.get-tokens(); + // Mixin that includes the density styles for form fields. MDC provides their own density // styles for MDC text-field which we cannot use. MDC relies on input elements to stretch // vertically when the height is reduced as per density scale. This doesn't work for our @@ -9,49 +11,48 @@ // specification. In order to support density, we need to adjust the vertical spacing to be // based on the density scale. @mixin private-text-field-density-overrides() { - @include token-utils.use-tokens(m2-form-field.$prefix, m2-form-field.get-token-slots()) { - $height: token-utils.slot(container-height); - - .mat-mdc-form-field-infix { - // We add a minimum height to the infix container to ensure that custom controls have the - // same default vertical space as text-field inputs (with respect to the vertical padding). - min-height: #{$height}; - - padding-top: token-utils.slot(filled-with-label-container-padding-top); - padding-bottom: token-utils.slot(filled-with-label-container-padding-bottom); - - .mdc-text-field--outlined &, - .mdc-text-field--no-label & { - padding-top: token-utils.slot(container-vertical-padding); - padding-bottom: token-utils.slot(container-vertical-padding); - } - } + $height: token-utils.slot(form-field-container-height, $fallbacks); - // By default, MDC aligns the label using percentage. This will be overwritten based - // on whether a textarea is used. This is not possible in our implementation of the - // form-field because we do not know what type of form-field control is set up. Hence - // we always use a fixed position for the label. This does not have any implications. - .mat-mdc-text-field-wrapper .mat-mdc-form-field-flex .mat-mdc-floating-label { - top: calc(#{$height} / 2); - } + .mat-mdc-form-field-infix { + // We add a minimum height to the infix container to ensure that custom controls have the + // same default vertical space as text-field inputs (with respect to the vertical padding). + min-height: #{$height}; - // We need to conditionally hide the floating label based on the height of the form field. - .mdc-text-field--filled .mat-mdc-floating-label { - display: token-utils.slot(filled-label-display, $fallback: block); - } + padding-top: token-utils.slot(form-field-filled-with-label-container-padding-top, $fallbacks); + padding-bottom: token-utils.slot( + form-field-filled-with-label-container-padding-bottom, $fallbacks); - // For the outline appearance, we re-create the active floating label transform. This is - // necessary because the transform for docked floating labels can be updated to account for - // the width of prefix container. - .mat-mdc-text-field-wrapper.mdc-text-field--outlined .mdc-notched-outline--upgraded - .mdc-floating-label--float-above { - // Needs to be in a string form to work around an internal check that incorrectly flags this - // interpolation in `calc` as unnecessary. If we don't have it, Sass won't evaluate it. - $offset: 'calc(6.75px + #{$height} / 2)'; - $translate: 'calc(#{$offset} * -1)'; - --mat-mdc-form-field-label-transform: translateY(#{$translate}) - scale(var(--mat-mdc-form-field-floating-label-scale, 0.75)); - transform: var(--mat-mdc-form-field-label-transform); + .mdc-text-field--outlined &, + .mdc-text-field--no-label & { + padding-top: token-utils.slot(form-field-container-vertical-padding, $fallbacks); + padding-bottom: token-utils.slot(form-field-container-vertical-padding, $fallbacks); } } + + // By default, MDC aligns the label using percentage. This will be overwritten based + // on whether a textarea is used. This is not possible in our implementation of the + // form-field because we do not know what type of form-field control is set up. Hence + // we always use a fixed position for the label. This does not have any implications. + .mat-mdc-text-field-wrapper .mat-mdc-form-field-flex .mat-mdc-floating-label { + top: calc(#{$height} / 2); + } + + // We need to conditionally hide the floating label based on the height of the form field. + .mdc-text-field--filled .mat-mdc-floating-label { + display: token-utils.slot(form-field-filled-label-display, $fallbacks, $fallback: block); + } + + // For the outline appearance, we re-create the active floating label transform. This is + // necessary because the transform for docked floating labels can be updated to account for + // the width of prefix container. + .mat-mdc-text-field-wrapper.mdc-text-field--outlined .mdc-notched-outline--upgraded + .mdc-floating-label--float-above { + // Needs to be in a string form to work around an internal check that incorrectly flags this + // interpolation in `calc` as unnecessary. If we don't have it, Sass won't evaluate it. + $offset: 'calc(6.75px + #{$height} / 2)'; + $translate: 'calc(#{$offset} * -1)'; + --mat-mdc-form-field-label-transform: translateY(#{$translate}) + scale(var(--mat-mdc-form-field-floating-label-scale, 0.75)); + transform: var(--mat-mdc-form-field-label-transform); + } } diff --git a/src/material/form-field/_mdc-text-field-structure-overrides.scss b/src/material/form-field/_mdc-text-field-structure-overrides.scss index 15202fb913b5..e3df7f0db167 100644 --- a/src/material/form-field/_mdc-text-field-structure-overrides.scss +++ b/src/material/form-field/_mdc-text-field-structure-overrides.scss @@ -1,7 +1,9 @@ -@use './m2-form-field'; +@use './m3-form-field'; @use '../core/tokens/token-utils'; @use '../core/style/vendor-prefixes'; +$fallbacks: m3-form-field.get-tokens(); + // TODO(b/263527625): should be removed when this is addressed on the MDC side. // MDC sets a will-change on this element, because of the animation. This can cause // scrolling performance degradation on pages with a lot of form fields so we reset it. @@ -116,9 +118,7 @@ $_enable-form-field-will-change-reset: true; // is disabled, but now there's no token for it so we need to implement it ourselves. .mat-form-field-disabled .mdc-text-field__input { @include vendor-prefixes.input-placeholder { - @include token-utils.use-tokens(m2-form-field.$prefix, m2-form-field.get-token-slots()) { - color: token-utils.slot(disabled-input-text-placeholder-color); - } + color: token-utils.slot(form-field-disabled-input-text-placeholder-color, $fallbacks); } } diff --git a/src/material/form-field/_mdc-text-field-structure.scss b/src/material/form-field/_mdc-text-field-structure.scss index 181e99a45d4c..f9b63d6cca94 100644 --- a/src/material/form-field/_mdc-text-field-structure.scss +++ b/src/material/form-field/_mdc-text-field-structure.scss @@ -1,568 +1,566 @@ @use '@angular/cdk'; @use '../core/style/vendor-prefixes'; @use '../core/tokens/token-utils'; -@use './m2-form-field'; +@use './m3-form-field'; // File contains some extra long selectors due to MDC's old styling // stylelint-disable max-line-length -$token-prefix: m2-form-field.$prefix; -$token-slots: m2-form-field.get-token-slots(); +$fallbacks: m3-form-field.get-tokens(); // Includes the structural styles for the form field inherited from MDC. @mixin private-text-field-structure { - @include token-utils.use-tokens($token-prefix, $token-slots) { - .mdc-text-field { - display: inline-flex; - align-items: baseline; - padding: 0 16px; - position: relative; - box-sizing: border-box; - overflow: hidden; - will-change: opacity, transform, color; - - // TODO(crisbeto): The filled form field overrides these while the outlined doesn't. - // The correct thing to do would be to remove them from here and have the one based on the - // token in the outlined appearance. We keep them as is for now to avoid screenshot diffs. - border-top-left-radius: 4px; - border-top-right-radius: 4px; - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; + .mdc-text-field { + display: inline-flex; + align-items: baseline; + padding: 0 16px; + position: relative; + box-sizing: border-box; + overflow: hidden; + will-change: opacity, transform, color; + + // TODO(crisbeto): The filled form field overrides these while the outlined doesn't. + // The correct thing to do would be to remove them from here and have the one based on the + // token in the outlined appearance. We keep them as is for now to avoid screenshot diffs. + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + + .mdc-text-field__input { + width: 100%; + min-width: 0; + border: none; + border-radius: 0; + background: none; + padding: 0; + -moz-appearance: none; + -webkit-appearance: none; + + // TODO(crisbeto): this height gets overwritten eventually, but there are some internal + // tests that depend on this being here in weird ways so we're keeping it around for now. + height: 28px; + + // Note that while this style and the `-ms-clear` are identical, we can't combine + // them because if one of them isn't supported, it'll invalidate the whole rule. + &::-webkit-calendar-picker-indicator, + &::-webkit-search-cancel-button { + display: none; } - .mdc-text-field__input { - width: 100%; - min-width: 0; - border: none; - border-radius: 0; - background: none; - padding: 0; - -moz-appearance: none; - -webkit-appearance: none; - - // TODO(crisbeto): this height gets overwritten eventually, but there are some internal - // tests that depend on this being here in weird ways so we're keeping it around for now. - height: 28px; - - // Note that while this style and the `-ms-clear` are identical, we can't combine - // them because if one of them isn't supported, it'll invalidate the whole rule. - &::-webkit-calendar-picker-indicator { - display: none; - } + &::-ms-clear { + display: none; + } - &::-ms-clear { - display: none; - } + &:focus { + outline: none; + } - &:focus { - outline: none; - } + &:invalid { + box-shadow: none; + } + + @include vendor-prefixes.input-placeholder { + opacity: 0; + } - &:invalid { - box-shadow: none; + .mdc-text-field--no-label &, + .mdc-text-field--focused & { + @include vendor-prefixes.input-placeholder { + opacity: 1; } + } + .mdc-text-field--disabled:not(.mdc-text-field--no-label) + &.mat-mdc-input-disabled-interactive { @include vendor-prefixes.input-placeholder { opacity: 0; } + } - .mdc-text-field--no-label &, - .mdc-text-field--focused & { - @include vendor-prefixes.input-placeholder { - opacity: 1; - } - } + .mdc-text-field--outlined &, + .mdc-text-field--filled.mdc-text-field--no-label & { + height: 100%; + } - .mdc-text-field--disabled:not(.mdc-text-field--no-label) - &.mat-mdc-input-disabled-interactive { - @include vendor-prefixes.input-placeholder { - opacity: 0; - } - } + .mdc-text-field--outlined & { + display: flex; + border: none !important; + background-color: transparent; + } - .mdc-text-field--outlined &, - .mdc-text-field--filled.mdc-text-field--no-label & { - height: 100%; - } + .mdc-text-field--disabled & { + pointer-events: auto; + } - .mdc-text-field--outlined & { - display: flex; - border: none !important; - background-color: transparent; - } + .mdc-text-field--filled:not(.mdc-text-field--disabled) & { + color: token-utils.slot(form-field-filled-input-text-color, $fallbacks); + caret-color: token-utils.slot(form-field-filled-caret-color, $fallbacks); - .mdc-text-field--disabled & { - pointer-events: auto; + @include vendor-prefixes.input-placeholder { + color: token-utils.slot(form-field-filled-input-text-placeholder-color, $fallbacks); } + } - .mdc-text-field--filled:not(.mdc-text-field--disabled) & { - color: token-utils.slot(filled-input-text-color); - caret-color: token-utils.slot(filled-caret-color); + .mdc-text-field--outlined:not(.mdc-text-field--disabled) & { + color: token-utils.slot(form-field-outlined-input-text-color, $fallbacks); + caret-color: token-utils.slot(form-field-outlined-caret-color, $fallbacks); - @include vendor-prefixes.input-placeholder { - color: token-utils.slot(filled-input-text-placeholder-color); - } + @include vendor-prefixes.input-placeholder { + color: token-utils.slot(form-field-outlined-input-text-placeholder-color, $fallbacks); } + } - .mdc-text-field--outlined:not(.mdc-text-field--disabled) & { - color: token-utils.slot(outlined-input-text-color); - caret-color: token-utils.slot(outlined-caret-color); + .mdc-text-field--filled.mdc-text-field--invalid:not(.mdc-text-field--disabled) & { + caret-color: token-utils.slot(form-field-filled-error-caret-color, $fallbacks); + } - @include vendor-prefixes.input-placeholder { - color: token-utils.slot(outlined-input-text-placeholder-color); - } - } + .mdc-text-field--outlined.mdc-text-field--invalid:not(.mdc-text-field--disabled) & { + caret-color: token-utils.slot(form-field-outlined-error-caret-color, $fallbacks); + } - .mdc-text-field--filled.mdc-text-field--invalid:not(.mdc-text-field--disabled) & { - caret-color: token-utils.slot(filled-error-caret-color); - } + .mdc-text-field--filled.mdc-text-field--disabled & { + color: token-utils.slot(form-field-filled-disabled-input-text-color, $fallbacks); + } - .mdc-text-field--outlined.mdc-text-field--invalid:not(.mdc-text-field--disabled) & { - caret-color: token-utils.slot(outlined-error-caret-color); - } + .mdc-text-field--outlined.mdc-text-field--disabled & { + color: token-utils.slot(form-field-outlined-disabled-input-text-color, $fallbacks); + } - .mdc-text-field--filled.mdc-text-field--disabled & { - color: token-utils.slot(filled-disabled-input-text-color); + @include cdk.high-contrast { + .mdc-text-field--disabled & { + background-color: Window; } + } + } - .mdc-text-field--outlined.mdc-text-field--disabled & { - color: token-utils.slot(outlined-disabled-input-text-color); - } + .mdc-text-field--filled { + height: 56px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + border-top-left-radius: token-utils.slot(form-field-filled-container-shape, $fallbacks); + border-top-right-radius: token-utils.slot(form-field-filled-container-shape, $fallbacks); - @include cdk.high-contrast { - .mdc-text-field--disabled & { - background-color: Window; - } - } + &:not(.mdc-text-field--disabled) { + background-color: token-utils.slot(form-field-filled-container-color, $fallbacks); } - .mdc-text-field--filled { - height: 56px; - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; - border-top-left-radius: token-utils.slot(filled-container-shape); - border-top-right-radius: token-utils.slot(filled-container-shape); + &.mdc-text-field--disabled { + background-color: token-utils.slot(form-field-filled-disabled-container-color, $fallbacks); + } + } - &:not(.mdc-text-field--disabled) { - background-color: token-utils.slot(filled-container-color); - } + .mdc-text-field--outlined { + height: 56px; + overflow: visible; + $shape-var: token-utils.slot(form-field-outlined-container-shape, $fallbacks); + padding-right: max(16px, #{$shape-var}); + padding-left: max(16px, calc(#{$shape-var} + 4px)); - &.mdc-text-field--disabled { - background-color: token-utils.slot(filled-disabled-container-color); - } + [dir='rtl'] & { + padding-right: max(16px, calc(#{$shape-var} + 4px)); + padding-left: max(16px, #{$shape-var}); } + } - .mdc-text-field--outlined { - height: 56px; - overflow: visible; - $shape-var: token-utils.slot(outlined-container-shape); - padding-right: max(16px, #{$shape-var}); - padding-left: max(16px, calc(#{$shape-var} + 4px)); - - [dir='rtl'] & { - padding-right: max(16px, calc(#{$shape-var} + 4px)); - padding-left: max(16px, #{$shape-var}); - } + .mdc-floating-label { + position: absolute; + left: 0; + transform-origin: left top; + line-height: 1.15rem; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + cursor: text; + overflow: hidden; + will-change: transform; + + [dir='rtl'] & { + right: 0; + left: auto; + transform-origin: right top; + text-align: right; } - .mdc-floating-label { - position: absolute; - left: 0; - transform-origin: left top; - line-height: 1.15rem; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; - cursor: text; - overflow: hidden; - will-change: transform; + .mdc-text-field & { + top: 50%; + transform: translateY(-50%); + pointer-events: none; + } - [dir='rtl'] & { - right: 0; - left: auto; - transform-origin: right top; - text-align: right; - } + .mdc-notched-outline & { + display: inline-block; + position: relative; + max-width: 100%; + } - .mdc-text-field & { - top: 50%; - transform: translateY(-50%); - pointer-events: none; - } + .mdc-text-field--outlined & { + left: 4px; + right: auto; + } - .mdc-notched-outline & { - display: inline-block; - position: relative; - max-width: 100%; - } + [dir='rtl'] .mdc-text-field--outlined & { + left: auto; + right: 4px; + } - .mdc-text-field--outlined & { - left: 4px; - right: auto; - } + .mdc-text-field--filled & { + left: 16px; + right: auto; + } - [dir='rtl'] .mdc-text-field--outlined & { - left: auto; - right: 4px; - } + [dir='rtl'] .mdc-text-field--filled & { + left: auto; + right: 16px; + } - .mdc-text-field--filled & { - left: 16px; - right: auto; - } + .mdc-text-field--disabled & { + cursor: default; - [dir='rtl'] .mdc-text-field--filled & { - left: auto; - right: 16px; + @include cdk.high-contrast { + z-index: 1; } + } - .mdc-text-field--disabled & { - cursor: default; + .mdc-text-field--filled.mdc-text-field--no-label & { + display: none; + } - @include cdk.high-contrast { - z-index: 1; - } - } + .mdc-text-field--filled:not(.mdc-text-field--disabled) & { + color: token-utils.slot(form-field-filled-label-text-color, $fallbacks); + } - .mdc-text-field--filled.mdc-text-field--no-label & { - display: none; - } + .mdc-text-field--filled:not(.mdc-text-field--disabled).mdc-text-field--focused & { + color: token-utils.slot(form-field-filled-focus-label-text-color, $fallbacks); + } - .mdc-text-field--filled:not(.mdc-text-field--disabled) & { - color: token-utils.slot(filled-label-text-color); - } + .mdc-text-field--filled:not(.mdc-text-field--disabled):not(.mdc-text-field--focused):hover & { + color: token-utils.slot(form-field-filled-hover-label-text-color, $fallbacks); + } - .mdc-text-field--filled:not(.mdc-text-field--disabled).mdc-text-field--focused & { - color: token-utils.slot(filled-focus-label-text-color); - } + .mdc-text-field--filled.mdc-text-field--disabled & { + color: token-utils.slot(form-field-filled-disabled-label-text-color, $fallbacks); + } - .mdc-text-field--filled:not(.mdc-text-field--disabled):not(.mdc-text-field--focused):hover & { - color: token-utils.slot(filled-hover-label-text-color); - } + .mdc-text-field--filled:not(.mdc-text-field--disabled).mdc-text-field--invalid & { + color: token-utils.slot(form-field-filled-error-label-text-color, $fallbacks); + } - .mdc-text-field--filled.mdc-text-field--disabled & { - color: token-utils.slot(filled-disabled-label-text-color); - } + .mdc-text-field--filled:not(.mdc-text-field--disabled).mdc-text-field--invalid.mdc-text-field--focused & { + color: token-utils.slot(form-field-filled-error-focus-label-text-color, $fallbacks); + } - .mdc-text-field--filled:not(.mdc-text-field--disabled).mdc-text-field--invalid & { - color: token-utils.slot(filled-error-label-text-color); - } + .mdc-text-field--filled:not(.mdc-text-field--disabled).mdc-text-field--invalid:not(.mdc-text-field--disabled):hover & { + color: token-utils.slot(form-field-filled-error-hover-label-text-color, $fallbacks); + } - .mdc-text-field--filled:not(.mdc-text-field--disabled).mdc-text-field--invalid.mdc-text-field--focused & { - color: token-utils.slot(filled-error-focus-label-text-color); - } + .mdc-text-field--filled & { + font-family: token-utils.slot(form-field-filled-label-text-font, $fallbacks); + font-size: token-utils.slot(form-field-filled-label-text-size, $fallbacks); + font-weight: token-utils.slot(form-field-filled-label-text-weight, $fallbacks); + letter-spacing: token-utils.slot(form-field-filled-label-text-tracking, $fallbacks); + } - .mdc-text-field--filled:not(.mdc-text-field--disabled).mdc-text-field--invalid:not(.mdc-text-field--disabled):hover & { - color: token-utils.slot(filled-error-hover-label-text-color); - } + .mdc-text-field--outlined:not(.mdc-text-field--disabled) & { + color: token-utils.slot(form-field-outlined-label-text-color, $fallbacks); + } - .mdc-text-field--filled & { - font-family: token-utils.slot(filled-label-text-font); - font-size: token-utils.slot(filled-label-text-size); - font-weight: token-utils.slot(filled-label-text-weight); - letter-spacing: token-utils.slot(filled-label-text-tracking); - } + .mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--focused & { + color: token-utils.slot(form-field-outlined-focus-label-text-color, $fallbacks); + } - .mdc-text-field--outlined:not(.mdc-text-field--disabled) & { - color: token-utils.slot(outlined-label-text-color); - } + .mdc-text-field--outlined:not(.mdc-text-field--disabled):not(.mdc-text-field--focused):hover & { + color: token-utils.slot(form-field-outlined-hover-label-text-color, $fallbacks); + } - .mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--focused & { - color: token-utils.slot(outlined-focus-label-text-color); - } + .mdc-text-field--outlined.mdc-text-field--disabled & { + color: token-utils.slot(form-field-outlined-disabled-label-text-color, $fallbacks); + } - .mdc-text-field--outlined:not(.mdc-text-field--disabled):not(.mdc-text-field--focused):hover & { - color: token-utils.slot(outlined-hover-label-text-color); - } + .mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--invalid & { + color: token-utils.slot(form-field-outlined-error-label-text-color, $fallbacks); + } - .mdc-text-field--outlined.mdc-text-field--disabled & { - color: token-utils.slot(outlined-disabled-label-text-color); - } + .mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--invalid.mdc-text-field--focused & { + color: token-utils.slot(form-field-outlined-error-focus-label-text-color, $fallbacks); + } - .mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--invalid & { - color: token-utils.slot(outlined-error-label-text-color); - } + .mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--invalid:not(.mdc-text-field--disabled):hover & { + color: token-utils.slot(form-field-outlined-error-hover-label-text-color, $fallbacks); + } - .mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--invalid.mdc-text-field--focused & { - color: token-utils.slot(outlined-error-focus-label-text-color); - } + .mdc-text-field--outlined & { + font-family: token-utils.slot(form-field-outlined-label-text-font, $fallbacks); + font-size: token-utils.slot(form-field-outlined-label-text-size, $fallbacks); + font-weight: token-utils.slot(form-field-outlined-label-text-weight, $fallbacks); + letter-spacing: token-utils.slot(form-field-outlined-label-text-tracking, $fallbacks); + } + } - .mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--invalid:not(.mdc-text-field--disabled):hover & { - color: token-utils.slot(outlined-error-hover-label-text-color); - } + .mdc-floating-label--float-above { + cursor: auto; + transform: translateY(-106%) scale(0.75); - .mdc-text-field--outlined & { - font-family: token-utils.slot(outlined-label-text-font); - font-size: token-utils.slot(outlined-label-text-size); - font-weight: token-utils.slot(outlined-label-text-weight); - letter-spacing: token-utils.slot(outlined-label-text-tracking); - } + .mdc-text-field--filled & { + transform: translateY(-106%) scale(0.75); } - .mdc-floating-label--float-above { - cursor: auto; - transform: translateY(-106%) scale(0.75); + .mdc-text-field--outlined & { + transform: translateY(-37.25px) scale(1); + font-size: 0.75rem; + } - .mdc-text-field--filled & { - transform: translateY(-106%) scale(0.75); - } + .mdc-notched-outline & { + text-overflow: clip; + } - .mdc-text-field--outlined & { - transform: translateY(-37.25px) scale(1); - font-size: 0.75rem; - } + .mdc-notched-outline--upgraded & { + max-width: 133.3333333333%; + } - .mdc-notched-outline & { - text-overflow: clip; - } + .mdc-text-field--outlined.mdc-notched-outline--upgraded &, + .mdc-text-field--outlined .mdc-notched-outline--upgraded & { + transform: translateY(-34.75px) scale(0.75); + } - .mdc-notched-outline--upgraded & { - max-width: 133.3333333333%; - } + .mdc-text-field--outlined.mdc-notched-outline--upgraded &, + .mdc-text-field--outlined .mdc-notched-outline--upgraded & { + font-size: 1rem; + } + } - .mdc-text-field--outlined.mdc-notched-outline--upgraded &, - .mdc-text-field--outlined .mdc-notched-outline--upgraded & { - transform: translateY(-34.75px) scale(0.75); - } + .mdc-floating-label--required { + &:not(.mdc-floating-label--hide-required-marker)::after { + margin-left: 1px; + margin-right: 0; + content: '*'; - .mdc-text-field--outlined.mdc-notched-outline--upgraded &, - .mdc-text-field--outlined .mdc-notched-outline--upgraded & { - font-size: 1rem; + [dir='rtl'] & { + margin-left: 0; + margin-right: 1px; } } + } - .mdc-floating-label--required { - &:not(.mdc-floating-label--hide-required-marker)::after { - margin-left: 1px; - margin-right: 0; - content: '*'; - - [dir='rtl'] & { - margin-left: 0; - margin-right: 1px; - } - } + .mdc-notched-outline { + display: flex; + position: absolute; + top: 0; + right: 0; + left: 0; + box-sizing: border-box; + width: 100%; + max-width: 100%; + height: 100%; + text-align: left; + pointer-events: none; + + [dir='rtl'] & { + text-align: right; } - .mdc-notched-outline { - display: flex; - position: absolute; - top: 0; - right: 0; - left: 0; - box-sizing: border-box; - width: 100%; - max-width: 100%; - height: 100%; - text-align: left; - pointer-events: none; + .mdc-text-field--outlined & { + z-index: 1; + } + } - [dir='rtl'] & { - text-align: right; - } + .mat-mdc-notch-piece { + box-sizing: border-box; + height: 100%; + pointer-events: none; + border-top: 1px solid; + border-bottom: 1px solid; - .mdc-text-field--outlined & { - z-index: 1; - } + .mdc-text-field--focused & { + border-width: 2px; } - .mat-mdc-notch-piece { - box-sizing: border-box; - height: 100%; - pointer-events: none; - border-top: 1px solid; - border-bottom: 1px solid; + // Moved out into variables because the selectors we inherited were too long. + $enabled-selector: '.mdc-text-field--outlined:not(.mdc-text-field--disabled)'; + $hover-selector: ':not(.mdc-text-field--focused):hover'; - .mdc-text-field--focused & { - border-width: 2px; - } + #{$enabled-selector} & { + border-color: token-utils.slot(form-field-outlined-outline-color, $fallbacks); + border-width: token-utils.slot(form-field-outlined-outline-width, $fallbacks); + } - // Moved out into variables because the selectors we inherited were too long. - $enabled-selector: '.mdc-text-field--outlined:not(.mdc-text-field--disabled)'; - $hover-selector: ':not(.mdc-text-field--focused):hover'; + #{$enabled-selector}#{$hover-selector} & { + border-color: token-utils.slot(form-field-outlined-hover-outline-color, $fallbacks); + } - #{$enabled-selector} & { - border-color: token-utils.slot(outlined-outline-color); - border-width: token-utils.slot(outlined-outline-width); - } + #{$enabled-selector}.mdc-text-field--focused & { + border-color: token-utils.slot(form-field-outlined-focus-outline-color, $fallbacks); + } - #{$enabled-selector}#{$hover-selector} & { - border-color: token-utils.slot(outlined-hover-outline-color); - } + .mdc-text-field--outlined.mdc-text-field--disabled & { + border-color: token-utils.slot(form-field-outlined-disabled-outline-color, $fallbacks); + } - #{$enabled-selector}.mdc-text-field--focused & { - border-color: token-utils.slot(outlined-focus-outline-color); - } + #{$enabled-selector}.mdc-text-field--invalid & { + border-color: token-utils.slot(form-field-outlined-error-outline-color, $fallbacks); + } - .mdc-text-field--outlined.mdc-text-field--disabled & { - border-color: token-utils.slot(outlined-disabled-outline-color); - } + #{$enabled-selector}.mdc-text-field--invalid#{$hover-selector} .mdc-notched-outline & { + border-color: token-utils.slot(form-field-outlined-error-hover-outline-color, $fallbacks); + } - #{$enabled-selector}.mdc-text-field--invalid & { - border-color: token-utils.slot(outlined-error-outline-color); - } + #{$enabled-selector}.mdc-text-field--invalid.mdc-text-field--focused & { + border-color: token-utils.slot(form-field-outlined-error-focus-outline-color, $fallbacks); + } - #{$enabled-selector}.mdc-text-field--invalid#{$hover-selector} .mdc-notched-outline & { - border-color: token-utils.slot(outlined-error-hover-outline-color); - } + #{$enabled-selector}.mdc-text-field--focused .mdc-notched-outline & { + border-width: token-utils.slot(form-field-outlined-focus-outline-width, $fallbacks); + } + } - #{$enabled-selector}.mdc-text-field--invalid.mdc-text-field--focused & { - border-color: token-utils.slot(outlined-error-focus-outline-color); - } + .mdc-notched-outline__leading { + border-left: 1px solid; + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-top-left-radius: token-utils.slot(form-field-outlined-container-shape, $fallbacks); + border-bottom-left-radius: token-utils.slot(form-field-outlined-container-shape, $fallbacks); + + .mdc-text-field--outlined .mdc-notched-outline & { + $shape-var: token-utils.slot(form-field-outlined-container-shape, $fallbacks); + width: max(12px, #{$shape-var}); + } - #{$enabled-selector}.mdc-text-field--focused .mdc-notched-outline & { - border-width: token-utils.slot(outlined-focus-outline-width); - } + [dir='rtl'] & { + border-left: none; + border-right: 1px solid; + border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-top-right-radius: token-utils.slot(form-field-outlined-container-shape, $fallbacks); + border-bottom-right-radius: token-utils.slot(form-field-outlined-container-shape, $fallbacks); } + } - .mdc-notched-outline__leading { + .mdc-notched-outline__trailing { + flex-grow: 1; + border-left: none; + border-right: 1px solid; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: token-utils.slot(form-field-outlined-container-shape, $fallbacks); + border-bottom-right-radius: token-utils.slot(form-field-outlined-container-shape, $fallbacks); + + [dir='rtl'] & { border-left: 1px solid; border-right: none; border-top-right-radius: 0; border-bottom-right-radius: 0; - border-top-left-radius: token-utils.slot(outlined-container-shape); - border-bottom-left-radius: token-utils.slot(outlined-container-shape); + border-top-left-radius: token-utils.slot(form-field-outlined-container-shape, $fallbacks); + border-bottom-left-radius: token-utils.slot(form-field-outlined-container-shape, $fallbacks); + } + } - .mdc-text-field--outlined .mdc-notched-outline & { - $shape-var: token-utils.slot(outlined-container-shape); - width: max(12px, #{$shape-var}); - } + .mdc-notched-outline__notch { + $min-value: 'calc(100% - max(12px, #{token-utils.slot(form-field-outlined-container-shape, $fallbacks)}) * 2)'; + flex: 0 0 auto; + width: auto; - [dir='rtl'] & { - border-left: none; - border-right: 1px solid; - border-bottom-left-radius: 0; - border-top-left-radius: 0; - border-top-right-radius: token-utils.slot(outlined-container-shape); - border-bottom-right-radius: token-utils.slot(outlined-container-shape); - } + .mdc-text-field--outlined .mdc-notched-outline & { + max-width: min(var(--mat-form-field-notch-max-width, 100%), #{$min-value}); } - .mdc-notched-outline__trailing { - flex-grow: 1; - border-left: none; - border-right: 1px solid; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - border-top-right-radius: token-utils.slot(outlined-container-shape); - border-bottom-right-radius: token-utils.slot(outlined-container-shape); + .mdc-text-field--outlined .mdc-notched-outline--notched & { + max-width: min(100%, #{$min-value}); + } - [dir='rtl'] & { - border-left: 1px solid; - border-right: none; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - border-top-left-radius: token-utils.slot(outlined-container-shape); - border-bottom-left-radius: token-utils.slot(outlined-container-shape); - } + .mdc-text-field--outlined .mdc-notched-outline--notched & { + padding-top: 1px; } - .mdc-notched-outline__notch { - $min-value: 'calc(100% - max(12px, #{token-utils.slot(outlined-container-shape)}) * 2)'; - flex: 0 0 auto; - width: auto; + .mdc-text-field--focused.mdc-text-field--outlined .mdc-notched-outline--notched & { + padding-top: 2px; + } - .mdc-text-field--outlined .mdc-notched-outline & { - max-width: min(var(--mat-form-field-notch-max-width, 100%), #{$min-value}); - } + .mdc-notched-outline--notched & { + padding-left: 0; + padding-right: 8px; + border-top: none; + } - .mdc-text-field--outlined .mdc-notched-outline--notched & { - max-width: min(100%, #{$min-value}); - } + [dir='rtl'] .mdc-notched-outline--notched & { + padding-left: 8px; + padding-right: 0; + } - .mdc-text-field--outlined .mdc-notched-outline--notched & { - padding-top: 1px; - } + .mdc-notched-outline--no-label & { + display: none; + } + } - .mdc-text-field--focused.mdc-text-field--outlined .mdc-notched-outline--notched & { - padding-top: 2px; - } + .mdc-line-ripple { + &::before, + &::after { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + border-bottom-style: solid; + content: ''; + } - .mdc-notched-outline--notched & { - padding-left: 0; - padding-right: 8px; - border-top: none; - } + &::before { + z-index: 1; + border-bottom-width: token-utils.slot(form-field-filled-active-indicator-height, $fallbacks); - [dir='rtl'] .mdc-notched-outline--notched & { - padding-left: 8px; - padding-right: 0; - } + $enabled-field: '.mdc-text-field--filled:not(.mdc-text-field--disabled)'; - .mdc-notched-outline--no-label & { - display: none; + #{$enabled-field} & { + border-bottom-color: token-utils.slot(form-field-filled-active-indicator-color, $fallbacks); } - } - .mdc-line-ripple { - &::before, - &::after { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - border-bottom-style: solid; - content: ''; + #{$enabled-field}:not(.mdc-text-field--focused):hover & { + border-bottom-color: token-utils.slot(form-field-filled-hover-active-indicator-color, $fallbacks); } - &::before { - z-index: 1; - border-bottom-width: token-utils.slot(filled-active-indicator-height); - - $enabled-field: '.mdc-text-field--filled:not(.mdc-text-field--disabled)'; - - #{$enabled-field} & { - border-bottom-color: token-utils.slot(filled-active-indicator-color); - } - - #{$enabled-field}:not(.mdc-text-field--focused):hover & { - border-bottom-color: token-utils.slot(filled-hover-active-indicator-color); - } - - .mdc-text-field--filled.mdc-text-field--disabled & { - border-bottom-color: token-utils.slot(filled-disabled-active-indicator-color); - } + .mdc-text-field--filled.mdc-text-field--disabled & { + border-bottom-color: token-utils.slot(form-field-filled-disabled-active-indicator-color, $fallbacks); + } - #{$enabled-field}.mdc-text-field--invalid & { - border-bottom-color: token-utils.slot(filled-error-active-indicator-color); - } + #{$enabled-field}.mdc-text-field--invalid & { + border-bottom-color: token-utils.slot(form-field-filled-error-active-indicator-color, $fallbacks); + } - #{$enabled-field}.mdc-text-field--invalid:not(.mdc-text-field--focused):hover & { - border-bottom-color: token-utils.slot(filled-error-hover-active-indicator-color); - } + #{$enabled-field}.mdc-text-field--invalid:not(.mdc-text-field--focused):hover & { + border-bottom-color: token-utils.slot(form-field-filled-error-hover-active-indicator-color, $fallbacks); } + } - &::after { - transform: scaleX(0); - opacity: 0; - z-index: 2; + &::after { + transform: scaleX(0); + opacity: 0; + z-index: 2; - .mdc-text-field--filled & { - border-bottom-width: token-utils.slot(filled-focus-active-indicator-height); - } + .mdc-text-field--filled & { + border-bottom-width: token-utils.slot(form-field-filled-focus-active-indicator-height, $fallbacks); + } - .mdc-text-field--filled:not(.mdc-text-field--disabled) & { - border-bottom-color: token-utils.slot(filled-focus-active-indicator-color); - } + .mdc-text-field--filled:not(.mdc-text-field--disabled) & { + border-bottom-color: token-utils.slot(form-field-filled-focus-active-indicator-color, $fallbacks); + } - .mdc-text-field--filled.mdc-text-field--invalid:not(.mdc-text-field--disabled) & { - border-bottom-color: token-utils.slot(filled-error-focus-active-indicator-color); - } + .mdc-text-field--filled.mdc-text-field--invalid:not(.mdc-text-field--disabled) & { + border-bottom-color: token-utils.slot(form-field-filled-error-focus-active-indicator-color, $fallbacks); } } + } - .mdc-line-ripple--active::after { - transform: scaleX(1); - opacity: 1; - } + .mdc-line-ripple--active::after { + transform: scaleX(1); + opacity: 1; + } - .mdc-line-ripple--deactivating::after { - opacity: 0; - } + .mdc-line-ripple--deactivating::after { + opacity: 0; + } - .mdc-text-field--disabled { - pointer-events: none; - } + .mdc-text-field--disabled { + pointer-events: none; } } diff --git a/src/material/form-field/form-field.md b/src/material/form-field/form-field.md index fb5e1335d1dd..3a0aa7a00f74 100644 --- a/src/material/form-field/form-field.md +++ b/src/material/form-field/form-field.md @@ -8,10 +8,10 @@ In this document, "form field" refers to the wrapper component ` The following Angular Material components are designed to work inside a ``: -- [`` & ` `, - standalone: false, + imports: [MatInputModule], }) class MatInputTextareaWithBindings { rows: number = 4; @@ -2025,7 +2017,7 @@ class MatInputTextareaWithBindings { @Component({ template: ``, - standalone: false, + imports: [MatInputModule], }) class MatInputMissingMatInputTestController {} @@ -2041,7 +2033,7 @@ class MatInputMissingMatInputTestController {} `, - standalone: false, + imports: [MatInputModule, ReactiveFormsModule, FormsModule], }) class MatInputWithFormErrorMessages { @ViewChild('form') form: NgForm; @@ -2064,7 +2056,7 @@ class MatInputWithFormErrorMessages { `, - standalone: false, + imports: [MatInputModule, ReactiveFormsModule], }) class InputInFormGroup { formGroup = new FormGroup({ @@ -2088,7 +2080,7 @@ class InputInFormGroup { `, - standalone: false, + imports: [MatInputModule, ReactiveFormsModule], }) class MatInputWithFormGroupErrorMessages { @ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective; @@ -2107,7 +2099,7 @@ class MatInputWithFormGroupErrorMessages { favorite `, - standalone: false, + imports: [MatInputModule], }) class MatInputWithPrefixAndSuffix {} @@ -2119,7 +2111,7 @@ class MatInputWithPrefixAndSuffix {} } `, - standalone: false, + imports: [MatInputModule], }) class MatInputWithNgIf { renderInput = true; @@ -2133,7 +2125,7 @@ class MatInputWithNgIf { `, - standalone: false, + imports: [MatInputModule, ReactiveFormsModule], }) class MatInputOnPush { formControl = new FormControl(''); @@ -2146,7 +2138,7 @@ class MatInputOnPush { `, - standalone: false, + imports: [MatInputModule], }) class MatInputWithLabel {} @@ -2157,7 +2149,7 @@ class MatInputWithLabel {} `, - standalone: false, + imports: [MatInputModule], }) class MatInputWithLabelAndPlaceholder { floatLabel: FloatLabelType; @@ -2171,7 +2163,7 @@ class MatInputWithLabelAndPlaceholder { `, - standalone: false, + imports: [MatInputModule], }) class MatInputWithAppearance { @ViewChild(MatFormField) formField: MatFormField; @@ -2185,7 +2177,7 @@ class MatInputWithAppearance { `, - standalone: false, + imports: [MatInputModule], }) class MatInputWithSubscriptSizing { @ViewChild(MatFormField) formField: MatFormField; @@ -2198,7 +2190,7 @@ class MatInputWithSubscriptSizing { `, - standalone: false, + imports: [MatInputModule], }) class MatInputWithoutPlaceholder {} @@ -2213,7 +2205,7 @@ class MatInputWithoutPlaceholder {} `, - standalone: false, + imports: [MatInputModule], }) class MatInputSelect { disabled: boolean; @@ -2231,7 +2223,7 @@ class MatInputSelect { `, - standalone: false, + imports: [MatInputModule], }) class MatInputSelectWithNoLabelNoValue {} @@ -2246,7 +2238,7 @@ class MatInputSelectWithNoLabelNoValue {} `, - standalone: false, + imports: [MatInputModule], }) class MatInputSelectWithLabel {} @@ -2261,29 +2253,10 @@ class MatInputSelectWithLabel {} `, - standalone: false, + imports: [MatInputModule], }) class MatInputSelectWithInnerHtml {} -@Component({ - template: ` - - - `, - standalone: false, -}) -class MatInputWithCustomAccessor {} - -@Component({ - template: ` - - - `, - standalone: false, -}) -class MatInputSelectWithoutOptions {} - /** Custom component that never has a value. Used for testing the `MAT_INPUT_VALUE_ACCESSOR`. */ @Directive({ selector: 'input[customInputAccessor]', @@ -2293,7 +2266,6 @@ class MatInputSelectWithoutOptions {} useExisting: CustomMatInputAccessor, }, ], - standalone: false, }) class CustomMatInputAccessor { get value() { @@ -2303,12 +2275,31 @@ class CustomMatInputAccessor { private _value = null; } +@Component({ + template: ` + + + `, + imports: [MatInputModule, CustomMatInputAccessor], +}) +class MatInputWithCustomAccessor {} + +@Component({ + template: ` + + + `, + imports: [MatInputModule], +}) +class MatInputSelectWithoutOptions {} + @Component({ template: ` `, - standalone: false, + imports: [MatInputModule], }) class MatInputWithColor { color: ThemePalette; @@ -2322,7 +2313,7 @@ class MatInputWithColor { `, - standalone: false, + imports: [MatInputModule], }) class MatInputInsideOutsideFormField {} @@ -2332,7 +2323,7 @@ class MatInputInsideOutsideFormField {} Hello `, - standalone: false, + imports: [MatInputModule, ReactiveFormsModule], }) class MatInputWithRequiredFormControl { formControl = new FormControl('', [Validators.required]); @@ -2344,7 +2335,7 @@ class MatInputWithRequiredFormControl { `, - standalone: false, + imports: [MatInputModule], }) class MatInputSimple {} @@ -2358,7 +2349,7 @@ class MatInputSimple {} icon-suffix `, - standalone: false, + imports: [MatInputModule], }) class InputWithNgContainerPrefixAndSuffix {} @@ -2368,7 +2359,7 @@ class InputWithNgContainerPrefixAndSuffix {} Hello `, - standalone: false, + imports: [MatInputModule, ReactiveFormsModule], }) class MatInputWithRequiredAssignableFormControl { formControl = new FormControl('', [Validators.required]); diff --git a/src/material/input/input.ts b/src/material/input/input.ts index b286d3aad979..5b45db32da37 100644 --- a/src/material/input/input.ts +++ b/src/material/input/input.ts @@ -214,7 +214,6 @@ export class MatInput return this._type; } set type(value: string) { - const prevType = this._type; this._type = value || 'text'; this._validateType(); @@ -224,10 +223,6 @@ export class MatInput if (!this._isTextarea && getSupportedInputTypes().has(this._type)) { (this._elementRef.nativeElement as HTMLInputElement).type = this._type; } - - if (this._type !== prevType) { - this._ensureWheelDefaultBehavior(); - } } protected _type = 'text'; @@ -610,33 +605,6 @@ export class MatInput } }; - private _webkitBlinkWheelListener = (): void => { - // This is a noop function and is used to enable mouse wheel input - // on number inputs - // on blink and webkit browsers. - }; - - /** - * In blink and webkit browsers a focused number input does not increment or decrement its value - * on mouse wheel interaction unless a wheel event listener is attached to it or one of its - * ancestors or a passive wheel listener is attached somewhere in the DOM. For example: Hitting - * a tooltip once enables the mouse wheel input for all number inputs as long as it exists. In - * order to get reliable and intuitive behavior we apply a wheel event on our own thus making - * sure increment and decrement by mouse wheel works every time. - * @docs-private - */ - private _ensureWheelDefaultBehavior(): void { - this._cleanupWebkitWheel?.(); - - if (this._type === 'number' && (this._platform.BLINK || this._platform.WEBKIT)) { - this._cleanupWebkitWheel = this._renderer.listen( - this._elementRef.nativeElement, - 'wheel', - this._webkitBlinkWheelListener, - ); - } - } - /** Gets the value to set on the `readonly` attribute. */ protected _getReadonlyAttribute(): string | null { if (this._isNativeSelect) { diff --git a/src/material/input/testing/input-harness-filters.ts b/src/material/input/testing/input-harness-filters.ts index c68b0d626708..1635877e6ae9 100644 --- a/src/material/input/testing/input-harness-filters.ts +++ b/src/material/input/testing/input-harness-filters.ts @@ -6,10 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -import {BaseHarnessFilters} from '@angular/cdk/testing'; +import {MatFormFieldControlHarnessFilters} from '@angular/material/form-field/testing/control'; /** A set of criteria that can be used to filter a list of `MatInputHarness` instances. */ -export interface InputHarnessFilters extends BaseHarnessFilters { +export interface InputHarnessFilters extends MatFormFieldControlHarnessFilters { /** Filters based on the value of the input. */ value?: string | RegExp; /** Filters based on the placeholder text of the input. */ diff --git a/src/material/input/testing/input-harness.spec.ts b/src/material/input/testing/input-harness.spec.ts index e4d95ee6f4de..3d93514de9fa 100644 --- a/src/material/input/testing/input-harness.spec.ts +++ b/src/material/input/testing/input-harness.spec.ts @@ -18,8 +18,7 @@ describe('MatInputHarness', () => { }); it('should load all input harnesses', async () => { - const inputs = await loader.getAllHarnesses(MatInputHarness); - expect(inputs.length).toBe(7); + expect(await loader.countHarnesses(MatInputHarness)).toBe(7); }); it('should load input with specific id', async () => { @@ -39,6 +38,11 @@ describe('MatInputHarness', () => { expect(inputs.length).toBe(1); }); + it('should load input with a specific label', async () => { + const inputs = await loader.getAllHarnesses(MatInputHarness.with({label: 'Favorite food'})); + expect(inputs.length).toBe(1); + }); + it('should load input with a value that matches a regex', async () => { const inputs = await loader.getAllHarnesses(MatInputHarness.with({value: /shi$/})); expect(inputs.length).toBe(1); @@ -231,6 +235,7 @@ describe('MatInputHarness', () => { @Component({ template: ` + Favorite food diff --git a/src/material/input/testing/input-harness.ts b/src/material/input/testing/input-harness.ts index 61e087f01cbb..c88101a32ad9 100644 --- a/src/material/input/testing/input-harness.ts +++ b/src/material/input/testing/input-harness.ts @@ -7,12 +7,12 @@ */ import {HarnessPredicate, parallel} from '@angular/cdk/testing'; -import {MatFormFieldControlHarness} from '../../form-field/testing/control'; +import {MatFormFieldControlHarnessBase} from '@angular/material/form-field/testing/control'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {InputHarnessFilters} from './input-harness-filters'; /** Harness for interacting with a standard Material inputs in tests. */ -export class MatInputHarness extends MatFormFieldControlHarness { +export class MatInputHarness extends MatFormFieldControlHarnessBase { // TODO: We do not want to handle `select` elements with `matNativeControl` because // not all methods of this harness work reasonably for native select elements. // For more details. See: https://github.com/angular/components/pull/18221. @@ -31,6 +31,9 @@ export class MatInputHarness extends MatFormFieldControlHarness { }) .addOption('placeholder', options.placeholder, (harness, placeholder) => { return HarnessPredicate.stringMatches(harness.getPlaceholder(), placeholder); + }) + .addOption('label', options.label, (harness, label) => { + return HarnessPredicate.stringMatches(harness.getLabel(), label); }); } diff --git a/src/material/input/testing/native-select-harness-filters.ts b/src/material/input/testing/native-select-harness-filters.ts index 438549ff0cb4..e44566192d32 100644 --- a/src/material/input/testing/native-select-harness-filters.ts +++ b/src/material/input/testing/native-select-harness-filters.ts @@ -7,9 +7,10 @@ */ import {BaseHarnessFilters} from '@angular/cdk/testing'; +import {MatFormFieldControlHarnessFilters} from '@angular/material/form-field/testing/control'; /** A set of criteria that can be used to filter a list of `MatNativeSelectHarness` instances. */ -export interface NativeSelectHarnessFilters extends BaseHarnessFilters {} +export interface NativeSelectHarnessFilters extends MatFormFieldControlHarnessFilters {} /** A set of criteria that can be used to filter a list of `MatNativeOptionHarness` instances. */ export interface NativeOptionHarnessFilters extends BaseHarnessFilters { diff --git a/src/material/input/testing/native-select-harness.spec.ts b/src/material/input/testing/native-select-harness.spec.ts index 856b3c2739ef..a3492f0e2460 100644 --- a/src/material/input/testing/native-select-harness.spec.ts +++ b/src/material/input/testing/native-select-harness.spec.ts @@ -21,6 +21,13 @@ describe('MatNativeSelectHarness', () => { expect(selects.length).toBe(2); }); + it('should load select with a specific label', async () => { + const inputs = await loader.getAllHarnesses( + MatNativeSelectHarness.with({label: 'Favorite food'}), + ); + expect(inputs.length).toBe(1); + }); + it('should get the id of a select', async () => { const selects = await loader.getAllHarnesses(MatNativeSelectHarness); expect(await parallel(() => selects.map(select => select.getId()))).toEqual(['food', 'drink']); @@ -187,6 +194,7 @@ describe('MatNativeSelectHarness', () => { @Component({ template: ` + Favorite food .mdc-radio__native-control:focus:enabled:not(:checked) { - & ~ .mdc-radio__background > .mdc-radio__outer-circle { - border-color: token-utils.slot(unselected-focus-icon-color); - } + .mdc-radio > .mdc-radio__native-control:focus:enabled:not(:checked) { + & ~ .mdc-radio__background > .mdc-radio__outer-circle { + border-color: token-utils.slot(radio-unselected-focus-icon-color, $fallbacks); } } @@ -96,10 +91,7 @@ $token-slots: m2-radio.get-token-slots(); height: 48px; width: 48px; transform: translate(-50%, -50%); - - @include token-utils.use-tokens($token-prefix, $token-slots) { - display: token-utils.slot(touch-target-display); - } + display: token-utils.slot(radio-touch-target-display, $fallbacks); [dir='rtl'] & { left: auto; diff --git a/src/material/radio/radio.ts b/src/material/radio/radio.ts index 18f71768b6a0..7a545cdd0f33 100644 --- a/src/material/radio/radio.ts +++ b/src/material/radio/radio.ts @@ -79,10 +79,10 @@ export const MAT_RADIO_GROUP = new InjectionToken('MatRadioGroup' export interface MatRadioDefaultOptions { /** * Theme color of the radio button. This API is supported in M2 themes only, it - * has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/radio/styling. + * has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/radio/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ color: ThemePalette; @@ -174,10 +174,10 @@ export class MatRadioGroup implements AfterContentInit, OnDestroy, ControlValueA /** * Theme color of the radio buttons in the group. This API is supported in M2 - * themes only, it has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/radio/styling. + * themes only, it has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/radio/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() color: ThemePalette; @@ -523,15 +523,18 @@ export class MatRadioButton implements OnInit, AfterViewInit, DoCheck, OnDestroy return this._required || (this.radioGroup && this.radioGroup.required); } set required(value: boolean) { + if (value !== this._required) { + this._changeDetector.markForCheck(); + } this._required = value; } /** * Theme color of the radio button. This API is supported in M2 themes only, it - * has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/radio/styling. + * has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/radio/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() get color(): ThemePalette { diff --git a/src/material/schematics/ng-add/schema.json b/src/material/schematics/ng-add/schema.json index 3d4fd270d9a8..7017356a89ff 100644 --- a/src/material/schematics/ng-add/schema.json +++ b/src/material/schematics/ng-add/schema.json @@ -21,19 +21,19 @@ "items": [ { "value": "azure-blue", - "label": "Azure/Blue [Preview: https://material.angular.io?theme=azure-blue]" + "label": "Azure/Blue [Preview: https://material.angular.dev?theme=azure-blue]" }, { "value": "rose-red", - "label": "Rose/Red [Preview: https://material.angular.io?theme=rose-red]" + "label": "Rose/Red [Preview: https://material.angular.dev?theme=rose-red]" }, { "value": "magenta-violet", - "label": "Magenta/Violet [Preview: https://material.angular.io?theme=magenta-violet]" + "label": "Magenta/Violet [Preview: https://material.angular.dev?theme=magenta-violet]" }, { "value": "cyan-orange", - "label": "Cyan/Orange [Preview: https://material.angular.io?theme=cyan-orange]" + "label": "Cyan/Orange [Preview: https://material.angular.dev?theme=cyan-orange]" }, {"value": "custom", "label": "Custom"} ] diff --git a/src/material/schematics/ng-add/theming/create-custom-theme.ts b/src/material/schematics/ng-add/theming/create-custom-theme.ts index b75880360b06..d490edef7b90 100644 --- a/src/material/schematics/ng-add/theming/create-custom-theme.ts +++ b/src/material/schematics/ng-add/theming/create-custom-theme.ts @@ -10,7 +10,7 @@ export function createCustomTheme(name: string = 'app') { return ` // Custom Theming for Angular Material -// For more information: https://material.angular.io/guide/theming +// For more information: https://material.angular.dev/guide/theming @use '@angular/material' as mat; html { diff --git a/src/material/schematics/ng-generate/address-form/index.spec.ts b/src/material/schematics/ng-generate/address-form/index.spec.ts index f0c5e08b723d..3df760bb565a 100644 --- a/src/material/schematics/ng-generate/address-form/index.spec.ts +++ b/src/material/schematics/ng-generate/address-form/index.spec.ts @@ -9,6 +9,7 @@ describe('Material address-form schematic', () => { const baseOptions: Schema = { name: 'foo', project: 'material', + module: 'app-module.ts', }; beforeEach(() => { @@ -25,7 +26,7 @@ describe('Material address-form schematic', () => { expect(files).toContain('/projects/material/src/app/foo/foo.component.spec.ts'); expect(files).toContain('/projects/material/src/app/foo/foo.component.ts'); - const moduleContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const moduleContent = getFileContent(tree, '/projects/material/src/app/app-module.ts'); expect(moduleContent).toMatch(/import.*Foo.*from '.\/foo\/foo.component'/); expect(moduleContent).toMatch(/declarations:\s*\[[^\]]+?,\r?\n\s+FooComponent\r?\n/m); }); @@ -33,7 +34,7 @@ describe('Material address-form schematic', () => { it('should add address-form imports to module', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('address-form', baseOptions, app); - const moduleContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const moduleContent = getFileContent(tree, '/projects/material/src/app/app-module.ts'); expect(moduleContent).toContain('MatInputModule'); expect(moduleContent).toContain('MatButtonModule'); @@ -58,7 +59,7 @@ describe('Material address-form schematic', () => { {...baseOptions, standalone: true}, app, ); - const module = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const module = getFileContent(tree, '/projects/material/src/app/app-module.ts'); const content = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); const requiredModules = [ 'MatInputModule', @@ -84,7 +85,7 @@ describe('Material address-form schematic', () => { {...baseOptions, standalone: false}, app, ); - const module = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const module = getFileContent(tree, '/projects/material/src/app/app-module.ts'); const content = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); const requiredModules = [ 'MatInputModule', @@ -108,7 +109,7 @@ describe('Material address-form schematic', () => { const tree = await runner.runSchematic('address-form', baseOptions, app); const component = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); - expect(tree.exists('/projects/material/src/app/app.module.ts')).toBe(false); + expect(tree.exists('/projects/material/src/app/app-module.ts')).toBe(false); expect(component).toContain('imports: ['); }); }); diff --git a/src/material/schematics/ng-generate/dashboard/index.spec.ts b/src/material/schematics/ng-generate/dashboard/index.spec.ts index 1d867252f67f..81f1e2ca3109 100644 --- a/src/material/schematics/ng-generate/dashboard/index.spec.ts +++ b/src/material/schematics/ng-generate/dashboard/index.spec.ts @@ -9,6 +9,7 @@ describe('material-dashboard-schematic', () => { const baseOptions: Schema = { name: 'foo', project: 'material', + module: './app-module.ts', }; beforeEach(() => { @@ -25,7 +26,7 @@ describe('material-dashboard-schematic', () => { expect(files).toContain('/projects/material/src/app/foo/foo.component.spec.ts'); expect(files).toContain('/projects/material/src/app/foo/foo.component.ts'); - const moduleContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const moduleContent = getFileContent(tree, '/projects/material/src/app/app-module.ts'); expect(moduleContent).toMatch(/import.*Foo.*from '.\/foo\/foo.component'/); expect(moduleContent).toMatch(/declarations:\s*\[[^\]]+?,\r?\n\s+FooComponent\r?\n/m); }); @@ -33,7 +34,7 @@ describe('material-dashboard-schematic', () => { it('should add dashboard imports to module', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('dashboard', baseOptions, app); - const moduleContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const moduleContent = getFileContent(tree, '/projects/material/src/app/app-module.ts'); expect(moduleContent).toContain('MatGridListModule'); expect(moduleContent).toContain('MatCardModule'); @@ -62,7 +63,7 @@ describe('material-dashboard-schematic', () => { it('should generate a standalone component', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('dashboard', {...baseOptions, standalone: true}, app); - const module = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const module = getFileContent(tree, '/projects/material/src/app/app-module.ts'); const component = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); const requiredModules = [ 'MatButtonModule', @@ -84,7 +85,7 @@ describe('material-dashboard-schematic', () => { it('should generate a component with no imports and standalone false', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('dashboard', {...baseOptions, standalone: false}, app); - const module = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const module = getFileContent(tree, '/projects/material/src/app/app-module.ts'); const component = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); const requiredModules = [ 'MatButtonModule', @@ -113,7 +114,7 @@ describe('material-dashboard-schematic', () => { '/projects/material/src/app/foo/foo.component.ts', ); - expect(tree.exists('/projects/material/src/app/app.module.ts')).toBe(false); + expect(tree.exists('/projects/material/src/app/app-module.ts')).toBe(false); expect(componentContent).toContain('imports: ['); }); }); diff --git a/src/material/schematics/ng-generate/navigation/index.spec.ts b/src/material/schematics/ng-generate/navigation/index.spec.ts index 266db2943c87..b7416967e677 100644 --- a/src/material/schematics/ng-generate/navigation/index.spec.ts +++ b/src/material/schematics/ng-generate/navigation/index.spec.ts @@ -10,6 +10,7 @@ describe('material-navigation-schematic', () => { const baseOptions: Schema = { name: 'foo', project: 'material', + module: './app-module.ts', }; beforeEach(() => { @@ -17,7 +18,7 @@ describe('material-navigation-schematic', () => { }); function expectNavigationSchematicModuleImports(tree: UnitTestTree) { - const moduleContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const moduleContent = getFileContent(tree, '/projects/material/src/app/app-module.ts'); expect(moduleContent).toMatch(/MatToolbarModule,\s+/); expect(moduleContent).toMatch(/MatButtonModule,\s+/); expect(moduleContent).toMatch(/MatSidenavModule,\s+/); @@ -44,7 +45,7 @@ describe('material-navigation-schematic', () => { expect(files).toContain('/projects/material/src/app/foo/foo.component.spec.ts'); expect(files).toContain('/projects/material/src/app/foo/foo.component.ts'); - const moduleContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const moduleContent = getFileContent(tree, '/projects/material/src/app/app-module.ts'); expect(moduleContent).toMatch(/import.*Foo.*from '.\/foo\/foo.component'/); expect(moduleContent).toMatch(/declarations:\s*\[[^\]]+?,\r?\n\s+FooComponent\r?\n/m); }); @@ -73,7 +74,7 @@ describe('material-navigation-schematic', () => { it('should generate a standalone component', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('navigation', {...baseOptions, standalone: true}, app); - const module = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const module = getFileContent(tree, '/projects/material/src/app/app-module.ts'); const component = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); const requiredModules = [ 'MatToolbarModule', @@ -99,7 +100,7 @@ describe('material-navigation-schematic', () => { {...baseOptions, standalone: false}, app, ); - const module = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const module = getFileContent(tree, '/projects/material/src/app/app-module.ts'); const component = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); const requiredModules = [ 'MatToolbarModule', @@ -128,7 +129,7 @@ describe('material-navigation-schematic', () => { '/projects/material/src/app/foo/foo.component.ts', ); - expect(tree.exists('/projects/material/src/app/app.module.ts')).toBe(false); + expect(tree.exists('/projects/material/src/app/app-module.ts')).toBe(false); expect(componentContent).toContain('imports: ['); }); }); diff --git a/src/material/schematics/ng-generate/table/index.spec.ts b/src/material/schematics/ng-generate/table/index.spec.ts index 45887d312fd4..d8723a7869a8 100644 --- a/src/material/schematics/ng-generate/table/index.spec.ts +++ b/src/material/schematics/ng-generate/table/index.spec.ts @@ -9,6 +9,7 @@ describe('material-table-schematic', () => { const baseOptions: Schema = { name: 'foo', project: 'material', + module: './app-module.ts', }; beforeEach(() => { @@ -26,7 +27,7 @@ describe('material-table-schematic', () => { expect(files).toContain('/projects/material/src/app/foo/foo.component.ts'); expect(files).toContain('/projects/material/src/app/foo/foo-datasource.ts'); - const moduleContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const moduleContent = getFileContent(tree, '/projects/material/src/app/app-module.ts'); expect(moduleContent).toMatch(/import.*Foo.*from '.\/foo\/foo.component'/); expect(moduleContent).toMatch(/declarations:\s*\[[^\]]+?,\r?\n\s+FooComponent\r?\n/m); @@ -49,7 +50,7 @@ describe('material-table-schematic', () => { it('should add table imports to module', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('table', baseOptions, app); - const moduleContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const moduleContent = getFileContent(tree, '/projects/material/src/app/app-module.ts'); expect(moduleContent).toContain('MatTableModule'); expect(moduleContent).toContain('MatPaginatorModule'); @@ -74,7 +75,7 @@ describe('material-table-schematic', () => { it('should generate a standalone component', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('table', {...baseOptions, standalone: true}, app); - const module = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const module = getFileContent(tree, '/projects/material/src/app/app-module.ts'); const component = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); const requiredModules = ['MatTableModule', 'MatPaginatorModule', 'MatSortModule']; @@ -90,7 +91,7 @@ describe('material-table-schematic', () => { it('should generate a component with no imports and standalone false', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('table', {...baseOptions, standalone: false}, app); - const module = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const module = getFileContent(tree, '/projects/material/src/app/app-module.ts'); const component = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); const requiredModules = ['MatTableModule', 'MatPaginatorModule', 'MatSortModule']; @@ -113,7 +114,7 @@ describe('material-table-schematic', () => { '/projects/material/src/app/foo/foo.component.ts', ); - expect(tree.exists('/projects/material/src/app/app.module.ts')).toBe(false); + expect(tree.exists('/projects/material/src/app/app-module.ts')).toBe(false); expect(componentContent).toContain('imports: ['); }); }); diff --git a/src/material/schematics/ng-generate/theme-color/README.md b/src/material/schematics/ng-generate/theme-color/README.md index 85c1243bd684..e128f8457910 100644 --- a/src/material/schematics/ng-generate/theme-color/README.md +++ b/src/material/schematics/ng-generate/theme-color/README.md @@ -6,7 +6,7 @@ ng generate @angular/material:theme-color ## Background This schematic allows users to create new Material 3 theme palettes based on custom colors by using [Material Color Utilities](https://github.com/material-foundation/material-color-utilities). -This is an alternative to using the available [predefined theme palettes](https://material.angular.io/guide/theming#prebuilt-color-palettes). +This is an alternative to using the available [predefined theme palettes](https://material.angular.dev/guide/theming#prebuilt-color-palettes). The generated [color palettes](https://m3.material.io/styles/color/roles) are optimized to have enough contrast to be more accessible. See [Science of Color Design](https://material.io/blog/science-of-color-design) @@ -47,7 +47,7 @@ true. The result of running the schematic is a new file with the generated custom colors. Angular recommendeds generating a Sass file since our theming system Sass APIs are supported and -have benefits such as error handling and relate to the [theming documentation](https://material.angular.io/guide/theming). +have benefits such as error handling and relate to the [theming documentation](https://material.angular.dev/guide/theming). If there are ever changes to the theming system or system variable names, your styles will continue to work and be supported. Color palettes get defined in the generated file that you can pass into the `theme()` mixin in your own theme file. See the [Sass themes section](#sass-themes) for more @@ -129,7 +129,7 @@ html { #### Adaptive high contrast colors for adaptive themes The `theme()` mixin can create one theme that detects and adapts to a user if they have a light or dark theme by defining `color-scheme`. See the [color-scheme documentation](https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme) -and the [theming guide](https://material.angular.io/guide/theming#supporting-light-and-dark-mode) +and the [theming guide](https://material.angular.dev/guide/theming#supporting-light-and-dark-mode) for more information. Apply the `high-contrast-overrides(color-scheme)` mixin wrapped inside `@media (prefers-contrast: more)` diff --git a/src/material/schematics/ng-generate/theme-color/index.spec.ts b/src/material/schematics/ng-generate/theme-color/index.spec.ts index 896b59cc95e7..bc78eec527c0 100644 --- a/src/material/schematics/ng-generate/theme-color/index.spec.ts +++ b/src/material/schematics/ng-generate/theme-color/index.spec.ts @@ -1091,7 +1091,7 @@ html { /* Tertiary palette variables */ --mat-sys-tertiary: light-dark(#4d1f00, #ffece4); - --mat-sys-on-tertiary: light-dark(#4d1f00, #ffece4); + --mat-sys-on-tertiary: light-dark(#ffffff, #000000); --mat-sys-tertiary-container: light-dark(#7a3500, #ffb184); --mat-sys-on-tertiary-container: light-dark(#ffffff, #190600); --mat-sys-tertiary-fixed: light-dark(#7a3500, #ffdbc9); diff --git a/src/material/schematics/ng-generate/theme-color/index.ts b/src/material/schematics/ng-generate/theme-color/index.ts index 44366579323a..c299b42c2e02 100644 --- a/src/material/schematics/ng-generate/theme-color/index.ts +++ b/src/material/schematics/ng-generate/theme-color/index.ts @@ -531,8 +531,8 @@ function getColorSysVariablesCSS( css += createLightDarkVar( leftSpacing, 'on-tertiary', - isHighContrast ? lightScheme.tertiary : lightScheme.tertiaryPalette.tone(100), - isHighContrast ? darkScheme.tertiary : darkScheme.tertiaryPalette.tone(20), + isHighContrast ? lightScheme.onTertiary : lightScheme.tertiaryPalette.tone(100), + isHighContrast ? darkScheme.onTertiary : darkScheme.tertiaryPalette.tone(20), ); css += createLightDarkVar( leftSpacing, diff --git a/src/material/schematics/ng-generate/theme-color/schema.json b/src/material/schematics/ng-generate/theme-color/schema.json index 6adc7d434fd6..b8e36dc8ba4e 100644 --- a/src/material/schematics/ng-generate/theme-color/schema.json +++ b/src/material/schematics/ng-generate/theme-color/schema.json @@ -49,7 +49,7 @@ "type": "boolean", "default": true, "description": "Whether to generate output file in scss or CSS", - "x-prompt": "Do you want to generated file to be a scss file? This is the recommended way of setting up theming in your application. If not, a CSS file will be generated with all the system variables defined. (Leave blank to generate a scss file)" + "x-prompt": "Do you want the generated file to be a scss file? This is the recommended way of setting up theming in your application. If not, a CSS file will be generated with all the system variables defined. (Leave blank to generate a scss file)" } } } diff --git a/src/material/schematics/ng-generate/tree/index.spec.ts b/src/material/schematics/ng-generate/tree/index.spec.ts index 65f1affbf815..d95582d6f498 100644 --- a/src/material/schematics/ng-generate/tree/index.spec.ts +++ b/src/material/schematics/ng-generate/tree/index.spec.ts @@ -9,6 +9,7 @@ describe('Material tree schematic', () => { const baseOptions: Schema = { name: 'foo', project: 'material', + module: './app-module.ts', }; beforeEach(() => { @@ -25,7 +26,7 @@ describe('Material tree schematic', () => { expect(files).toContain('/projects/material/src/app/foo/foo.component.spec.ts'); expect(files).toContain('/projects/material/src/app/foo/foo.component.ts'); - const moduleContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const moduleContent = getFileContent(tree, '/projects/material/src/app/app-module.ts'); expect(moduleContent).toMatch(/import.*Foo.*from '.\/foo\/foo.component'/); expect(moduleContent).toMatch(/declarations:\s*\[[^\]]+?,\r?\n\s+FooComponent\r?\n/m); }); @@ -33,7 +34,7 @@ describe('Material tree schematic', () => { it('should add tree imports to module', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('tree', baseOptions, app); - const moduleContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const moduleContent = getFileContent(tree, '/projects/material/src/app/app-module.ts'); expect(moduleContent).toContain('MatTreeModule'); expect(moduleContent).toContain('MatIconModule'); @@ -52,7 +53,7 @@ describe('Material tree schematic', () => { it('should generate a standalone component', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('tree', {...baseOptions, standalone: true}, app); - const module = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const module = getFileContent(tree, '/projects/material/src/app/app-module.ts'); const component = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); const requiredModules = ['MatTreeModule', 'MatButtonModule', 'MatIconModule']; @@ -68,7 +69,7 @@ describe('Material tree schematic', () => { it('should generate a component with no imports and standalone false', async () => { const app = await createTestApp(runner, {standalone: false}); const tree = await runner.runSchematic('tree', {...baseOptions, standalone: false}, app); - const module = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + const module = getFileContent(tree, '/projects/material/src/app/app-module.ts'); const component = getFileContent(tree, '/projects/material/src/app/foo/foo.component.ts'); const requiredModules = ['MatTreeModule', 'MatButtonModule', 'MatIconModule']; @@ -91,7 +92,7 @@ describe('Material tree schematic', () => { '/projects/material/src/app/foo/foo.component.ts', ); - expect(tree.exists('/projects/material/src/app/app.module.ts')).toBe(false); + expect(tree.exists('/projects/material/src/app/app-module.ts')).toBe(false); expect(componentContent).toContain('imports: ['); }); }); diff --git a/src/material/schematics/ng-update/BUILD.bazel b/src/material/schematics/ng-update/BUILD.bazel index 5457154fa98d..6ee349fda826 100644 --- a/src/material/schematics/ng-update/BUILD.bazel +++ b/src/material/schematics/ng-update/BUILD.bazel @@ -68,6 +68,7 @@ ts_project( jasmine_test( name = "test", data = [ + ":ng_update_index", ":schematics_test_cases", ":test_lib", "//src/material/schematics:collection_assets", diff --git a/src/material/schematics/ng-update/index.ts b/src/material/schematics/ng-update/index.ts index cd99930de5ab..1a8b2f6ab431 100644 --- a/src/material/schematics/ng-update/index.ts +++ b/src/material/schematics/ng-update/index.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Rule, SchematicContext} from '@angular-devkit/schematics'; +import {chain, Rule, SchematicContext} from '@angular-devkit/schematics'; import { createMigrationSchematicRule, NullableDevkitMigration, @@ -22,16 +22,94 @@ const materialMigrations: NullableDevkitMigration[] = [ ExplicitSystemVariablePrefixMigration, ]; -/** Entry point for the migration schematics with target of Angular Material v19 */ +/** Entry point for the migration schematics with target of Angular Material v20 */ export function updateToV20(): Rule { - return createMigrationSchematicRule( - TargetVersion.V20, - materialMigrations, - materialUpgradeData, - onMigrationComplete, + return chain([ + createMigrationSchematicRule( + TargetVersion.V20, + materialMigrations, + materialUpgradeData, + onMigrationComplete, + ), + renameMdcTokens(), + renameComponentTokens(), + ]); +} + +// Whether the given path should be included when renaming theme token names. +function shouldRenameTokens(path: string) { + if (path.includes('node_modules') || path.includes('.angular') || path.includes('.git')) { + return false; + } + + return ( + path.endsWith('.html') || + path.endsWith('.css') || + path.endsWith('.scss') || + path.endsWith('.ts') ); } +// Renames any CSS variables beginning with "--mdc-" to be "--mat-". These CSS variables +// refer to tokens that used to be derived from a mix of MDC and Angular. Now all the tokens +// are converged on being prefixed "--mat-". +function renameMdcTokens(): Rule { + return tree => { + tree.visit(path => { + if (shouldRenameTokens(path)) { + const content = tree.readText(path); + const updatedContent = content.replaceAll('--mdc-', '--mat-'); + if (content !== updatedContent) { + tree.overwrite(path, updatedContent); + } + } + }); + }; +} + +// Renames Angular Material component token CSS variables that were renamed so that the base +// component's name came first or otherwise renamed to match our terminology instead of MDC's. +function renameComponentTokens(): Rule { + const tokenPrefixes = [ + {old: '--mat-circular-progress', replacement: '--mat-progress-spinner'}, + {old: '--mat-elevated-card', replacement: '--mat-card-elevated'}, + {old: '--mat-extended-fab', replacement: '--mat-fab-extended'}, + {old: '--mat-filled-button', replacement: '--mat-button-filled'}, + {old: '--mat-filled-text-field', replacement: '--mat-form-field-filled'}, + {old: '--mat-full-pseudo-checkbox', replacement: '--mat-pseudo-checkbox-full'}, + {old: '--mat-legacy-button-toggle', replacement: '--mat-button-toggle-legacy'}, + {old: '--mat-linear-progress', replacement: '--mat-progress-bar'}, + {old: '--mat-minimal-pseudo-checkbox', replacement: '--mat-pseudo-checkbox-minimal'}, + {old: '--mat-outlined-button', replacement: '--mat-button-outlined'}, + {old: '--mat-outlined-card', replacement: '--mat-card-outlined'}, + {old: '--mat-outlined-text-field', replacement: '--mat-form-field-outlined'}, + {old: '--mat-plain-tooltip', replacement: '--mat-tooltip'}, + {old: '--mat-protected-button', replacement: '--mat-button-protected'}, + {old: '--mat-secondary-navigation-tab', replacement: '--mat-tab'}, + {old: '--mat-standard-button-toggle', replacement: '--mat-button-toggle'}, + {old: '--mat-switch', replacement: '--mat-slide-toggle'}, + {old: '--mat-tab-header', replacement: '--mat-tab'}, + {old: '--mat-tab-header-with-background', replacement: '--mat-tab'}, + {old: '--mat-tab-indicator', replacement: '--mat-tab'}, + {old: '--mat-text-button', replacement: '--mat-button-text'}, + {old: '--mat-tonal-button', replacement: '--mat-button-tonal'}, + ]; + return tree => { + tree.visit(path => { + if (shouldRenameTokens(path)) { + const content = tree.readText(path); + let updatedContent = content; + for (const tokenPrefix of tokenPrefixes) { + updatedContent = updatedContent.replaceAll(tokenPrefix.old, tokenPrefix.replacement); + } + if (content !== updatedContent) { + tree.overwrite(path, updatedContent); + } + } + }); + }; +} + /** Function that will be called when the migration completed. */ function onMigrationComplete( context: SchematicContext, diff --git a/src/material/schematics/ng-update/test-cases/rename-mdc-tokens.spec.ts b/src/material/schematics/ng-update/test-cases/rename-mdc-tokens.spec.ts new file mode 100644 index 000000000000..28840e5abd19 --- /dev/null +++ b/src/material/schematics/ng-update/test-cases/rename-mdc-tokens.spec.ts @@ -0,0 +1,105 @@ +import {UnitTestTree} from '@angular-devkit/schematics/testing'; +import {createTestCaseSetup} from '../../../../cdk/schematics/testing'; +import {MIGRATION_PATH} from '../../paths'; + +const THEME_FILE_PATH = '/projects/cdk-testing/src/theme.scss'; + +describe('v20 rename tokens migration', () => { + let tree: UnitTestTree; + let writeFile: (filename: string, content: string) => void; + let runMigration: () => Promise; + + function stripWhitespace(content: string): string { + return content.replace(/\s/g, ''); + } + + beforeEach(async () => { + const testSetup = await createTestCaseSetup('migration-v20', MIGRATION_PATH, []); + tree = testSetup.appTree; + writeFile = testSetup.writeFile; + runMigration = testSetup.runFixers; + }); + + it('should rename mdc tokens to mat and change component ordering', async () => { + writeFile( + THEME_FILE_PATH, + ` + html { + --mdc-icon-button-icon-size: 24px; + --mat-filled-button-color: red; + --mat-filled-text-field-color: red; + --mat-full-pseudo-checkbox-color: red; + --mat-legacy-button-toggle-color: red; + } + `, + ); + + await runMigration(); + + expect(stripWhitespace(tree.readText(THEME_FILE_PATH))).toBe( + stripWhitespace(` + html { + --mat-icon-button-icon-size: 24px; + --mat-button-filled-color: red; + --mat-form-field-filled-color: red; + --mat-pseudo-checkbox-full-color: red; + --mat-button-toggle-legacy-color: red; + } + `), + ); + }); + + it('should rename multiple instances of the --mdc prefix', async () => { + writeFile( + THEME_FILE_PATH, + ` + html { + --mdc-foo: 1px; + --mdc-bar: 2px; + --mdc-baz: 3px; + } + `, + ); + + await runMigration(); + + expect(stripWhitespace(tree.readText(THEME_FILE_PATH))).toBe( + stripWhitespace(` + html { + --mat-foo: 1px; + --mat-bar: 2px; + --mat-baz: 3px; + } + `), + ); + }); + + it('should rename multiple instances of a specific component token', async () => { + writeFile( + THEME_FILE_PATH, + ` + .one { + --mat-circular-progress-foo: 1px; + } + + .two { + --mat-circular-progress-bar: 2px; + } + `, + ); + + await runMigration(); + + expect(stripWhitespace(tree.readText(THEME_FILE_PATH))).toBe( + stripWhitespace(` + .one { + --mat-progress-spinner-foo: 1px; + } + + .two { + --mat-progress-spinner-bar: 2px; + } + `), + ); + }); +}); diff --git a/src/material/select/README.md b/src/material/select/README.md index 092134ace1ca..beb4f4614234 100644 --- a/src/material/select/README.md +++ b/src/material/select/README.md @@ -1 +1 @@ -Please see the official documentation at https://material.angular.io/components/component/select +Please see the official documentation at https://material.angular.dev/components/component/select diff --git a/src/material/select/_m2-select.scss b/src/material/select/_m2-select.scss index 08cb7cbc5ef7..c485ee3bd603 100644 --- a/src/material/select/_m2-select.scss +++ b/src/material/select/_m2-select.scss @@ -1,81 +1,49 @@ @use 'sass:map'; -@use '../core/tokens/m2-utils'; -@use '../core/theming/inspection'; @use '../core/theming/theming'; @use '../core/style/elevation'; -@use '../core/style/sass-utils'; +@use '../core/tokens/m2-utils'; +@use '../core/tokens/m3-utils'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, select); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); + $density-scale: theming.clamp-density(map.get($system, density-scale), -2); -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { @return ( - container-elevation-shadow: elevation.get-box-shadow(8), + base: ( + select-container-elevation-shadow: elevation.get-box-shadow(8), + ), + color: private-get-color-palette-color-tokens($theme, primary), + typography: ( + select-trigger-text-font: map.get($system, body-large-font), + select-trigger-text-line-height: map.get($system, body-large-line-height), + select-trigger-text-size: map.get($system, body-large-size), + select-trigger-text-tracking: map.get($system, body-large-tracking), + select-trigger-text-weight: map.get($system, body-large-weight) + ), + density: ( + select-arrow-transform: map.get(( + 0: translateY(-8px), + -1: translateY(-8px), + -2: none, + ), $density-scale), + ), ); } // Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme, $palette-name: primary) { - $is-dark: inspection.get-theme-type($theme) == dark; - - // Ideally we would derive all values directly from the theme, but it causes a lot of regressions - // internally. For now we fall back to the old hardcoded behavior only for internal apps. - $on-surface: if($is-dark, #fff, #000); - $text-color-base: if(m2-utils.$private-is-internal-build, $on-surface, - inspection.get-theme-color($theme, foreground, text, 1)); - $disabled-text-color-base: if(m2-utils.$private-is-internal-build, $on-surface, - inspection.get-theme-color($theme, foreground, disabled-text, 1)); - $icon-color-base: if(m2-utils.$private-is-internal-build, $on-surface, - inspection.get-theme-color($theme, foreground, divider, 1)); - - @return ( - panel-background-color: inspection.get-theme-color($theme, background, card), - enabled-trigger-text-color: sass-utils.safe-color-change($text-color-base, $alpha: 0.87), - disabled-trigger-text-color: - sass-utils.safe-color-change($disabled-text-color-base, $alpha: 0.38), - placeholder-text-color: sass-utils.safe-color-change($text-color-base, $alpha: 0.6), - enabled-arrow-color: sass-utils.safe-color-change($icon-color-base, $alpha: 0.54), - disabled-arrow-color: sass-utils.safe-color-change($text-color-base, $alpha: 0.38), - focused-arrow-color: sass-utils.safe-color-change( - inspection.get-theme-color($theme, $palette-name, default), $alpha: 0.87), - invalid-arrow-color: sass-utils.safe-color-change( - inspection.get-theme-color($theme, warn, default), $alpha: 0.87), - ); -} +@function private-get-color-palette-color-tokens($theme, $color-variant) { + $system: m2-utils.get-system($theme); + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); + $disabled: m3-utils.color-with-opacity(map.get($system, on-surface), 38%); -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { @return ( - trigger-text-font: inspection.get-theme-typography($theme, body-1, font-family), - trigger-text-line-height: inspection.get-theme-typography($theme, body-1, line-height), - trigger-text-size: inspection.get-theme-typography($theme, body-1, font-size), - trigger-text-tracking: inspection.get-theme-typography($theme, body-1, letter-spacing), - trigger-text-weight: inspection.get-theme-typography($theme, body-1, font-weight) - ); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $density-scale: theming.clamp-density(inspection.get-theme-density($theme), -2); - - @return ( - arrow-transform: map.get(( - 0: translateY(-8px), - -1: translateY(-8px), - -2: none, - ), $density-scale), - ); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + select-panel-background-color: map.get($system, surface), + select-enabled-trigger-text-color: map.get($system, on-surface), + select-disabled-trigger-text-color: $disabled, + select-placeholder-text-color: map.get($system, on-surface-variant), + select-enabled-arrow-color: map.get($system, on-surface-variant), + select-disabled-arrow-color: $disabled, + select-focused-arrow-color: map.get($system, primary), + select-invalid-arrow-color: map.get($system, error), ); } diff --git a/src/material/select/_m3-select.scss b/src/material/select/_m3-select.scss index 3f760aaedd15..de5199f7b1a8 100644 --- a/src/material/select/_m3-select.scss +++ b/src/material/select/_m3-select.scss @@ -1,61 +1,54 @@ @use 'sass:map'; @use 'sass:list'; -@use '../core/style/sass-utils'; @use '../core/style/elevation'; @use '../core/tokens/m3-utils'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; +@use '../core/tokens/m3'; // The prefix used to generate the fully qualified name for tokens in this file. $prefix: (mat, select); /// Generates custom tokens for the mat-select. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-select -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: sass-utils.merge-all( - m3-utils.generate-typography-tokens($systems, trigger-text, body-large), - ( - panel-background-color: map.get($systems, md-sys-color, surface-container), - enabled-trigger-text-color: map.get($systems, md-sys-color, on-surface), - disabled-trigger-text-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - placeholder-text-color: map.get($systems, md-sys-color, on-surface-variant), - enabled-arrow-color: map.get($systems, md-sys-color, on-surface-variant), - disabled-arrow-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, on-surface), $alpha: 0.38), - focused-arrow-color: map.get($systems, md-sys-color, primary), - invalid-arrow-color: map.get($systems, md-sys-color, error), - container-elevation-shadow: - m3-utils.hardcode(elevation.get-box-shadow(2), $exclude-hardcoded), - ) - ); +/// @param {String} $color-variant The color variant to use for the component +@function get-tokens($theme: m3.$sys-theme, $color-variant: null) { + $system: m3-utils.get-system($theme); + @if $color-variant { + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); + } - $variant-tokens: ( - // Color variants: - primary: (), // Default, no overrides needed - secondary: ( - focused-arrow-color: map.get($systems, md-sys-color, secondary), + @return ( + base: ( + select-container-elevation-shadow: elevation.get-box-shadow(2), ), - tertiary: ( - focused-arrow-color: map.get($systems, md-sys-color, tertiary), + color: ( + select-panel-background-color: map.get($system, surface-container), + select-enabled-trigger-text-color: map.get($system, on-surface), + select-disabled-trigger-text-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + select-placeholder-text-color: map.get($system, on-surface-variant), + select-enabled-arrow-color: map.get($system, on-surface-variant), + select-disabled-arrow-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + select-focused-arrow-color: map.get($system, primary), + select-invalid-arrow-color: map.get($system, error), ), - error: ( - focused-arrow-color: map.get($systems, md-sys-color, error), - ) + typography: ( + select-trigger-text-font: map.get($system, body-large-font), + select-trigger-text-line-height: map.get($system, body-large-line-height), + select-trigger-text-size: map.get($system, body-large-size), + select-trigger-text-tracking: map.get($system, body-large-tracking), + select-trigger-text-weight: map.get($system, body-large-weight), + ), + density: get-density-tokens(map.get($system, density-scale)), ); - - @return m3-utils.namespace($prefix, ($tokens, $variant-tokens), $token-slots); } // Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $scale: theming.clamp-density(inspection.get-theme-density($theme), -2); +@function get-density-tokens($scale) { + $scale: theming.clamp-density($scale, -2); $index: ($scale * -1) + 1; @return ( - arrow-transform: list.nth((translateY(-8px), translateY(-8px), none), $index), + select-arrow-transform: list.nth((translateY(-8px), translateY(-8px), none), $index), ); } diff --git a/src/material/select/_select-theme.scss b/src/material/select/_select-theme.scss index 9f8f091425cb..0e842a4e4f8f 100644 --- a/src/material/select/_select-theme.scss +++ b/src/material/select/_select-theme.scss @@ -1,95 +1,70 @@ -@use '../core/style/sass-utils'; @use '../core/theming/inspection'; -@use '../core/theming/theming'; -@use '../core/theming/validation'; @use '../core/tokens/token-utils'; @use '../core/typography/typography'; @use './m2-select'; @use './m3-select'; +@use 'sass:map'; /// Outputs base theme styles (styles not dependent on the color, typography, or density settings) /// for the mat-select. /// @param {Map} $theme The theme to generate base styles for. @mixin base($theme) { + $tokens: map.get(m2-select.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - @include sass-utils.current-selector-or-root() { - $mat-tokens: m2-select.get-unthemable-tokens(); - @include token-utils.create-token-values-mixed(m2-select.$prefix, $mat-tokens); - } + $tokens: map.get(m3-select.get-tokens($theme), base); } + + @include token-utils.values($tokens); } /// Outputs color theme styles for the mat-select. /// @param {Map} $theme The theme to generate color styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the select arrow: primary, secondary, tertiary, -/// or error (If not specified, default primary color will be used). -@mixin color($theme, $options...) { +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin color($theme, $color-variant: null) { + $tokens: map.get(m2-select.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color), $options...); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-select.$prefix, - m2-select.get-color-tokens($theme) - ); + $tokens: map.get(m3-select.get-tokens($theme, $color-variant), color); + } - .mat-mdc-form-field.mat-accent { - @include token-utils.create-token-values-mixed( - m2-select.$prefix, - m2-select.get-color-tokens($theme, accent) - ); - } + @include token-utils.values($tokens); - .mat-mdc-form-field.mat-warn { - @include token-utils.create-token-values-mixed( - m2-select.$prefix, - m2-select.get-color-tokens($theme, warn) - ); - } + @if inspection.get-theme-version($theme) != 1 { + .mat-mdc-form-field.mat-accent { + $tokens: m2-select.private-get-color-palette-color-tokens($theme, secondary); + @include token-utils.values($tokens); + } + + .mat-mdc-form-field.mat-warn { + $tokens: m2-select.private-get-color-palette-color-tokens($theme, error); + @include token-utils.values($tokens); } } } -/// Outputs typography theme styles for the mat-select. -/// @param {Map} $theme The theme to generate typography styles for. @mixin typography($theme) { + $tokens: map.get(m2-select.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-select.$prefix, - m2-select.get-typography-tokens($theme) - ); - } + $tokens: map.get(m3-select.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } -/// Outputs typography theme styles for the mat-select. -/// @param {Map} $theme The theme to generate density styles for. @mixin density($theme) { + $tokens: map.get(m2-select.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - @include token-utils.create-token-values( - m3-select.$prefix, m3-select.get-density-tokens($theme)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-select.$prefix, - m2-select.get-density-tokens($theme) - ); - } + $tokens: map.get(m3-select.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-select.$prefix, - tokens: m2-select.get-token-slots(), + namespace: select, + tokens: token-utils.get-overrides(m3-select.get-tokens(), select) ), ); } @@ -97,40 +72,28 @@ /// Outputs the CSS variable values for the given tokens. /// @param {Map} $tokens The token values to emit. @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } /// Outputs all (base, color, typography, and density) theme styles for the mat-icon. /// @param {Map} $theme The theme to generate styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the select arrow: primary, secondary, tertiary, -/// or error (If not specified, default primary color will be used). -@mixin theme($theme, $options...) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-select') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme), $options...); - @if inspection.theme-has($theme, density) { - @include density($theme); - } - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin theme($theme, $color-variant: null) { + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme, $color-variant); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } } } - -@mixin _theme-from-tokens($tokens, $options...) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - $mat-select-tokens: token-utils.get-tokens-for($tokens, m2-select.$prefix, $options...); - @include token-utils.create-token-values(m2-select.$prefix, $mat-select-tokens); -} diff --git a/src/material/select/select.md b/src/material/select/select.md index 2a00d2849caf..619a82ed6cce 100644 --- a/src/material/select/select.md +++ b/src/material/select/select.md @@ -1,7 +1,7 @@ `` is a form control for selecting a value from a set of options, similar to the native `` element inside of ``. The native control has several performance, accessibility, and usability advantages. See [the documentation for -form-field](https://material.angular.io/components/form-field) for more information. +form-field](https://material.angular.dev/components/form-field) for more information. To use a native select inside ``, import `MatInputModule` and add the `matNativeControl` attribute to the `` and ``. These include error messages, hint text, prefix & suffix, and theming. For additional information about these features, see the -[form field documentation](https://material.angular.io/components/form-field/overview). +[form field documentation](https://material.angular.dev/components/form-field/overview). @@ -48,7 +48,7 @@ The placeholder is text shown when the `` label is floating but is empty. It is used to give the user an additional hint about the value they should select. The placeholder can be specified by setting the `placeholder` attribute on the `` element. In some cases that `` may use the placeholder as the label (see the -[form field label documentation](https://material.angular.io/components/form-field/overview#floating-label)). +[form field label documentation](https://material.angular.dev/components/form-field/overview#floating-label)). ### Disabling the select or individual options @@ -121,7 +121,7 @@ which can be used to apply additional CSS classes to the dropdown panel. ### Changing when error messages are shown The `` allows you to -[associate error messages](https://material.angular.io/components/form-field/overview#error-messages) +[associate error messages](https://material.angular.dev/components/form-field/overview#error-messages) with your ` `, styles: SLIDER_STYLES, - standalone: false, + imports: [MatSliderModule, ReactiveFormsModule], }) class SliderWithFormControl { control = new FormControl(0); @@ -1979,7 +1984,7 @@ class SliderWithFormControl { `, styles: SLIDER_STYLES, - standalone: false, + imports: [MatSliderModule, ReactiveFormsModule], }) class RangeSliderWithFormControl { startInputControl = new FormControl(0); @@ -1993,7 +1998,7 @@ class RangeSliderWithFormControl { `, styles: SLIDER_STYLES, - standalone: false, + imports: [MatSliderModule], }) class SliderWithTwoWayBinding { value = 0; @@ -2007,7 +2012,7 @@ class SliderWithTwoWayBinding { `, styles: SLIDER_STYLES, - standalone: false, + imports: [MatSliderModule], }) class RangeSliderWithTwoWayBinding { @ViewChild(MatSlider) slider: MatSlider; @@ -2023,7 +2028,7 @@ class RangeSliderWithTwoWayBinding { `, styles: SLIDER_STYLES, - standalone: false, + imports: [MatSliderModule], }) class SliderWithTickMarks { @ViewChild(MatSlider) slider: MatSlider; @@ -2037,7 +2042,7 @@ class SliderWithTickMarks { `, styles: SLIDER_STYLES, - standalone: false, + imports: [MatSliderModule], }) class RangeSliderWithTickMarks { @ViewChild(MatSlider) slider: MatSlider; @@ -2053,7 +2058,7 @@ class RangeSliderWithTickMarks {
    `, styles: SLIDER_STYLES, - standalone: false, + imports: [MatSliderModule, ReactiveFormsModule], }) class SliderWithFormGroup { readonly MIN = 0; diff --git a/src/material/slider/slider.ts b/src/material/slider/slider.ts index 7ce70cdc12aa..823870215ca1 100644 --- a/src/material/slider/slider.ts +++ b/src/material/slider/slider.ts @@ -149,10 +149,10 @@ export class MatSlider implements AfterViewInit, OnDestroy, _MatSlider { /** * Theme color of the slider. This API is supported in M2 themes only, it - * has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/slider/styling. + * has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/slider/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() color: ThemePalette; diff --git a/src/material/snack-bar/README.md b/src/material/snack-bar/README.md index dadbabcf5cd8..67bb1b880d06 100644 --- a/src/material/snack-bar/README.md +++ b/src/material/snack-bar/README.md @@ -1 +1 @@ -Please see the official documentation at https://material.angular.io/components/component/snack-bar +Please see the official documentation at https://material.angular.dev/components/component/snack-bar diff --git a/src/material/snack-bar/_m2-snack-bar.scss b/src/material/snack-bar/_m2-snack-bar.scss index 9db12843824a..63bfa6058dcd 100644 --- a/src/material/snack-bar/_m2-snack-bar.scss +++ b/src/material/snack-bar/_m2-snack-bar.scss @@ -1,62 +1,24 @@ @use '../core/tokens/m2-utils'; -@use '../core/theming/inspection'; -@use '../core/style/sass-utils'; -@use 'sass:meta'; -@use 'sass:color'; +@use 'sass:map'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, snack-bar); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { @return ( - container-shape: 4px, - ); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - $is-dark: inspection.get-theme-type($theme) == dark; - $surface: inspection.get-theme-color($theme, background, card); - - @return ( - container-color: if( - meta.type-of($surface) == color, - color.mix(if($is-dark, #fff, #000), $surface, 80%), - $surface), - supporting-text-color: if(meta.type-of($surface) == color, rgba($surface, 0.87), $surface), - button-color: - if( - $is-dark, - inspection.get-theme-color($theme, primary, 500), - inspection.get-theme-color($theme, primary, 100) - ) - ); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return ( - supporting-text-font: inspection.get-theme-typography($theme, body-2, font-family), - supporting-text-line-height: inspection.get-theme-typography($theme, body-2, line-height), - supporting-text-size: inspection.get-theme-typography($theme, body-2, font-size), - supporting-text-weight: inspection.get-theme-typography($theme, body-2, font-weight), - ); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + base: ( + snack-bar-container-shape: 4px, + ), + color: ( + snack-bar-container-color: map.get($system, inverse-surface), + snack-bar-supporting-text-color: map.get($system, inverse-on-surface), + snack-bar-button-color: map.get($system, inverse-primary) + ), + typography: ( + snack-bar-supporting-text-font: map.get($system, body-medium-font), + snack-bar-supporting-text-line-height: map.get($system, body-medium-line-height), + snack-bar-supporting-text-size: map.get($system, body-medium-size), + snack-bar-supporting-text-weight: map.get($system, body-medium-weight), + ), + density: (), ); } diff --git a/src/material/snack-bar/_m3-snack-bar.scss b/src/material/snack-bar/_m3-snack-bar.scss index 52ad45f0d2e8..3d0d8262e98f 100644 --- a/src/material/snack-bar/_m3-snack-bar.scss +++ b/src/material/snack-bar/_m3-snack-bar.scss @@ -1,25 +1,26 @@ @use 'sass:map'; @use '../core/tokens/m3-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, snack-bar); +@use '../core/tokens/m3'; /// Generates custom tokens for the mat-snack-bar. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-snack-bar -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: ( - button-color: map.get($systems, md-sys-color, inverse-primary), - container-color: map.get($systems, md-sys-color, inverse-surface), - container-shape: map.get($systems, md-sys-shape, corner-extra-small), - supporting-text-color: map.get($systems, md-sys-color, inverse-on-surface), - supporting-text-font: map.get($systems, md-sys-typescale, body-medium-font), - supporting-text-line-height: map.get($systems, md-sys-typescale, body-medium-line-height), - supporting-text-size: map.get($systems, md-sys-typescale, body-medium-size), - supporting-text-weight: map.get($systems, md-sys-typescale, body-medium-weight), - ); +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); - @return m3-utils.namespace($prefix, $tokens, $token-slots); + @return ( + base: ( + snack-bar-container-shape: map.get($system, corner-extra-small), + ), + color: ( + snack-bar-button-color: map.get($system, inverse-primary), + snack-bar-container-color: map.get($system, inverse-surface), + snack-bar-supporting-text-color: map.get($system, inverse-on-surface), + ), + typography: ( + snack-bar-supporting-text-font: map.get($system, body-medium-font), + snack-bar-supporting-text-line-height: map.get($system, body-medium-line-height), + snack-bar-supporting-text-size: map.get($system, body-medium-size), + snack-bar-supporting-text-weight: map.get($system, body-medium-weight), + ), + density: (), + ); } diff --git a/src/material/snack-bar/_snack-bar-theme.scss b/src/material/snack-bar/_snack-bar-theme.scss index ec133baaab0d..64eb6e231376 100644 --- a/src/material/snack-bar/_snack-bar-theme.scss +++ b/src/material/snack-bar/_snack-bar-theme.scss @@ -1,55 +1,51 @@ @use 'sass:map'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; -@use '../core/style/sass-utils'; @use '../core/typography/typography'; @use '../core/tokens/token-utils'; @use './m2-snack-bar'; +@use './m3-snack-bar'; @mixin base($theme) { + $tokens: map.get(m2-snack-bar.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - // Add default values for tokens not related to color, typography, or density. - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-snack-bar.$prefix, m2-snack-bar.get-unthemable-tokens()); - } + $tokens: map.get(m3-snack-bar.get-tokens($theme), base); } + + @include token-utils.values($tokens); } @mixin color($theme) { @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); + @include token-utils.values(map.get(m3-snack-bar.get-tokens($theme), color)); } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-snack-bar.$prefix, m2-snack-bar.get-color-tokens($theme)); - } + @include token-utils.values(map.get(m2-snack-bar.get-tokens($theme), color)); } } @mixin typography($theme) { + $tokens: map.get(m2-snack-bar.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-snack-bar.$prefix, m2-snack-bar.get-typography-tokens($theme)); - } + $tokens: map.get(m3-snack-bar.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } @mixin density($theme) { + $tokens: map.get(m2-snack-bar.get-tokens($theme), density); + @if inspection.get-theme-version($theme) == 1 { + $tokens: map.get(m3-snack-bar.get-tokens($theme), density); + } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-snack-bar.$prefix, - tokens: m2-snack-bar.get-token-slots(), + namespace: snack-bar, + tokens: token-utils.get-overrides(m3-snack-bar.get-tokens(), snack-bar) ), ); } @@ -57,36 +53,25 @@ /// Outputs the CSS variable values for the given tokens. /// @param {Map} $tokens The token values to emit. @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } @mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-snack-bar') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - @include token-utils.create-token-values( - m2-snack-bar.$prefix, - map.get($tokens, m2-snack-bar.$prefix) - ); } } diff --git a/src/material/snack-bar/snack-bar-container.scss b/src/material/snack-bar/snack-bar-container.scss index 6e66482d7833..f2ffe71c8a0b 100644 --- a/src/material/snack-bar/snack-bar-container.scss +++ b/src/material/snack-bar/snack-bar-container.scss @@ -1,13 +1,11 @@ @use '@angular/cdk'; -@use './m2-snack-bar'; -@use '../button/m2-button'; +@use './m3-snack-bar'; @use '../core/tokens/token-utils'; @use '../core/style/elevation'; $_side-padding: 8px; -$token-prefix: m2-snack-bar.$prefix; -$token-slots: m2-snack-bar.get-token-slots(); +$fallbacks: m3-snack-bar.get-tokens(); @keyframes _mat-snack-bar-enter { from { @@ -90,12 +88,10 @@ $token-slots: m2-snack-bar.get-token-slots(); outline: solid 1px; } - @include token-utils.use-tokens($token-prefix, $token-slots) { - .mat-mdc-snack-bar-container & { - color: token-utils.slot(supporting-text-color); - border-radius: token-utils.slot(container-shape); - background-color: token-utils.slot(container-color); - } + .mat-mdc-snack-bar-container & { + color: token-utils.slot(snack-bar-supporting-text-color, $fallbacks); + border-radius: token-utils.slot(snack-bar-container-shape, $fallbacks); + background-color: token-utils.slot(snack-bar-container-color, $fallbacks); } } @@ -111,13 +107,11 @@ $token-slots: m2-snack-bar.get-token-slots(); padding-right: 16px; } - @include token-utils.use-tokens($token-prefix, $token-slots) { - .mat-mdc-snack-bar-container & { - font-family: token-utils.slot(supporting-text-font); - font-size: token-utils.slot(supporting-text-size); - font-weight: token-utils.slot(supporting-text-weight); - line-height: token-utils.slot(supporting-text-line-height); - } + .mat-mdc-snack-bar-container & { + font-family: token-utils.slot(snack-bar-supporting-text-font, $fallbacks); + font-size: token-utils.slot(snack-bar-supporting-text-size, $fallbacks); + font-weight: token-utils.slot(snack-bar-supporting-text-weight, $fallbacks); + line-height: token-utils.slot(snack-bar-supporting-text-line-height, $fallbacks); } } @@ -144,15 +138,13 @@ $token-slots: m2-snack-bar.get-token-slots(); // MDC's `action-label-text-color` should be able to do this, but the button theme has a // higher specificity so it ends up overriding it. Define our own variable that we can // use to control the color instead. - @include token-utils.use-tokens($token-prefix, $token-slots) { - color: token-utils.slot(button-color); - } + color: token-utils.slot(snack-bar-button-color, $fallbacks); } // Darken the ripples in the button so they're visible against the dark background. - @include token-utils.create-token-values-mixed(m2-button.$prefix, ( - text-state-layer-color: currentColor, - text-ripple-color: currentColor, + @include token-utils.values(( + button-text-state-layer-color: currentColor, + button-text-ripple-color: currentColor, )); .mat-ripple-element { diff --git a/src/material/sort/_m2-sort.scss b/src/material/sort/_m2-sort.scss index 67e37fe1fda7..d3483b3d5b18 100644 --- a/src/material/sort/_m2-sort.scss +++ b/src/material/sort/_m2-sort.scss @@ -1,60 +1,16 @@ -@use 'sass:color'; -@use 'sass:meta'; @use '../core/tokens/m2-utils'; -@use '../core/theming/inspection'; -@use '../core/style/sass-utils'; +@use 'sass:map'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, sort); - -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - @return (); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - $table-background: inspection.get-theme-color($theme, background, card); - $text-color: inspection.get-theme-color($theme, foreground, secondary-text); - $arrow-color: null; - - // Because the arrow is made up of multiple elements that are stacked on top of each other, - // we can't use the semi-transparent color from the theme directly. If the value is a color - // *type*, we convert it into a solid color by taking the opacity from the rgba value and - // using the value to determine the percentage of the background to put into foreground - // when mixing the colors together. Otherwise, if it resolves to something different - // (e.g. it resolves to a CSS variable), we use the color directly. - @if (meta.type-of($table-background) == color and meta.type-of($text-color) == color) { - $text-opacity: color.opacity($text-color); - $arrow-color: color.mix($table-background, rgba($text-color, 1), (1 - $text-opacity) * 100%); - } - @else { - $arrow-color: $text-color; - } +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); @return ( - arrow-color: $arrow-color, + base: (), + color: ( + sort-arrow-color: map.get($system, on-surface), + ), + typography: (), + density: (), ); } -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return (); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) - ); -} diff --git a/src/material/sort/_m3-sort.scss b/src/material/sort/_m3-sort.scss index b9cadc762fdd..ae5785b56fc0 100644 --- a/src/material/sort/_m3-sort.scss +++ b/src/material/sort/_m3-sort.scss @@ -1,18 +1,20 @@ @use 'sass:map'; @use '../core/tokens/m3-utils'; +@use '../core/tokens/m3'; // The prefix used to generate the fully qualified name for tokens in this file. $prefix: (mat, sort); /// Generates custom tokens for the mat-sort. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-sort -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: ( - arrow-color: map.get($systems, md-sys-color, on-surface), - ); +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); - @return m3-utils.namespace($prefix, $tokens, $token-slots); + @return ( + base: (), + color: ( + sort-arrow-color: map.get($system, on-surface), + ), + typography: (), + density: (), + ); } diff --git a/src/material/sort/_sort-theme.scss b/src/material/sort/_sort-theme.scss index b27aa8722189..99797f12190b 100644 --- a/src/material/sort/_sort-theme.scss +++ b/src/material/sort/_sort-theme.scss @@ -1,93 +1,76 @@ @use 'sass:map'; @use './m2-sort'; -@use '../core/style/sass-utils'; +@use './m3-sort'; @use '../core/typography/typography'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; @use '../core/tokens/token-utils'; @mixin base($theme) { + $tokens: map.get(m2-sort.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { + $tokens: map.get(m3-sort.get-tokens($theme), base); } + + @include token-utils.values($tokens); } @mixin color($theme) { + $tokens: map.get(m2-sort.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-sort.$prefix, m2-sort.get-color-tokens($theme)); - } + $tokens: map.get(m3-sort.get-tokens($theme), color); } + + @include token-utils.values($tokens); } @mixin typography($theme) { + $tokens: map.get(m2-sort.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-sort.$prefix, m2-sort.get-typography-tokens($theme)); - } + $tokens: map.get(m3-sort.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } @mixin density($theme) { + $tokens: map.get(m2-sort.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - // There are no M3 density tokens for this component - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-sort.$prefix, m2-sort.get-density-tokens($theme)); - } + $tokens: map.get(m3-sort.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-sort.$prefix, - tokens: m2-sort.get-token-slots(), + namespace: sort, + tokens: token-utils.get-overrides(m3-sort.get-tokens(), sort) ), ); } @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } @mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-sort') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - @include token-utils.create-token-values( - m2-sort.$prefix, - map.get($tokens, m2-sort.$prefix) - ); } } diff --git a/src/material/sort/sort-header.scss b/src/material/sort/sort-header.scss index 54ad6f0e9dea..c5ecfdae4500 100644 --- a/src/material/sort/sort-header.scss +++ b/src/material/sort/sort-header.scss @@ -1,7 +1,9 @@ -@use './m2-sort'; +@use './m3-sort'; @use '../core/tokens/token-utils'; @use '../core/focus-indicators/private'; +$fallbacks: m3-sort.get-tokens(); + .mat-sort-header { cursor: pointer; } @@ -76,10 +78,7 @@ transition: transform $timing, opacity $timing; opacity: 0; overflow: visible; - - @include token-utils.use-tokens(m2-sort.$prefix, m2-sort.get-token-slots()) { - color: token-utils.slot(arrow-color); - } + color: token-utils.slot(sort-arrow-color, $fallbacks); .mat-sort-header.cdk-keyboard-focused &, .mat-sort-header.cdk-program-focused &, diff --git a/src/material/stepper/_m2-stepper.scss b/src/material/stepper/_m2-stepper.scss index 6963ef3b0925..c2fb62f9faff 100644 --- a/src/material/stepper/_m2-stepper.scss +++ b/src/material/stepper/_m2-stepper.scss @@ -1,93 +1,65 @@ @use 'sass:map'; -@use '../core/tokens/m2-utils'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; -@use '../core/style/sass-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, stepper); - -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - @return ( - header-focus-state-layer-shape: 0, - header-hover-state-layer-shape: 0, - ); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - @return map.merge(private-get-color-palette-color-tokens($theme, primary), ( - container-color: inspection.get-theme-color($theme, background, card), - line-color: inspection.get-theme-color($theme, foreground, divider), - header-hover-state-layer-color: inspection.get-theme-color($theme, background, hover), - header-focus-state-layer-color: inspection.get-theme-color($theme, background, hover), - header-label-text-color: inspection.get-theme-color($theme, foreground, secondary-text), - header-optional-label-text-color: inspection.get-theme-color( - $theme, foreground, secondary-text), - header-selected-state-label-text-color: inspection.get-theme-color($theme, foreground, text), - header-error-state-label-text-color: inspection.get-theme-color($theme, warn, text), - header-icon-background-color: inspection.get-theme-color($theme, foreground, secondary-text), - header-error-state-icon-foreground-color: inspection.get-theme-color($theme, warn, text), - header-error-state-icon-background-color: transparent, - )); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return ( - container-text-font: inspection.get-theme-typography($theme, body-2, font-family), - header-label-text-font: inspection.get-theme-typography($theme, body-2, font-family), - header-label-text-size: inspection.get-theme-typography($theme, body-2, font-size), - header-label-text-weight: inspection.get-theme-typography($theme, body-2, font-weight), - header-error-state-label-text-size: inspection.get-theme-typography($theme, body-1, font-size), - header-selected-state-label-text-size: inspection.get-theme-typography( - $theme, body-1, font-size), - header-selected-state-label-text-weight: inspection.get-theme-typography( - $theme, body-1, font-weight), - ); -} +@use '../core/tokens/m2-utils'; +@use '../core/tokens/m3-utils'; -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $scale: theming.clamp-density(inspection.get-theme-density($theme), -4); - $height-config: ( - 0: 72px, - -1: 68px, - -2: 64px, - -3: 60px, - -4: 42px, - ); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); + $density-scale: theming.clamp-density(map.get($system, density-scale), -4); @return ( - header-height: map.get($height-config, $scale), + base: ( + stepper-header-focus-state-layer-shape: 0, + stepper-header-hover-state-layer-shape: 0, + ), + color: map.merge(private-get-color-palette-color-tokens($theme, primary), ( + stepper-container-color: map.get($system, surface), + stepper-line-color: map.get($system, outline), + stepper-header-hover-state-layer-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, hover-state-layer-opacity)), + stepper-header-focus-state-layer-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, focus-state-layer-opacity)), + stepper-header-label-text-color: map.get($system, on-surface-variant), + stepper-header-optional-label-text-color: map.get($system, on-surface-variant), + stepper-header-selected-state-label-text-color: map.get($system, on-surface), + stepper-header-error-state-label-text-color: map.get($system, error), + stepper-header-icon-background-color: map.get($system, on-surface-variant), + stepper-header-error-state-icon-foreground-color: map.get($system, error), + stepper-header-error-state-icon-background-color: transparent) + ), + typography: ( + stepper-container-text-font: map.get($system, body-medium-font), + stepper-header-label-text-font: map.get($system, body-medium-font), + stepper-header-label-text-size: map.get($system, body-medium-size), + stepper-header-label-text-weight: map.get($system, body-medium-weight), + stepper-header-error-state-label-text-size: map.get($system, body-large-size), + stepper-header-selected-state-label-text-size: map.get($system, body-large-size), + stepper-header-selected-state-label-text-weight: map.get($system, body-large-weight), + ), + density: ( + stepper-header-height: map.get(( + 0: 72px, + -1: 68px, + -2: 64px, + -3: 60px, + -4: 42px, + ), $density-scale), + ), ); } // Generates the tokens used to theme the stepper based on a palette. -@function private-get-color-palette-color-tokens($theme, $palette-name) { - $active-state-foreground: inspection.get-theme-color($theme, $palette-name, default-contrast); - $active-state-background: inspection.get-theme-color($theme, $palette-name); +@function private-get-color-palette-color-tokens($theme, $color-variant) { + $system: m2-utils.get-system($theme); + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); @return ( - header-icon-foreground-color: $active-state-foreground, - header-selected-state-icon-background-color: $active-state-background, - header-selected-state-icon-foreground-color: $active-state-foreground, - header-done-state-icon-background-color: $active-state-background, - header-done-state-icon-foreground-color: $active-state-foreground, - header-edit-state-icon-background-color: $active-state-background, - header-edit-state-icon-foreground-color: $active-state-foreground, - ); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + stepper-header-icon-foreground-color: map.get($system, on-primary), + stepper-header-selected-state-icon-background-color: map.get($system, primary), + stepper-header-selected-state-icon-foreground-color: map.get($system, on-primary), + stepper-header-done-state-icon-background-color: map.get($system, primary), + stepper-header-done-state-icon-foreground-color: map.get($system, on-primary), + stepper-header-edit-state-icon-background-color: map.get($system, primary), + stepper-header-edit-state-icon-foreground-color: map.get($system, on-primary), ); } diff --git a/src/material/stepper/_m3-stepper.scss b/src/material/stepper/_m3-stepper.scss index 2aa1884e83c1..433352c141f1 100644 --- a/src/material/stepper/_m3-stepper.scss +++ b/src/material/stepper/_m3-stepper.scss @@ -1,102 +1,66 @@ @use 'sass:map'; @use 'sass:list'; -@use '../core/style/sass-utils'; @use '../core/tokens/m3-utils'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; +@use '../core/tokens/m3'; // The prefix used to generate the fully qualified name for tokens in this file. $prefix: (mat, stepper); /// Generates custom tokens for the mat-stepper. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-stepper -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: ( - container-color: map.get($systems, md-sys-color, surface), - line-color: map.get($systems, md-sys-color, outline), - header-hover-state-layer-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, inverse-surface), - $alpha: map.get($systems, md-sys-state, hover-state-layer-opacity) - ), - header-focus-state-layer-color: sass-utils.safe-color-change( - map.get($systems, md-sys-color, inverse-surface), - $alpha: map.get($systems, md-sys-state, focus-state-layer-opacity) - ), - header-label-text-color: map.get($systems, md-sys-color, on-surface-variant), - header-optional-label-text-color: map.get($systems, md-sys-color, on-surface-variant), - header-selected-state-label-text-color: map.get($systems, md-sys-color, on-surface-variant), - header-icon-background-color: map.get($systems, md-sys-color, on-surface-variant), - header-icon-foreground-color: map.get($systems, md-sys-color, surface), - header-edit-state-icon-background-color: map.get($systems, md-sys-color, primary), - header-edit-state-icon-foreground-color: map.get($systems, md-sys-color, on-primary), - header-done-state-icon-background-color: map.get($systems, md-sys-color, primary), - header-done-state-icon-foreground-color: map.get($systems, md-sys-color, on-primary), - header-error-state-label-text-color: map.get($systems, md-sys-color, error), - header-error-state-icon-foreground-color: map.get($systems, md-sys-color, error), - header-error-state-icon-background-color: - m3-utils.hardcode(transparent, $exclude-hardcoded), - container-text-font: map.get($systems, md-sys-typescale, body-medium-font), - header-label-text-font: map.get($systems, md-sys-typescale, title-small-font), - header-label-text-size: map.get($systems, md-sys-typescale, title-small-size), - header-label-text-weight: map.get($systems, md-sys-typescale, title-small-weight), - header-error-state-label-text-size: map.get($systems, md-sys-typescale, title-small-size), - header-focus-state-layer-shape: map.get($systems, md-sys-shape, corner-medium), - header-hover-state-layer-shape: map.get($systems, md-sys-shape, corner-medium), - header-selected-state-label-text-size: map.get($systems, md-sys-typescale, title-small-size), - header-selected-state-label-text-weight: map.get( - $systems, md-sys-typescale, title-small-weight), - header-selected-state-icon-background-color: map.get($systems, md-sys-color, primary), - header-selected-state-icon-foreground-color: map.get($systems, md-sys-color, on-primary), - ); +/// @param {String} $color-variant The color variant to use for the component +@function get-tokens($theme: m3.$sys-theme, $color-variant: null) { + $system: m3-utils.get-system($theme); + @if $color-variant { + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); + } - $variant-tokens: ( - // Color variants - primary: ( - header-edit-state-icon-background-color: map.get($systems, md-sys-color, primary), - header-edit-state-icon-foreground-color: map.get($systems, md-sys-color, on-primary), - header-done-state-icon-background-color: map.get($systems, md-sys-color, primary), - header-done-state-icon-foreground-color: map.get($systems, md-sys-color, on-primary), - header-selected-state-icon-background-color: map.get($systems, md-sys-color, primary), - header-selected-state-icon-foreground-color: map.get($systems, md-sys-color, on-primary), + @return ( + base: ( + stepper-header-error-state-icon-background-color: transparent, + stepper-header-focus-state-layer-shape: map.get($system, corner-medium), + stepper-header-hover-state-layer-shape: map.get($system, corner-medium), ), - secondary: ( - header-edit-state-icon-background-color: map.get($systems, md-sys-color, secondary), - header-edit-state-icon-foreground-color: map.get($systems, md-sys-color, on-secondary), - header-done-state-icon-background-color: map.get($systems, md-sys-color, secondary), - header-done-state-icon-foreground-color: map.get($systems, md-sys-color, on-secondary), - header-selected-state-icon-background-color: map.get($systems, md-sys-color, secondary), - header-selected-state-icon-foreground-color: map.get($systems, md-sys-color, on-secondary), + color: ( + stepper-container-color: map.get($system, surface), + stepper-header-done-state-icon-background-color: map.get($system, primary), + stepper-header-done-state-icon-foreground-color: map.get($system, on-primary), + stepper-header-edit-state-icon-background-color: map.get($system, primary), + stepper-header-edit-state-icon-foreground-color: map.get($system, on-primary), + stepper-header-error-state-icon-foreground-color: map.get($system, error), + stepper-header-error-state-label-text-color: map.get($system, error), + stepper-header-focus-state-layer-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, focus-state-layer-opacity)), + stepper-header-hover-state-layer-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, hover-state-layer-opacity)), + stepper-header-icon-background-color: map.get($system, on-surface-variant), + stepper-header-icon-foreground-color: map.get($system, surface), + stepper-header-label-text-color: map.get($system, on-surface-variant), + stepper-header-optional-label-text-color: map.get($system, on-surface-variant), + stepper-header-selected-state-icon-background-color: map.get($system, primary), + stepper-header-selected-state-icon-foreground-color: map.get($system, on-primary), + stepper-header-selected-state-label-text-color: map.get($system, on-surface-variant), + stepper-line-color: map.get($system, outline), ), - tertiary: ( - header-edit-state-icon-background-color: map.get($systems, md-sys-color, tertiary), - header-edit-state-icon-foreground-color: map.get($systems, md-sys-color, on-tertiary), - header-done-state-icon-background-color: map.get($systems, md-sys-color, tertiary), - header-done-state-icon-foreground-color: map.get($systems, md-sys-color, on-tertiary), - header-selected-state-icon-background-color: map.get($systems, md-sys-color, tertiary), - header-selected-state-icon-foreground-color: map.get($systems, md-sys-color, on-tertiary), + typography: ( + stepper-container-text-font: map.get($system, body-medium-font), + stepper-header-label-text-font: map.get($system, title-small-font), + stepper-header-label-text-size: map.get($system, title-small-size), + stepper-header-label-text-weight: map.get($system, title-small-weight), + stepper-header-error-state-label-text-size: map.get($system, title-small-size), + stepper-header-selected-state-label-text-size: map.get($system, title-small-size), + stepper-header-selected-state-label-text-weight: map.get($system, title-small-weight), ), - error: ( - header-edit-state-icon-background-color: map.get($systems, md-sys-color, error), - header-edit-state-icon-foreground-color: map.get($systems, md-sys-color, on-error), - header-done-state-icon-background-color: map.get($systems, md-sys-color, error), - header-done-state-icon-foreground-color: map.get($systems, md-sys-color, on-error), - header-selected-state-icon-background-color: map.get($systems, md-sys-color, error), - header-selected-state-icon-foreground-color: map.get($systems, md-sys-color, on-error), - ) + density: get-density-tokens(map.get($system, density-scale)), ); - - @return m3-utils.namespace($prefix, ($tokens, $variant-tokens), $token-slots); } // Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $scale: theming.clamp-density(inspection.get-theme-density($theme), -4); +@function get-density-tokens($scale) { + $scale: theming.clamp-density(scale, -4); $index: ($scale * -1) + 1; @return ( - header-height: list.nth((72px, 68px, 64px, 60px, 42px), $index), + stepper-header-height: list.nth((72px, 68px, 64px, 60px, 42px), $index), ); } diff --git a/src/material/stepper/_stepper-theme.scss b/src/material/stepper/_stepper-theme.scss index 924facb0acd2..326be2bf2040 100644 --- a/src/material/stepper/_stepper-theme.scss +++ b/src/material/stepper/_stepper-theme.scss @@ -1,91 +1,70 @@ -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; @use '../core/typography/typography'; -@use '../core/style/sass-utils'; @use '../core/tokens/token-utils'; @use './m2-stepper'; @use './m3-stepper'; +@use 'sass:map'; /// Outputs base theme styles (styles not dependent on the color, typography, or density settings) /// for mat-stepper. /// @param {Map} $theme The theme to generate base styles for. @mixin base($theme) { + $tokens: map.get(m2-stepper.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { + $tokens: map.get(m3-stepper.get-tokens($theme), base); } + + @include token-utils.values($tokens); } /// Outputs color theme styles for mat-stepper. /// @param {Map} $theme The theme to generate color styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the stepper: primary, secondary, -/// tertiary, or error (If not specified, primary color values will be used). -@mixin color($theme, $options...) { +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin color($theme, $color-variant: null) { + $tokens: map.get(m2-stepper.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color), $options...); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-stepper.$prefix, - m2-stepper.get-color-tokens($theme) - ); + $tokens: map.get(m3-stepper.get-tokens($theme, $color-variant), color); + } + + @include token-utils.values($tokens); - .mat-step-header.mat-accent { - @include token-utils.create-token-values-mixed( - m2-stepper.$prefix, - m2-stepper.private-get-color-palette-color-tokens($theme, accent) - ); - } + @if inspection.get-theme-version($theme) != 1 { + .mat-step-header.mat-accent { + $tokens: m2-stepper.private-get-color-palette-color-tokens($theme, secondary); + @include token-utils.values($tokens); + } - .mat-step-header.mat-warn { - @include token-utils.create-token-values-mixed( - m2-stepper.$prefix, - m2-stepper.private-get-color-palette-color-tokens($theme, warn) - ); - } + .mat-step-header.mat-warn { + $tokens: m2-stepper.private-get-color-palette-color-tokens($theme, error); + @include token-utils.values($tokens); } } } -/// Outputs typography theme styles for mat-stepper. -/// @param {Map} $theme The theme to generate typography styles for. @mixin typography($theme) { + $tokens: map.get(m2-stepper.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-stepper.$prefix, - m2-stepper.get-typography-tokens($theme) - ); - } + $tokens: map.get(m3-stepper.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } -/// Outputs density theme styles for mat-stepper. -/// @param {Map} $theme The theme to generate density styles for. @mixin density($theme) { + $tokens: map.get(m2-stepper.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - @include token-utils.create-token-values( - m3-stepper.$prefix, m3-stepper.get-density-tokens($theme)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-stepper.$prefix, - m2-stepper.get-density-tokens($theme) - ); - } + $tokens: map.get(m3-stepper.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-stepper.$prefix, - tokens: m2-stepper.get-token-slots(), + namespace: stepper, + tokens: token-utils.get-overrides(m3-stepper.get-tokens(), stepper) ), ); } @@ -93,46 +72,31 @@ /// Outputs the CSS variable values for the given tokens. /// @param {Map} $tokens The token values to emit. @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } /// Outputs all (base, color, typography, and density) theme styles for mat-stepper. /// @param {Map} $theme The theme to generate color styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the stepper: primary, secondary, -/// tertiary, or error (If not specified, primary color values will be used). -@mixin theme($theme, $options...) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-stepper') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme), $options...); - @if inspection.theme-has($theme, density) { - @include density($theme); - } - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin theme($theme, $color-variant: null) { + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme, $color-variant); + @include density($theme); + @include typography($theme); + @if inspection.theme-has($theme, density) { + @include density($theme); + } + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens, $options...) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - $mat-stepper-tokens: token-utils.get-tokens-for( - $tokens, - m2-stepper.$prefix, - $options... - ); - @include token-utils.create-token-values(m2-stepper.$prefix, $mat-stepper-tokens); } } diff --git a/src/material/stepper/step-header.html b/src/material/stepper/step-header.html index 19ade872e7a4..99b65a5643a4 100644 --- a/src/material/stepper/step-header.html +++ b/src/material/stepper/step-header.html @@ -7,7 +7,7 @@ @if (iconOverrides && iconOverrides[state]) { + [ngTemplateOutletContext]="{index, active, optional}"> } @else { @switch (state) { @case ('number') { diff --git a/src/material/stepper/step-header.scss b/src/material/stepper/step-header.scss index 92e241eac38a..5c4f025dbf8c 100644 --- a/src/material/stepper/step-header.scss +++ b/src/material/stepper/step-header.scss @@ -1,11 +1,10 @@ @use '@angular/cdk'; @use '../core/style/layout-common'; @use '../core/tokens/token-utils'; -@use './m2-stepper'; +@use './m3-stepper'; @use './stepper-variables'; -$token-prefix: m2-stepper.$prefix; -$token-slots: m2-stepper.get-token-slots(); +$fallbacks: m3-stepper.get-tokens(); .mat-step-header { overflow: hidden; @@ -25,19 +24,17 @@ $token-slots: m2-stepper.get-token-slots(); cursor: default; } - @include token-utils.use-tokens($token-prefix, $token-slots) { - &:hover:not([aria-disabled]), - &:hover[aria-disabled='false'] { - background-color: token-utils.slot(header-hover-state-layer-color); - border-radius: token-utils.slot(header-hover-state-layer-shape); - } + &:hover:not([aria-disabled]), + &:hover[aria-disabled='false'] { + background-color: token-utils.slot(stepper-header-hover-state-layer-color, $fallbacks); + border-radius: token-utils.slot(stepper-header-hover-state-layer-shape, $fallbacks); + } - &.cdk-keyboard-focused, - &.cdk-program-focused { - background-color: token-utils.slot(header-focus-state-layer-color); - border-radius: token-utils.slot(header-focus-state-layer-shape); - } + &.cdk-keyboard-focused, + &.cdk-program-focused { + background-color: token-utils.slot(stepper-header-focus-state-layer-color, $fallbacks); + border-radius: token-utils.slot(stepper-header-focus-state-layer-shape, $fallbacks); } // On touch devices the :hover state will linger on the element after a tap. @@ -73,10 +70,7 @@ $token-slots: m2-stepper.get-token-slots(); .mat-step-optional { font-size: stepper-variables.$step-sub-label-font-size; - - @include token-utils.use-tokens($token-prefix, $token-slots) { - color: token-utils.slot(header-optional-label-text-color); - } + color: token-utils.slot(stepper-header-optional-label-text-color, $fallbacks); } .mat-step-sub-label-error { @@ -90,11 +84,8 @@ $token-slots: m2-stepper.get-token-slots(); width: stepper-variables.$label-header-height; flex-shrink: 0; position: relative; - - @include token-utils.use-tokens($token-prefix, $token-slots) { - color: token-utils.slot(header-icon-foreground-color); - background-color: token-utils.slot(header-icon-background-color); - } + color: token-utils.slot(stepper-header-icon-foreground-color, $fallbacks); + background-color: token-utils.slot(stepper-header-icon-background-color, $fallbacks); } .mat-step-icon-content { @@ -116,10 +107,8 @@ $token-slots: m2-stepper.get-token-slots(); } .mat-step-icon-state-error { - @include token-utils.use-tokens($token-prefix, $token-slots) { - background-color: token-utils.slot(header-error-state-icon-background-color); - color: token-utils.slot(header-error-state-icon-foreground-color); - } + background-color: token-utils.slot(stepper-header-error-state-icon-background-color, $fallbacks); + color: token-utils.slot(stepper-header-error-state-icon-foreground-color, $fallbacks); .mat-icon { font-size: stepper-variables.$step-header-icon-size + 8; @@ -136,25 +125,23 @@ $token-slots: m2-stepper.get-token-slots(); min-width: stepper-variables.$label-min-width; vertical-align: middle; - @include token-utils.use-tokens($token-prefix, $token-slots) { - font-family: token-utils.slot(header-label-text-font); - font-size: token-utils.slot(header-label-text-size); - font-weight: token-utils.slot(header-label-text-weight); - color: token-utils.slot(header-label-text-color); + font-family: token-utils.slot(stepper-header-label-text-font, $fallbacks); + font-size: token-utils.slot(stepper-header-label-text-size, $fallbacks); + font-weight: token-utils.slot(stepper-header-label-text-weight, $fallbacks); + color: token-utils.slot(stepper-header-label-text-color, $fallbacks); - &.mat-step-label-active { - color: token-utils.slot(header-selected-state-label-text-color); - } + &.mat-step-label-active { + color: token-utils.slot(stepper-header-selected-state-label-text-color, $fallbacks); + } - &.mat-step-label-error { - color: token-utils.slot(header-error-state-label-text-color); - font-size: token-utils.slot(header-error-state-label-text-size); - } + &.mat-step-label-error { + color: token-utils.slot(stepper-header-error-state-label-text-color, $fallbacks); + font-size: token-utils.slot(stepper-header-error-state-label-text-size, $fallbacks); + } - &.mat-step-label-selected { - font-size: token-utils.slot(header-selected-state-label-text-size); - font-weight: token-utils.slot(header-selected-state-label-text-weight); - } + &.mat-step-label-selected { + font-size: token-utils.slot(stepper-header-selected-state-label-text-size, $fallbacks); + font-weight: token-utils.slot(stepper-header-selected-state-label-text-weight, $fallbacks); } } @@ -171,22 +158,17 @@ $token-slots: m2-stepper.get-token-slots(); } .mat-step-icon-selected { - @include token-utils.use-tokens($token-prefix, $token-slots) { - background-color: token-utils.slot(header-selected-state-icon-background-color); - color: token-utils.slot(header-selected-state-icon-foreground-color); - } + background-color: token-utils.slot( + stepper-header-selected-state-icon-background-color, $fallbacks); + color: token-utils.slot(stepper-header-selected-state-icon-foreground-color, $fallbacks); } .mat-step-icon-state-done { - @include token-utils.use-tokens($token-prefix, $token-slots) { - background-color: token-utils.slot(header-done-state-icon-background-color); - color: token-utils.slot(header-done-state-icon-foreground-color); - } + background-color: token-utils.slot(stepper-header-done-state-icon-background-color, $fallbacks); + color: token-utils.slot(stepper-header-done-state-icon-foreground-color, $fallbacks); } .mat-step-icon-state-edit { - @include token-utils.use-tokens($token-prefix, $token-slots) { - background-color: token-utils.slot(header-edit-state-icon-background-color); - color: token-utils.slot(header-edit-state-icon-foreground-color); - } + background-color: token-utils.slot(stepper-header-edit-state-icon-background-color, $fallbacks); + color: token-utils.slot(stepper-header-edit-state-icon-foreground-color, $fallbacks); } diff --git a/src/material/stepper/step-header.ts b/src/material/stepper/step-header.ts index c6f1815e0b5e..998e5d3610ad 100644 --- a/src/material/stepper/step-header.ts +++ b/src/material/stepper/step-header.ts @@ -76,10 +76,10 @@ export class MatStepHeader extends CdkStepHeader implements AfterViewInit, OnDes /** * Theme color of the step header. This API is supported in M2 themes only, it - * has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/stepper/styling. + * has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/stepper/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() color: ThemePalette; @@ -128,15 +128,6 @@ export class MatStepHeader extends CdkStepHeader implements AfterViewInit, OnDes return this._elementRef.nativeElement; } - /** Template context variables that are exposed to the `matStepperIcon` instances. */ - _getIconContext(): MatStepperIconContext { - return { - index: this.index, - active: this.active, - optional: this.optional, - }; - } - _getDefaultTextForState(state: StepState): string { if (state == 'number') { return `${this.index + 1}`; diff --git a/src/material/stepper/stepper.html b/src/material/stepper/stepper.html index a75e48522600..0f75e58c012b 100644 --- a/src/material/stepper/stepper.html +++ b/src/material/stepper/stepper.html @@ -15,7 +15,7 @@ @for (step of steps; track step) { + [ngTemplateOutletContext]="{step}"/> @if (!$last) {
    } @@ -44,7 +44,7 @@
    + [ngTemplateOutletContext]="{step}"/>
    - + diff --git a/src/material/stepper/stepper.scss b/src/material/stepper/stepper.scss index a271ddd4b11e..eace322adeb1 100644 --- a/src/material/stepper/stepper.scss +++ b/src/material/stepper/stepper.scss @@ -1,26 +1,22 @@ @use 'sass:math'; @use '@angular/cdk'; -@use './m2-stepper'; +@use './m3-stepper'; @use '../core/tokens/token-utils'; @use './stepper-variables'; // Gets the `calc` expression for the vertical padding of the stepper header. @function _get-vertical-padding-calc() { - $height: token-utils.slot(header-height); + $height: token-utils.slot(stepper-header-height, $fallbacks); @return calc(calc(#{$height} - #{stepper-variables.$label-header-height}) / 2); } -$token-prefix: m2-stepper.$prefix; -$token-slots: m2-stepper.get-token-slots(); +$fallbacks: m3-stepper.get-tokens(); .mat-stepper-vertical, .mat-stepper-horizontal { display: block; - - @include token-utils.use-tokens($token-prefix, $token-slots) { - font-family: token-utils.slot(container-text-font); - background: token-utils.slot(container-color); - } + font-family: token-utils.slot(stepper-container-text-font, $fallbacks); + background: token-utils.slot(stepper-container-color, $fallbacks); } .mat-horizontal-stepper-header-container { @@ -44,19 +40,16 @@ $token-slots: m2-stepper.get-token-slots(); height: 0; margin: 0 stepper-variables.$line-gap - stepper-variables.$side-gap; min-width: stepper-variables.$line-gap + stepper-variables.$side-gap; + border-top-color: token-utils.slot(stepper-line-color, $fallbacks); - @include token-utils.use-tokens($token-prefix, $token-slots) { - border-top-color: token-utils.slot(line-color); - - .mat-stepper-label-position-bottom & { - $vertical-padding: _get-vertical-padding-calc(); - margin: 0; - min-width: 0; - position: relative; + .mat-stepper-label-position-bottom & { + $vertical-padding: _get-vertical-padding-calc(); + margin: 0; + min-width: 0; + position: relative; - // Ensures that the horizontal line for the step content is aligned centered vertically. - top: calc(#{$vertical-padding} + #{math.div(stepper-variables.$label-header-height, 2)}); - } + // Ensures that the horizontal line for the step content is aligned centered vertically. + top: calc(#{$vertical-padding} + #{math.div(stepper-variables.$label-header-height, 2)}); } } @@ -73,14 +66,10 @@ $token-slots: m2-stepper.get-token-slots(); .mat-horizontal-stepper-header { display: flex; - height: stepper-variables.$header-height; overflow: hidden; align-items: center; padding: 0 stepper-variables.$side-gap; - - @include token-utils.use-tokens($token-prefix, $token-slots) { - height: token-utils.slot(header-height); - } + height: token-utils.slot(stepper-header-height, $fallbacks); .mat-step-icon { margin-right: stepper-variables.$line-gap; @@ -92,22 +81,20 @@ $token-slots: m2-stepper.get-token-slots(); } } - @include token-utils.use-tokens($token-prefix, $token-slots) { - $vertical-padding: _get-vertical-padding-calc(); + $vertical-padding: _get-vertical-padding-calc(); - &::before, - &::after { - border-top-color: token-utils.slot(line-color); - } + &::before, + &::after { + border-top-color: token-utils.slot(stepper-line-color, $fallbacks); + } - .mat-stepper-label-position-bottom & { - padding: #{$vertical-padding} stepper-variables.$side-gap; + .mat-stepper-label-position-bottom & { + padding: #{$vertical-padding} stepper-variables.$side-gap; - &::before, - &::after { - // Ensures that the horizontal lines for the step header are centered vertically. - top: calc(#{$vertical-padding} + #{math.div(stepper-variables.$label-header-height, 2)}); - } + &::before, + &::after { + // Ensures that the horizontal lines for the step header are centered vertically. + top: calc(#{$vertical-padding} + #{math.div(stepper-variables.$label-header-height, 2)}); } } @@ -156,9 +143,7 @@ $token-slots: m2-stepper.get-token-slots(); // We can't use `max-height` here, because it breaks the flexbox centering in IE. height: stepper-variables.$label-header-height; - @include token-utils.use-tokens($token-prefix, $token-slots) { - padding: #{_get-vertical-padding-calc()} stepper-variables.$side-gap; - } + padding: #{_get-vertical-padding-calc()} stepper-variables.$side-gap; .mat-step-icon { margin-right: stepper-variables.$vertical-stepper-content-margin - stepper-variables.$side-gap; @@ -271,17 +256,15 @@ $token-slots: m2-stepper.get-token-slots(); border-left-width: stepper-variables.$line-width; border-left-style: solid; - @include token-utils.use-tokens($token-prefix, $token-slots) { - $vertical-padding: _get-vertical-padding-calc(); - $vertical-offset: calc(#{stepper-variables.$line-gap} - #{$vertical-padding}); + $vertical-padding: _get-vertical-padding-calc(); + $vertical-offset: calc(#{stepper-variables.$line-gap} - #{$vertical-padding}); - border-left-color: token-utils.slot(line-color); + border-left-color: token-utils.slot(stepper-line-color, $fallbacks); - // Ensures that the vertical lines for the step content exceed into the step - // headers with a given distance (`$mat-stepper-line-gap`) to the step icon. - top: $vertical-offset; - bottom: $vertical-offset; - } + // Ensures that the vertical lines for the step content exceed into the step + // headers with a given distance (`$mat-stepper-line-gap`) to the step icon. + top: $vertical-offset; + bottom: $vertical-offset; [dir='rtl'] & { left: auto; diff --git a/src/material/stepper/stepper.spec.ts b/src/material/stepper/stepper.spec.ts index 959e36d93a3e..ad62b508bb8e 100644 --- a/src/material/stepper/stepper.spec.ts +++ b/src/material/stepper/stepper.spec.ts @@ -1,4 +1,4 @@ -import {Direction, Directionality} from '@angular/cdk/bidi'; +import {Direction} from '@angular/cdk/bidi'; import { DOWN_ARROW, END, @@ -20,17 +20,18 @@ import { createKeyboardEvent, dispatchEvent, dispatchKeyboardEvent, + provideFakeDirectionality, } from '@angular/cdk/testing/private'; import { Component, DebugElement, - EventEmitter, Provider, QueryList, Type, ViewChild, ViewChildren, ViewEncapsulation, + WritableSignal, inject, signal, } from '@angular/core'; @@ -45,26 +46,22 @@ import { ValidationErrors, Validators, } from '@angular/forms'; -import {MATERIAL_ANIMATIONS, MatRipple, ThemePalette} from '../core'; -import {MatFormFieldModule} from '../form-field'; -import {MatInputModule} from '../input'; import {By} from '@angular/platform-browser'; import {Observable, Subject, merge} from 'rxjs'; import {map, take} from 'rxjs/operators'; +import {MATERIAL_ANIMATIONS, MatRipple, ThemePalette} from '../core'; +import {MatInputModule} from '../input'; import {MatStepHeader, MatStepperModule} from './index'; import {MatStep, MatStepper} from './stepper'; import {MatStepperNext, MatStepperPrevious} from './stepper-button'; import {MatStepperIntl} from './stepper-intl'; const VALID_REGEX = /valid/; -let dir: {value: Direction; readonly change: EventEmitter}; +let dir: WritableSignal; describe('MatStepper', () => { beforeEach(() => { - dir = { - value: 'ltr', - change: new EventEmitter(), - }; + dir = signal('ltr'); }); describe('basic stepper', () => { @@ -76,59 +73,45 @@ describe('MatStepper', () => { }); it('should default to the first step', () => { - const stepperComponent: MatStepper = fixture.debugElement.query( - By.css('mat-stepper'), - )!.componentInstance; - - expect(stepperComponent.selectedIndex).toBe(0); + expect(fixture.componentInstance.stepper.selectedIndex).toBe(0); }); it('should throw when a negative `selectedIndex` is assigned', () => { - const stepperComponent: MatStepper = fixture.debugElement.query( - By.css('mat-stepper'), - )!.componentInstance; - expect(() => { - stepperComponent.selectedIndex = -10; + fixture.componentInstance.stepper.selectedIndex = -10; fixture.detectChanges(); }).toThrowError(/Cannot assign out-of-bounds/); }); it('should throw when an out-of-bounds `selectedIndex` is assigned', () => { - const stepperComponent: MatStepper = fixture.debugElement.query( - By.css('mat-stepper'), - )!.componentInstance; - expect(() => { - stepperComponent.selectedIndex = 1337; + fixture.componentInstance.stepper.selectedIndex = 1337; fixture.detectChanges(); }).toThrowError(/Cannot assign out-of-bounds/); }); it('should change selected index on header click', () => { const stepHeaders = fixture.debugElement.queryAll(By.css('.mat-vertical-stepper-header')); - const stepperComponent = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = fixture.componentInstance.stepper; - expect(stepperComponent.selectedIndex).toBe(0); - expect(stepperComponent.selected instanceof MatStep).toBe(true); + expect(stepper.selectedIndex).toBe(0); + expect(stepper.selected instanceof MatStep).toBe(true); // select the second step let stepHeaderEl = stepHeaders[1].nativeElement; stepHeaderEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); - expect(stepperComponent.selected instanceof MatStep).toBe(true); + expect(stepper.selectedIndex).toBe(1); + expect(stepper.selected instanceof MatStep).toBe(true); // select the third step stepHeaderEl = stepHeaders[2].nativeElement; stepHeaderEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(2); - expect(stepperComponent.selected instanceof MatStep).toBe(true); + expect(stepper.selectedIndex).toBe(2); + expect(stepper.selected instanceof MatStep).toBe(true); }); it('should set the "tablist" role on stepper', () => { @@ -137,13 +120,10 @@ describe('MatStepper', () => { }); it('should display the correct label', () => { - const stepperComponent = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; let selectedLabel = fixture.nativeElement.querySelector('[aria-selected="true"]'); expect(selectedLabel.textContent).toMatch('Step 1'); - stepperComponent.selectedIndex = 2; + fixture.componentInstance.stepper.selectedIndex = 2; fixture.detectChanges(); selectedLabel = fixture.nativeElement.querySelector('[aria-selected="true"]'); @@ -157,32 +137,30 @@ describe('MatStepper', () => { }); it('should go to next available step when the next button is clicked', () => { - const stepperComponent = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = fixture.componentInstance.stepper; - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper.selectedIndex).toBe(0); let nextButtonNativeEl = fixture.debugElement.queryAll(By.directive(MatStepperNext))[0] .nativeElement; nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); nextButtonNativeEl = fixture.debugElement.queryAll(By.directive(MatStepperNext))[1] .nativeElement; nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(2); + expect(stepper.selectedIndex).toBe(2); nextButtonNativeEl = fixture.debugElement.queryAll(By.directive(MatStepperNext))[2] .nativeElement; nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(2); + expect(stepper.selectedIndex).toBe(2); }); it('should set the next stepper button type to "submit"', () => { @@ -193,34 +171,32 @@ describe('MatStepper', () => { }); it('should go to previous available step when the previous button is clicked', () => { - const stepperComponent = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = fixture.componentInstance.stepper; - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper.selectedIndex).toBe(0); - stepperComponent.selectedIndex = 2; + stepper.selectedIndex = 2; let previousButtonNativeEl = fixture.debugElement.queryAll( By.directive(MatStepperPrevious), )[2].nativeElement; previousButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); previousButtonNativeEl = fixture.debugElement.queryAll(By.directive(MatStepperPrevious))[1] .nativeElement; previousButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper.selectedIndex).toBe(0); previousButtonNativeEl = fixture.debugElement.queryAll(By.directive(MatStepperPrevious))[0] .nativeElement; previousButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper.selectedIndex).toBe(0); }); it('should set the previous stepper button type to "button"', () => { @@ -231,33 +207,29 @@ describe('MatStepper', () => { }); it('should set the correct step position for animation', () => { - const stepperComponent = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = fixture.componentInstance.stepper; - expect(stepperComponent._getAnimationDirection(0)).toBe('current'); - expect(stepperComponent._getAnimationDirection(1)).toBe('next'); - expect(stepperComponent._getAnimationDirection(2)).toBe('next'); + expect(stepper._getAnimationDirection(0)).toBe('current'); + expect(stepper._getAnimationDirection(1)).toBe('next'); + expect(stepper._getAnimationDirection(2)).toBe('next'); - stepperComponent.selectedIndex = 1; + stepper.selectedIndex = 1; fixture.detectChanges(); - expect(stepperComponent._getAnimationDirection(0)).toBe('previous'); - expect(stepperComponent._getAnimationDirection(2)).toBe('next'); - expect(stepperComponent._getAnimationDirection(1)).toBe('current'); + expect(stepper._getAnimationDirection(0)).toBe('previous'); + expect(stepper._getAnimationDirection(2)).toBe('next'); + expect(stepper._getAnimationDirection(1)).toBe('current'); - stepperComponent.selectedIndex = 2; + stepper.selectedIndex = 2; fixture.detectChanges(); - expect(stepperComponent._getAnimationDirection(0)).toBe('previous'); - expect(stepperComponent._getAnimationDirection(1)).toBe('previous'); - expect(stepperComponent._getAnimationDirection(2)).toBe('current'); + expect(stepper._getAnimationDirection(0)).toBe('previous'); + expect(stepper._getAnimationDirection(1)).toBe('previous'); + expect(stepper._getAnimationDirection(2)).toBe('current'); }); it('should not set focus on header of selected step if header is not clicked', () => { - const stepperComponent = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = fixture.componentInstance.stepper; const stepHeaderEl = fixture.debugElement.queryAll(By.css('mat-step-header'))[1] .nativeElement; const nextButtonNativeEl = fixture.debugElement.queryAll(By.directive(MatStepperNext))[0] @@ -266,14 +238,12 @@ describe('MatStepper', () => { nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); expect(stepHeaderEl.focus).not.toHaveBeenCalled(); }); it('should focus next step header if focus is inside the stepper', () => { - const stepperComponent = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = fixture.componentInstance.stepper; const stepHeaderEl = fixture.debugElement.queryAll(By.css('mat-step-header'))[1] .nativeElement; const nextButtonNativeEl = fixture.debugElement.queryAll(By.directive(MatStepperNext))[0] @@ -283,7 +253,7 @@ describe('MatStepper', () => { nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); expect(stepHeaderEl.focus).toHaveBeenCalled(); }); @@ -294,12 +264,10 @@ describe('MatStepper', () => { fixture.destroy(); TestBed.resetTestingModule(); - fixture = createComponent(SimpleMatVerticalStepperApp, [], [], ViewEncapsulation.ShadowDom); + fixture = createComponent(SimpleMatVerticalStepperApp, [], ViewEncapsulation.ShadowDom); fixture.detectChanges(); - const stepperComponent = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = fixture.componentInstance.stepper; const stepHeaderEl = fixture.debugElement.queryAll(By.css('mat-step-header'))[1] .nativeElement; const nextButtonNativeEl = fixture.debugElement.queryAll(By.directive(MatStepperNext))[0] @@ -309,62 +277,56 @@ describe('MatStepper', () => { nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); expect(stepHeaderEl.focus).toHaveBeenCalled(); }); it('should only be able to return to a previous step if it is editable', () => { - const stepperComponent = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = fixture.componentInstance.stepper; - stepperComponent.selectedIndex = 1; - stepperComponent.steps.toArray()[0].editable = false; + stepper.selectedIndex = 1; + stepper.steps.toArray()[0].editable = false; const previousButtonNativeEl = fixture.debugElement.queryAll( By.directive(MatStepperPrevious), )[1].nativeElement; previousButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); - stepperComponent.steps.toArray()[0].editable = true; + stepper.steps.toArray()[0].editable = true; previousButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper.selectedIndex).toBe(0); }); it('should set create icon if step is editable and completed', () => { - const stepperComponent = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = fixture.componentInstance.stepper; const nextButtonNativeEl = fixture.debugElement.queryAll(By.directive(MatStepperNext))[0] .nativeElement; - expect(stepperComponent._getIndicatorType(0)).toBe('number'); - stepperComponent.steps.toArray()[0].editable = true; + expect(stepper.steps.first.indicatorType()).toBe('number'); + stepper.steps.toArray()[0].editable = true; nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent._getIndicatorType(0)).toBe('edit'); + expect(stepper.steps.first.indicatorType()).toBe('edit'); }); it('should set done icon if step is not editable and is completed', () => { - const stepperComponent = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = fixture.componentInstance.stepper; const nextButtonNativeEl = fixture.debugElement.queryAll(By.directive(MatStepperNext))[0] .nativeElement; - expect(stepperComponent._getIndicatorType(0)).toBe('number'); - stepperComponent.steps.toArray()[0].editable = false; + expect(stepper.steps.first.indicatorType()).toBe('number'); + stepper.steps.toArray()[0].editable = false; nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent._getIndicatorType(0)).toBe('done'); + expect(stepper.steps.first.indicatorType()).toBe('done'); }); it('should emit an event when the enter animation is done', () => { - const stepper = fixture.debugElement.query(By.directive(MatStepper))!.componentInstance; + const stepper = fixture.componentInstance.stepper; const selectionChangeSpy = jasmine.createSpy('selectionChange spy'); const animationDoneSpy = jasmine.createSpy('animationDone spy'); const selectionChangeSubscription = stepper.selectionChange.subscribe(selectionChangeSpy); @@ -390,22 +352,20 @@ describe('MatStepper', () => { }); it('should adjust the index when removing a step before the current one', () => { - const stepperComponent: MatStepper = fixture.debugElement.query( - By.css('mat-stepper'), - )!.componentInstance; + const stepper = fixture.componentInstance.stepper; - stepperComponent.selectedIndex = 2; + stepper.selectedIndex = 2; fixture.detectChanges(); // Re-assert since the setter has some extra logic. - expect(stepperComponent.selectedIndex).toBe(2); + expect(stepper.selectedIndex).toBe(2); expect(() => { fixture.componentInstance.showStepTwo.set(false); fixture.detectChanges(); }).not.toThrow(); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); }); it('should not do anything when pressing the ENTER key with a modifier', () => { @@ -434,35 +394,32 @@ describe('MatStepper', () => { }); it('should add units to unit-less values passed in to animationDuration', () => { - const stepperComponent: MatStepper = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; - - stepperComponent.animationDuration = '1337'; - expect(stepperComponent.animationDuration).toBe('1337ms'); + const stepper = fixture.componentInstance.stepper; + stepper.animationDuration = '1337'; + expect(stepper.animationDuration).toBe('1337ms'); }); }); describe('basic stepper when attempting to set the selected step too early', () => { it('should not throw', () => { const fixture = createComponent(SimpleMatVerticalStepperApp); - const stepperComponent: MatStepper = fixture.debugElement.query( + const stepper: MatStepper = fixture.debugElement.query( By.css('mat-stepper'), )!.componentInstance; - expect(() => stepperComponent.selected).not.toThrow(); + expect(() => stepper.selected).not.toThrow(); }); }); describe('basic stepper when attempting to set the selected step too early', () => { it('should not throw', () => { const fixture = createComponent(SimpleMatVerticalStepperApp); - const stepperComponent: MatStepper = fixture.debugElement.query( + const stepper: MatStepper = fixture.debugElement.query( By.css('mat-stepper'), )!.componentInstance; - expect(() => (stepperComponent.selected = null!)).not.toThrow(); - expect(stepperComponent.selectedIndex).toBe(-1); + expect(() => (stepper.selected = null!)).not.toThrow(); + expect(stepper.selectedIndex).toBe(-1); }); }); @@ -500,14 +457,14 @@ describe('MatStepper', () => { it('should re-render when the completed labels change', () => { const intl = TestBed.inject(MatStepperIntl); - const stepperDebugElement = fixture.debugElement.query(By.directive(MatStepper))!; - const stepperComponent: MatStepper = stepperDebugElement.componentInstance; + const stepperElement = fixture.nativeElement.querySelector('mat-stepper'); + const stepper = fixture.componentInstance.stepper; - stepperComponent.steps.toArray()[0].editable = false; - stepperComponent.next(); + stepper.steps.toArray()[0].editable = false; + stepper.next(); fixture.detectChanges(); - const header = stepperDebugElement.nativeElement.querySelector('mat-step-header'); + const header = stepperElement.querySelector('mat-step-header'); const completedLabel = header.querySelector('.cdk-visually-hidden'); expect(completedLabel).toBeTruthy(); @@ -527,18 +484,19 @@ describe('MatStepper', () => { beforeEach(() => { fixture = createComponent(SimpleMatHorizontalStepperApp); fixture.detectChanges(); + fixture.detectChanges(); }); it('should re-render when the editable label changes', () => { const intl = TestBed.inject(MatStepperIntl); - const stepperDebugElement = fixture.debugElement.query(By.directive(MatStepper))!; - const stepperComponent: MatStepper = stepperDebugElement.componentInstance; + const stepperElement = fixture.nativeElement.querySelector('mat-stepper'); + const stepper = fixture.componentInstance.stepper; - stepperComponent.steps.toArray()[0].editable = true; - stepperComponent.next(); + stepper.steps.toArray()[0].editable = true; + stepper.next(); fixture.detectChanges(); - const header = stepperDebugElement.nativeElement.querySelector('mat-step-header'); + const header = stepperElement.querySelector('mat-step-header'); const editableLabel = header.querySelector('.cdk-visually-hidden'); expect(editableLabel).toBeTruthy(); @@ -561,34 +519,34 @@ describe('MatStepper', () => { }); it('should allow for the `edit` icon to be overridden', () => { - const stepperDebugElement = fixture.debugElement.query(By.directive(MatStepper))!; - const stepperComponent: MatStepper = stepperDebugElement.componentInstance; + const stepperElement = fixture.nativeElement.querySelector('mat-stepper'); + const stepper = fixture.componentInstance.stepper; - stepperComponent.steps.toArray()[0].editable = true; - stepperComponent.next(); + stepper.steps.toArray()[0].editable = true; + stepper.next(); fixture.detectChanges(); - const header = stepperDebugElement.nativeElement.querySelector('mat-step-header'); + const header = stepperElement.querySelector('mat-step-header'); expect(header.textContent).toContain('Custom edit'); }); it('should allow for the `done` icon to be overridden', () => { - const stepperDebugElement = fixture.debugElement.query(By.directive(MatStepper))!; - const stepperComponent: MatStepper = stepperDebugElement.componentInstance; + const stepperElement = fixture.nativeElement.querySelector('mat-stepper'); + const stepper = fixture.componentInstance.stepper; - stepperComponent.steps.toArray()[0].editable = false; - stepperComponent.next(); + stepper.steps.toArray()[0].editable = false; + stepper.next(); fixture.detectChanges(); - const header = stepperDebugElement.nativeElement.querySelector('mat-step-header'); + const header = stepperElement.querySelector('mat-step-header'); expect(header.textContent).toContain('Custom done'); }); it('should allow for the `number` icon to be overridden with context', () => { - const stepperDebugElement = fixture.debugElement.query(By.directive(MatStepper))!; - const headers = stepperDebugElement.nativeElement.querySelectorAll('mat-step-header'); + const stepperElement = fixture.nativeElement.querySelector('mat-stepper'); + const headers = stepperElement.querySelectorAll('mat-step-header'); expect(headers[2].textContent).toContain('III'); }); @@ -598,51 +556,49 @@ describe('MatStepper', () => { let fixture: ComponentFixture; beforeEach(() => { - dir.value = 'rtl'; + dir.set('rtl'); fixture = createComponent(SimpleMatVerticalStepperApp); fixture.detectChanges(); }); it('should reverse animation in RTL mode', () => { - const stepperComponent = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = fixture.componentInstance.stepper; - expect(stepperComponent._getAnimationDirection(0)).toBe('current'); - expect(stepperComponent._getAnimationDirection(1)).toBe('previous'); - expect(stepperComponent._getAnimationDirection(2)).toBe('previous'); + expect(stepper._getAnimationDirection(0)).toBe('current'); + expect(stepper._getAnimationDirection(1)).toBe('previous'); + expect(stepper._getAnimationDirection(2)).toBe('previous'); - stepperComponent.selectedIndex = 1; + stepper.selectedIndex = 1; fixture.detectChanges(); - expect(stepperComponent._getAnimationDirection(0)).toBe('next'); - expect(stepperComponent._getAnimationDirection(2)).toBe('previous'); - expect(stepperComponent._getAnimationDirection(1)).toBe('current'); + expect(stepper._getAnimationDirection(0)).toBe('next'); + expect(stepper._getAnimationDirection(2)).toBe('previous'); + expect(stepper._getAnimationDirection(1)).toBe('current'); - stepperComponent.selectedIndex = 2; + stepper.selectedIndex = 2; fixture.detectChanges(); - expect(stepperComponent._getAnimationDirection(0)).toBe('next'); - expect(stepperComponent._getAnimationDirection(1)).toBe('next'); - expect(stepperComponent._getAnimationDirection(2)).toBe('current'); + expect(stepper._getAnimationDirection(0)).toBe('next'); + expect(stepper._getAnimationDirection(1)).toBe('next'); + expect(stepper._getAnimationDirection(2)).toBe('current'); }); }); describe('linear stepper', () => { let fixture: ComponentFixture; let testComponent: LinearMatVerticalStepperApp; - let stepperComponent: MatStepper; + let stepper: MatStepper; beforeEach(() => { - fixture = createComponent(LinearMatVerticalStepperApp, [], [], undefined, []); + fixture = createComponent(LinearMatVerticalStepperApp); fixture.detectChanges(); testComponent = fixture.componentInstance; - stepperComponent = fixture.debugElement.query(By.css('mat-stepper'))!.componentInstance; + stepper = fixture.debugElement.query(By.css('mat-stepper'))!.componentInstance; }); it('should have true linear attribute', () => { - expect(stepperComponent.linear).toBe(true); + expect(stepper.linear).toBe(true); }); it('should not move to next step if current step is invalid', () => { @@ -650,7 +606,7 @@ describe('MatStepper', () => { expect(testComponent.oneGroup.get('oneCtrl')!.valid).toBe(false); expect(testComponent.oneGroup.valid).toBe(false); expect(testComponent.oneGroup.invalid).toBe(true); - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper.selectedIndex).toBe(0); const stepHeaderEl = fixture.debugElement.queryAll(By.css('.mat-vertical-stepper-header'))[1] .nativeElement; @@ -658,21 +614,21 @@ describe('MatStepper', () => { stepHeaderEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper.selectedIndex).toBe(0); const nextButtonNativeEl = fixture.debugElement.queryAll(By.directive(MatStepperNext))[0] .nativeElement; nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper.selectedIndex).toBe(0); testComponent.oneGroup.get('oneCtrl')!.setValue('answer'); stepHeaderEl.click(); fixture.detectChanges(); expect(testComponent.oneGroup.valid).toBe(true); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); }); it('should not move to next step if current step is pending', () => { @@ -684,9 +640,9 @@ describe('MatStepper', () => { testComponent.oneGroup.get('oneCtrl')!.setValue('input'); testComponent.twoGroup.get('twoCtrl')!.setValue('input'); - stepperComponent.selectedIndex = 1; + stepper.selectedIndex = 1; fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); // Step status = PENDING // Assert that linear stepper does not allow step selection change @@ -695,12 +651,12 @@ describe('MatStepper', () => { stepHeaderEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); // Trigger asynchronous validation testComponent.validationTrigger.next(); @@ -712,16 +668,16 @@ describe('MatStepper', () => { stepHeaderEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(2); + expect(stepper.selectedIndex).toBe(2); - stepperComponent.selectedIndex = 1; + stepper.selectedIndex = 1; fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(2); + expect(stepper.selectedIndex).toBe(2); }); it('should be able to focus step header upon click if it is unable to be selected', () => { @@ -737,13 +693,13 @@ describe('MatStepper', () => { testComponent.oneGroup.get('oneCtrl')!.setValue('input'); testComponent.twoGroup.get('twoCtrl')!.setValue('input'); testComponent.validationTrigger.next(); - stepperComponent.selectedIndex = 1; + stepper.selectedIndex = 1; fixture.detectChanges(); - stepperComponent.selectedIndex = 2; + stepper.selectedIndex = 2; fixture.detectChanges(); - expect(stepperComponent.steps.toArray()[2].optional).toBe(true); - expect(stepperComponent.selectedIndex).toBe(2); + expect(stepper.steps.toArray()[2].optional).toBe(true); + expect(stepper.selectedIndex).toBe(2); expect(testComponent.threeGroup.get('threeCtrl')!.valid).toBe(true); const nextButtonNativeEl = fixture.debugElement.queryAll(By.directive(MatStepperNext))[2] @@ -751,34 +707,34 @@ describe('MatStepper', () => { nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex) + expect(stepper.selectedIndex) .withContext('Expected selectedIndex to change when optional step input is empty.') .toBe(3); - stepperComponent.selectedIndex = 2; + stepper.selectedIndex = 2; testComponent.threeGroup.get('threeCtrl')!.setValue('input'); nextButtonNativeEl.click(); fixture.detectChanges(); expect(testComponent.threeGroup.get('threeCtrl')!.valid).toBe(false); - expect(stepperComponent.selectedIndex) + expect(stepper.selectedIndex) .withContext('Expected selectedIndex to change when optional step input is invalid.') .toBe(3); }); it('should be able to reset the stepper to its initial state', () => { - const steps = stepperComponent.steps.toArray(); + const steps = stepper.steps.toArray(); testComponent.oneGroup.get('oneCtrl')!.setValue('value'); fixture.detectChanges(); - stepperComponent.next(); + stepper.next(); fixture.detectChanges(); - stepperComponent.next(); + stepper.next(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); expect(steps[0].interacted).toBe(true); expect(steps[0].completed).toBe(true); expect(testComponent.oneGroup.get('oneCtrl')!.valid).toBe(true); @@ -788,10 +744,10 @@ describe('MatStepper', () => { expect(steps[1].completed).toBe(false); expect(testComponent.twoGroup.get('twoCtrl')!.valid).toBe(false); - stepperComponent.reset(); + stepper.reset(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper.selectedIndex).toBe(0); expect(steps[0].interacted).toBe(false); expect(steps[0].completed).toBe(false); expect(testComponent.oneGroup.get('oneCtrl')!.valid).toBe(false); @@ -803,36 +759,36 @@ describe('MatStepper', () => { }); it('should reset back to the first step when some of the steps are not editable', () => { - const steps = stepperComponent.steps.toArray(); + const steps = stepper.steps.toArray(); steps[0].editable = false; testComponent.oneGroup.get('oneCtrl')!.setValue('value'); fixture.detectChanges(); - stepperComponent.next(); + stepper.next(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); - stepperComponent.reset(); + stepper.reset(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper.selectedIndex).toBe(0); }); it('should not clobber the `complete` binding when resetting', () => { - const steps: CdkStep[] = stepperComponent.steps.toArray(); + const steps: CdkStep[] = stepper.steps.toArray(); const fillOutStepper = () => { testComponent.oneGroup.get('oneCtrl')!.setValue('input'); testComponent.twoGroup.get('twoCtrl')!.setValue('input'); testComponent.threeGroup.get('threeCtrl')!.setValue('valid'); testComponent.validationTrigger.next(); - stepperComponent.selectedIndex = 1; + stepper.selectedIndex = 1; fixture.detectChanges(); - stepperComponent.selectedIndex = 2; + stepper.selectedIndex = 2; fixture.detectChanges(); - stepperComponent.selectedIndex = 3; + stepper.selectedIndex = 3; fixture.detectChanges(); }; @@ -842,7 +798,7 @@ describe('MatStepper', () => { .withContext('Expected third step to be considered complete after the first run through.') .toBe(true); - stepperComponent.reset(); + stepper.reset(); fixture.detectChanges(); fillOutStepper(); @@ -857,21 +813,21 @@ describe('MatStepper', () => { expect(testComponent.oneGroup.get('oneCtrl')!.value).toBe(''); expect(testComponent.oneGroup.get('oneCtrl')!.valid).toBe(false); expect(testComponent.oneGroup.valid).toBe(false); - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper.selectedIndex).toBe(0); const nextButtonNativeEl = fixture.debugElement.queryAll(By.directive(MatStepperNext))[0] .nativeElement; nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper.selectedIndex).toBe(0); - stepperComponent.steps.first.completed = true; + stepper.steps.first.completed = true; nextButtonNativeEl.click(); fixture.detectChanges(); expect(testComponent.oneGroup.valid).toBe(false); - expect(stepperComponent.selectedIndex).toBe(1); + expect(stepper.selectedIndex).toBe(1); }); it('should set aria-disabled if the user is not able to navigate to a step', () => { @@ -895,7 +851,7 @@ describe('MatStepper', () => { beforeEach(() => { preselectedFixture = createComponent(SimplePreselectedMatHorizontalStepperApp); preselectedFixture.detectChanges(); - stepper = preselectedFixture.debugElement.query(By.directive(MatStepper))!.componentInstance; + stepper = preselectedFixture.componentInstance.stepper; }); it('should not throw', () => { @@ -918,7 +874,7 @@ describe('MatStepper', () => { beforeEach(() => { fixture = createComponent(LinearMatVerticalStepperAppForAlreadyFilledForm); fixture.detectChanges(); - stepper = fixture.debugElement.query(By.directive(MatStepper))!.componentInstance; + stepper = fixture.componentInstance.stepper; }); it('should navigate to previous steps', () => { @@ -939,9 +895,7 @@ describe('MatStepper', () => { noStepControlFixture.detectChanges(); }); it('should not move to the next step if the current one is not completed ', () => { - const stepper: MatStepper = noStepControlFixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = noStepControlFixture.componentInstance.stepper; const headers = noStepControlFixture.debugElement.queryAll( By.css('.mat-horizontal-stepper-header'), @@ -967,9 +921,7 @@ describe('MatStepper', () => { expect(controlAndBindingFixture.componentInstance.steps[0].control.valid).toBe(true); expect(controlAndBindingFixture.componentInstance.steps[0].completed).toBe(false); - const stepper: MatStepper = controlAndBindingFixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = controlAndBindingFixture.componentInstance.stepper; const headers = controlAndBindingFixture.debugElement.queryAll( By.css('.mat-horizontal-stepper-header'), @@ -1010,7 +962,7 @@ describe('MatStepper', () => { }); it('should reverse arrow key focus in RTL mode', () => { - dir.value = 'rtl'; + dir.set('rtl'); const fixture = createComponent(SimpleMatVerticalStepperApp); fixture.detectChanges(); @@ -1106,7 +1058,7 @@ describe('MatStepper', () => { }); it('should reverse arrow key focus in RTL mode', () => { - dir.value = 'rtl'; + dir.set('rtl'); const fixture = createComponent(SimpleMatHorizontalStepperApp); fixture.detectChanges(); @@ -1131,8 +1083,7 @@ describe('MatStepper', () => { const stepHeaders = fixture.debugElement.queryAll(By.css('.mat-horizontal-stepper-header')); assertCorrectKeyboardInteraction(fixture, stepHeaders, 'horizontal'); - dir.value = 'rtl'; - dir.change.emit('rtl'); + dir.set('rtl'); fixture.detectChanges(); assertArrowKeyInteractionInRtl(fixture, stepHeaders); @@ -1198,9 +1149,7 @@ describe('MatStepper', () => { const fixture = createComponent(SimpleMatHorizontalStepperApp); fixture.detectChanges(); - const stepper: MatStepper = fixture.debugElement.query( - By.directive(MatStepper), - ).componentInstance; + const stepper = fixture.componentInstance.stepper; expect(stepper.steps.map(step => step.interacted)).toEqual([false, false, false]); @@ -1221,9 +1170,7 @@ describe('MatStepper', () => { const fixture = createComponent(SimpleMatHorizontalStepperApp); fixture.detectChanges(); - const stepper: MatStepper = fixture.debugElement.query( - By.directive(MatStepper), - ).componentInstance; + const stepper = fixture.componentInstance.stepper; const interactedSteps: number[] = []; const subscription = merge(...stepper.steps.map(step => step.interactedStream)).subscribe( step => interactedSteps.push(stepper.steps.toArray().indexOf(step as MatStep)), @@ -1249,9 +1196,7 @@ describe('MatStepper', () => { const fixture = createComponent(SimpleMatHorizontalStepperApp); fixture.detectChanges(); - const stepper: MatStepper = fixture.debugElement.query( - By.directive(MatStepper), - ).componentInstance; + const stepper = fixture.componentInstance.stepper; const interactedSteps: number[] = []; const subscription = merge(...stepper.steps.map(step => step.interactedStream)).subscribe( step => interactedSteps.push(stepper.steps.toArray().indexOf(step as MatStep)), @@ -1361,16 +1306,12 @@ describe('MatStepper', () => { let stepper: MatStepper; function createFixture(showErrorByDefault: boolean | undefined) { - fixture = createComponent( - MatHorizontalStepperWithErrorsApp, - [ - { - provide: STEPPER_GLOBAL_OPTIONS, - useValue: {showError: showErrorByDefault}, - }, - ], - [MatFormFieldModule, MatInputModule], - ); + fixture = createComponent(MatHorizontalStepperWithErrorsApp, [ + { + provide: STEPPER_GLOBAL_OPTIONS, + useValue: {showError: showErrorByDefault}, + }, + ]); fixture.detectChanges(); stepper = fixture.debugElement.query(By.css('mat-stepper'))!.componentInstance; } @@ -1385,7 +1326,7 @@ describe('MatStepper', () => { nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepper._getIndicatorType(0)).toBe(STEP_STATE.ERROR); + expect(stepper.steps.first.indicatorType()).toBe(STEP_STATE.ERROR); }); it('should respect a custom falsy hasError value', () => { @@ -1397,12 +1338,12 @@ describe('MatStepper', () => { nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepper._getIndicatorType(0)).toBe(STEP_STATE.ERROR); + expect(stepper.steps.first.indicatorType()).toBe(STEP_STATE.ERROR); stepper.steps.first.hasError = false; fixture.detectChanges(); - expect(stepper._getIndicatorType(0)).not.toBe(STEP_STATE.ERROR); + expect(stepper.steps.first.indicatorType()).not.toBe(STEP_STATE.ERROR); }); it('should show error state if explicitly enabled, even when disabled globally', () => { @@ -1415,7 +1356,7 @@ describe('MatStepper', () => { nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepper._getIndicatorType(0)).toBe(STEP_STATE.ERROR); + expect(stepper.steps.first.indicatorType()).toBe(STEP_STATE.ERROR); }); }); @@ -1424,16 +1365,12 @@ describe('MatStepper', () => { let stepper: MatStepper; beforeEach(() => { - fixture = createComponent( - MatHorizontalStepperWithErrorsApp, - [ - { - provide: STEPPER_GLOBAL_OPTIONS, - useValue: {displayDefaultIndicatorType: false}, - }, - ], - [MatFormFieldModule, MatInputModule], - ); + fixture = createComponent(MatHorizontalStepperWithErrorsApp, [ + { + provide: STEPPER_GLOBAL_OPTIONS, + useValue: {displayDefaultIndicatorType: false}, + }, + ]); fixture.detectChanges(); stepper = fixture.debugElement.query(By.css('mat-stepper'))!.componentInstance; }); @@ -1447,7 +1384,7 @@ describe('MatStepper', () => { nextButtonNativeEl.click(); fixture.detectChanges(); - expect(stepper._getIndicatorType(0)).toBe(STEP_STATE.DONE); + expect(stepper.steps.first.indicatorType()).toBe(STEP_STATE.DONE); }); it('should show edit state when step is editable and its the current step', () => { @@ -1455,7 +1392,7 @@ describe('MatStepper', () => { stepper.steps.toArray()[1].editable = true; fixture.detectChanges(); - expect(stepper._getIndicatorType(1)).toBe(STEP_STATE.EDIT); + expect(stepper.steps.get(1)!.indicatorType()).toBe(STEP_STATE.EDIT); }); }); @@ -1465,42 +1402,40 @@ describe('MatStepper', () => { fixture.detectChanges(); const stepHeaders = fixture.debugElement.queryAll(By.css('.mat-vertical-stepper-header')); - const stepperComponent = fixture.debugElement.query( - By.directive(MatStepper), - )!.componentInstance; + const stepper = fixture.componentInstance.stepper; - expect(stepperComponent.selectedIndex).toBe(0); - expect(stepperComponent.selected instanceof MatStep).toBe(true); + expect(stepper.selectedIndex).toBe(0); + expect(stepper.selected instanceof MatStep).toBe(true); // select the second step let stepHeaderEl = stepHeaders[1].nativeElement; stepHeaderEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(1); - expect(stepperComponent.selected instanceof MatStep).toBe(true); + expect(stepper.selectedIndex).toBe(1); + expect(stepper.selected instanceof MatStep).toBe(true); // select the third step stepHeaderEl = stepHeaders[2].nativeElement; stepHeaderEl.click(); fixture.detectChanges(); - expect(stepperComponent.selectedIndex).toBe(2); - expect(stepperComponent.selected instanceof MatStep).toBe(true); + expect(stepper.selectedIndex).toBe(2); + expect(stepper.selected instanceof MatStep).toBe(true); }); it('should allow for the `edit` icon to be overridden', () => { const fixture = createComponent(IndirectDescendantIconOverridesStepper); fixture.detectChanges(); - const stepperDebugElement = fixture.debugElement.query(By.directive(MatStepper))!; - const stepperComponent: MatStepper = stepperDebugElement.componentInstance; + const stepperElement = fixture.nativeElement.querySelector('mat-stepper'); + const stepper = fixture.componentInstance.stepper; - stepperComponent.steps.toArray()[0].editable = true; - stepperComponent.next(); + stepper.steps.toArray()[0].editable = true; + stepper.next(); fixture.detectChanges(); - const header = stepperDebugElement.nativeElement.querySelector('mat-step-header'); + const header = stepperElement.querySelector('mat-step-header'); expect(header.textContent).toContain('Custom edit'); }); @@ -1509,14 +1444,14 @@ describe('MatStepper', () => { const fixture = createComponent(IndirectDescendantIconOverridesStepper); fixture.detectChanges(); - const stepperDebugElement = fixture.debugElement.query(By.directive(MatStepper))!; - const stepperComponent: MatStepper = stepperDebugElement.componentInstance; + const stepperElement = fixture.nativeElement.querySelector('mat-stepper'); + const stepper = fixture.componentInstance.stepper; - stepperComponent.steps.toArray()[0].editable = false; - stepperComponent.next(); + stepper.steps.toArray()[0].editable = false; + stepper.next(); fixture.detectChanges(); - const header = stepperDebugElement.nativeElement.querySelector('mat-step-header'); + const header = stepperElement.querySelector('mat-step-header'); expect(header.textContent).toContain('Custom done'); }); @@ -1525,8 +1460,8 @@ describe('MatStepper', () => { const fixture = createComponent(IndirectDescendantIconOverridesStepper); fixture.detectChanges(); - const stepperDebugElement = fixture.debugElement.query(By.directive(MatStepper))!; - const headers = stepperDebugElement.nativeElement.querySelectorAll('mat-step-header'); + const stepperElement = fixture.nativeElement.querySelector('mat-stepper'); + const headers = stepperElement.querySelectorAll('mat-step-header'); expect(headers[2].textContent).toContain('III'); }); @@ -1640,25 +1575,25 @@ describe('MatStepper', () => { /** Asserts that keyboard interaction works correctly. */ function assertCorrectKeyboardInteraction( - fixture: ComponentFixture, + fixture: ComponentFixture<{stepper: MatStepper}>, stepHeaders: DebugElement[], orientation: StepperOrientation, ) { - const stepperComponent = fixture.debugElement.query(By.directive(MatStepper))!.componentInstance; + const stepper = fixture.componentInstance.stepper; const nextKey = orientation === 'vertical' ? DOWN_ARROW : RIGHT_ARROW; const prevKey = orientation === 'vertical' ? UP_ARROW : LEFT_ARROW; - expect(stepperComponent._getFocusIndex()).toBe(0); - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper._getFocusIndex()).toBe(0); + expect(stepper.selectedIndex).toBe(0); let stepHeaderEl = stepHeaders[0].nativeElement; dispatchKeyboardEvent(stepHeaderEl, 'keydown', nextKey); fixture.detectChanges(); - expect(stepperComponent._getFocusIndex()) + expect(stepper._getFocusIndex()) .withContext('Expected index of focused step to increase by 1 after pressing the next key.') .toBe(1); - expect(stepperComponent.selectedIndex) + expect(stepper.selectedIndex) .withContext('Expected index of selected step to remain unchanged after pressing the next key.') .toBe(0); @@ -1666,10 +1601,10 @@ function assertCorrectKeyboardInteraction( dispatchKeyboardEvent(stepHeaderEl, 'keydown', ENTER); fixture.detectChanges(); - expect(stepperComponent._getFocusIndex()) + expect(stepper._getFocusIndex()) .withContext('Expected index of focused step to remain unchanged after ENTER event.') .toBe(1); - expect(stepperComponent.selectedIndex) + expect(stepper.selectedIndex) .withContext( 'Expected index of selected step to change to index of focused step ' + 'after ENTER event.', ) @@ -1679,12 +1614,12 @@ function assertCorrectKeyboardInteraction( dispatchKeyboardEvent(stepHeaderEl, 'keydown', prevKey); fixture.detectChanges(); - expect(stepperComponent._getFocusIndex()) + expect(stepper._getFocusIndex()) .withContext( 'Expected index of focused step to decrease by 1 after pressing the ' + 'previous key.', ) .toBe(0); - expect(stepperComponent.selectedIndex) + expect(stepper.selectedIndex) .withContext( 'Expected index of selected step to remain unchanged after pressing the ' + 'previous key.', ) @@ -1692,18 +1627,18 @@ function assertCorrectKeyboardInteraction( // When the focus is on the last step and right arrow key is pressed, the focus should cycle // through to the first step. - stepperComponent._keyManager.updateActiveItem(2); + (stepper as any)._keyManager.updateActiveItem(2); stepHeaderEl = stepHeaders[2].nativeElement; dispatchKeyboardEvent(stepHeaderEl, 'keydown', nextKey); fixture.detectChanges(); - expect(stepperComponent._getFocusIndex()) + expect(stepper._getFocusIndex()) .withContext( 'Expected index of focused step to cycle through to index 0 after pressing ' + 'the next key.', ) .toBe(0); - expect(stepperComponent.selectedIndex) + expect(stepper.selectedIndex) .withContext( 'Expected index of selected step to remain unchanged after pressing ' + 'the next key.', ) @@ -1713,17 +1648,17 @@ function assertCorrectKeyboardInteraction( dispatchKeyboardEvent(stepHeaderEl, 'keydown', SPACE); fixture.detectChanges(); - expect(stepperComponent._getFocusIndex()) + expect(stepper._getFocusIndex()) .withContext('Expected index of focused to remain unchanged after SPACE event.') .toBe(0); - expect(stepperComponent.selectedIndex) + expect(stepper.selectedIndex) .withContext( 'Expected index of selected step to change to index of focused step ' + 'after SPACE event.', ) .toBe(0); const endEvent = dispatchKeyboardEvent(stepHeaderEl, 'keydown', END); - expect(stepperComponent._getFocusIndex()) + expect(stepper._getFocusIndex()) .withContext('Expected last step to be focused when pressing END.') .toBe(stepHeaders.length - 1); expect(endEvent.defaultPrevented) @@ -1731,7 +1666,7 @@ function assertCorrectKeyboardInteraction( .toBe(true); const homeEvent = dispatchKeyboardEvent(stepHeaderEl, 'keydown', HOME); - expect(stepperComponent._getFocusIndex()) + expect(stepper._getFocusIndex()) .withContext('Expected first step to be focused when pressing HOME.') .toBe(0); expect(homeEvent.defaultPrevented) @@ -1741,38 +1676,37 @@ function assertCorrectKeyboardInteraction( /** Asserts that arrow key direction works correctly in RTL mode. */ function assertArrowKeyInteractionInRtl( - fixture: ComponentFixture, + fixture: ComponentFixture<{stepper: MatStepper}>, stepHeaders: DebugElement[], ) { - const stepperComponent = fixture.debugElement.query(By.directive(MatStepper))!.componentInstance; - - expect(stepperComponent._getFocusIndex()).toBe(0); + const stepper = fixture.componentInstance.stepper; + expect(stepper._getFocusIndex()).toBe(0); let stepHeaderEl = stepHeaders[0].nativeElement; dispatchKeyboardEvent(stepHeaderEl, 'keydown', LEFT_ARROW); fixture.detectChanges(); - expect(stepperComponent._getFocusIndex()).toBe(1); + expect(stepper._getFocusIndex()).toBe(1); stepHeaderEl = stepHeaders[1].nativeElement; dispatchKeyboardEvent(stepHeaderEl, 'keydown', RIGHT_ARROW); fixture.detectChanges(); - expect(stepperComponent._getFocusIndex()).toBe(0); + expect(stepper._getFocusIndex()).toBe(0); } /** Asserts that keyboard interaction works correctly when the user is pressing a modifier key. */ function assertSelectKeyWithModifierInteraction( - fixture: ComponentFixture, + fixture: ComponentFixture<{stepper: MatStepper}>, stepHeaders: DebugElement[], orientation: StepperOrientation, selectionKey: number, ) { - const stepperComponent = fixture.debugElement.query(By.directive(MatStepper))!.componentInstance; + const stepper = fixture.componentInstance.stepper; const modifiers = ['altKey', 'shiftKey', 'ctrlKey', 'metaKey']; - expect(stepperComponent._getFocusIndex()).toBe(0); - expect(stepperComponent.selectedIndex).toBe(0); + expect(stepper._getFocusIndex()).toBe(0); + expect(stepper.selectedIndex).toBe(0); dispatchKeyboardEvent( stepHeaders[0].nativeElement, @@ -1781,12 +1715,12 @@ function assertSelectKeyWithModifierInteraction( ); fixture.detectChanges(); - expect(stepperComponent._getFocusIndex()) + expect(stepper._getFocusIndex()) .withContext( 'Expected index of focused step to increase by 1 after pressing ' + 'the next key.', ) .toBe(1); - expect(stepperComponent.selectedIndex) + expect(stepper.selectedIndex) .withContext( 'Expected index of selected step to remain unchanged after pressing ' + 'the next key.', ) @@ -1798,7 +1732,7 @@ function assertSelectKeyWithModifierInteraction( dispatchEvent(stepHeaders[1].nativeElement, event); fixture.detectChanges(); - expect(stepperComponent.selectedIndex) + expect(stepper.selectedIndex) .withContext( `Expected selected index to remain unchanged ` + `when pressing the selection key with ${modifier} modifier.`, @@ -1822,18 +1756,14 @@ function asyncValidator(minLength: number, validationTrigger: Subject): As function createComponent( component: Type, providers: Provider[] = [], - imports: any[] = [], encapsulation?: ViewEncapsulation, - declarations = [component], ): ComponentFixture { TestBed.configureTestingModule({ - imports: [MatStepperModule, ReactiveFormsModule, ...imports], providers: [ - {provide: Directionality, useFactory: () => dir}, + provideFakeDirectionality(dir), {provide: MATERIAL_ANIMATIONS, useValue: {animationsDisabled: true}}, ...providers, ], - declarations, }); if (encapsulation != null) { @@ -1858,22 +1788,22 @@ function createComponent( This field is required
    - - + +
    Step 2 Content 2
    - - + +
    `, - standalone: false, + imports: [MatStepperModule, MatInputModule, ReactiveFormsModule], }) class MatHorizontalStepperWithErrorsApp { private readonly _formBuilder = inject(FormBuilder); @@ -1894,30 +1824,31 @@ class MatHorizontalStepperWithErrorsApp { Step 1 Content 1
    - - + +
    Step 2 Content 2
    - - + +
    Content 3
    - - + +
    `, - standalone: false, + imports: [MatStepperModule], }) class SimpleMatHorizontalStepperApp { + @ViewChild(MatStepper) stepper: MatStepper; inputLabel = 'Step 3'; disableRipple = signal(false); stepperTheme = signal(undefined); @@ -1932,8 +1863,8 @@ class SimpleMatHorizontalStepperApp { Step 1 Content 1
    - - + +
    @if (showStepTwo()) { @@ -1941,23 +1872,24 @@ class SimpleMatHorizontalStepperApp { Step 2 Content 2
    - - + +
    } Content 3
    - - + +
    `, - standalone: false, + imports: [MatStepperModule], }) class SimpleMatVerticalStepperApp { + @ViewChild(MatStepper) stepper: MatStepper; inputLabel = signal('Step 3'); showStepTwo = signal(true); disableRipple = signal(false); @@ -2026,9 +1958,10 @@ class LinearMatVerticalStepperApp { `, - standalone: false, + imports: [MatStepperModule], }) class SimplePreselectedMatHorizontalStepperApp { + @ViewChild(MatStepper) stepper: MatStepper; index = 0; } @@ -2071,9 +2004,9 @@ class SimplePreselectedMatHorizontalStepperApp { `, imports: [ReactiveFormsModule, MatStepperModule], - standalone: false, }) class LinearMatVerticalStepperAppForAlreadyFilledForm { + @ViewChild(MatStepper) stepper: MatStepper; selectedIndex = signal(2); oneGroup = new FormGroup({ @@ -2095,9 +2028,10 @@ class LinearMatVerticalStepperAppForAlreadyFilledForm { } `, - standalone: false, + imports: [MatStepperModule], }) class SimpleStepperWithoutStepControl { + @ViewChild(MatStepper) stepper: MatStepper; steps = [ {label: 'One', completed: false}, {label: 'Two', completed: false}, @@ -2116,9 +2050,11 @@ class SimpleStepperWithoutStepControl { } `, - standalone: false, + imports: [MatStepperModule], }) class SimpleStepperWithStepControlAndCompletedBinding { + @ViewChild(MatStepper) stepper: MatStepper; + steps = [ {label: 'One', completed: false, control: new FormControl('')}, {label: 'Two', completed: false, control: new FormControl('')}, @@ -2140,9 +2076,11 @@ class SimpleStepperWithStepControlAndCompletedBinding { Content 3 `, - standalone: false, + imports: [MatStepperModule], }) class IconOverridesStepper { + @ViewChild(MatStepper) stepper: MatStepper; + getRomanNumeral(value: number) { const numberMap: {[key: number]: string} = { 1: 'I', @@ -2176,7 +2114,7 @@ class IconOverridesStepper { Content 3 `, - standalone: false, + imports: [MatStepperModule], }) class IndirectDescendantIconOverridesStepper extends IconOverridesStepper {} @@ -2188,7 +2126,7 @@ class IndirectDescendantIconOverridesStepper extends IconOverridesStepper {} `, - standalone: false, + imports: [MatStepperModule], }) class LinearStepperWithValidOptionalStep { controls = [0, 0, 0].map(() => new FormControl('')); @@ -2201,7 +2139,7 @@ class LinearStepperWithValidOptionalStep { `, - standalone: false, + imports: [MatStepperModule], }) class StepperWithAriaInputs { ariaLabel = signal(''); @@ -2218,9 +2156,11 @@ class StepperWithAriaInputs { } `, - standalone: false, + imports: [MatStepperModule], }) -class StepperWithIndirectDescendantSteps {} +class StepperWithIndirectDescendantSteps { + @ViewChild(MatStepper) stepper: MatStepper; +} @Component({ template: ` @@ -2236,7 +2176,7 @@ class StepperWithIndirectDescendantSteps {} } `, - standalone: false, + imports: [MatStepperModule], }) class StepperWithNgIf { showStep2 = signal(false); @@ -2255,7 +2195,7 @@ class StepperWithNgIf { `, - standalone: false, + imports: [MatStepperModule], }) class NestedSteppers { @ViewChildren(MatStepper) steppers: QueryList; @@ -2269,7 +2209,7 @@ class NestedSteppers { Content 3 `, - standalone: false, + imports: [MatStepperModule], }) class StepperWithStaticOutOfBoundsIndex { @ViewChild(MatStepper) stepper: MatStepper; @@ -2292,7 +2232,7 @@ class StepperWithStaticOutOfBoundsIndex { `, - standalone: false, + imports: [MatStepperModule], }) class StepperWithLazyContent { selectedIndex = signal(0); @@ -2308,9 +2248,10 @@ class StepperWithLazyContent { Content 3 `, - standalone: false, + imports: [MatStepperModule], }) class HorizontalStepperWithDelayedStep { + @ViewChild(MatStepper) stepper: MatStepper; renderSecondStep = signal(false); } @@ -2322,7 +2263,7 @@ class HorizontalStepperWithDelayedStep { `, - standalone: false, + imports: [MatStepperModule], }) class StepperWithTwoWayBindingOnSelectedIndex { index: number = 0; diff --git a/src/material/stepper/stepper.ts b/src/material/stepper/stepper.ts index fe5834bde0a0..6d1399dae0df 100644 --- a/src/material/stepper/stepper.ts +++ b/src/material/stepper/stepper.ts @@ -68,10 +68,10 @@ export class MatStep extends CdkStep implements ErrorStateMatcher, AfterContentI /** * Theme color for the particular step. This API is supported in M2 themes - * only, it has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/stepper/styling. + * only, it has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/stepper/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() color: ThemePalette; @@ -168,10 +168,10 @@ export class MatStepper extends CdkStepper implements AfterViewInit, AfterConten /** * Theme color for all of the steps in stepper. This API is supported in M2 - * themes only, it has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/stepper/styling. + * themes only, it has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/stepper/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() color: ThemePalette; @@ -283,10 +283,6 @@ export class MatStepper extends CdkStepper implements AfterViewInit, AfterConten this._cleanupTransition?.(); } - _stepIsNavigable(index: number, step: MatStep): boolean { - return step.completed || this.selectedIndex === index || !this.linear; - } - _getAnimationDuration() { if (this._animationsDisabled) { return '0ms'; diff --git a/src/material/table/README.md b/src/material/table/README.md index 57531f0cbf1e..e5703902b6e3 100644 --- a/src/material/table/README.md +++ b/src/material/table/README.md @@ -1 +1 @@ -Please see the official documentation at https://material.angular.io/components/component/table +Please see the official documentation at https://material.angular.dev/components/component/table diff --git a/src/material/table/_m2-table.scss b/src/material/table/_m2-table.scss index aff938ffe713..da8afdcbcf7b 100644 --- a/src/material/table/_m2-table.scss +++ b/src/material/table/_m2-table.scss @@ -1,93 +1,63 @@ @use 'sass:map'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; @use '../core/tokens/m2-utils'; -@use '../core/style/sass-utils'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, table); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); + $density-scale: theming.clamp-density(map.get($system, density-scale), -4); -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { @return ( - row-item-outline-width: 1px, - ); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - @return ( - background-color: inspection.get-theme-color($theme, background, 'card'), - - header-headline-color: inspection.get-theme-color($theme, foreground, text), - row-item-label-text-color: inspection.get-theme-color($theme, foreground, text), - row-item-outline-color: inspection.get-theme-color($theme, foreground, divider), - ); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - $cell-font-family: inspection.get-theme-typography($theme, body-2, font-family); - $cell-line-height: inspection.get-theme-typography($theme, body-2, line-height); - $cell-font-size: inspection.get-theme-typography($theme, body-2, font-size); - $cell-font-weight: inspection.get-theme-typography($theme, body-2, font-weight); - $cell-letter-spacing: inspection.get-theme-typography($theme, body-2, letter-spacing); - - @return ( - header-headline-font: inspection.get-theme-typography($theme, subtitle-2, font-family), - header-headline-line-height: inspection.get-theme-typography($theme, subtitle-2, line-height), - header-headline-size: inspection.get-theme-typography($theme, subtitle-2, font-size), - header-headline-weight: inspection.get-theme-typography($theme, subtitle-2, font-weight), - header-headline-tracking: inspection.get-theme-typography($theme, subtitle-2, letter-spacing), - - // Plain cells and footer cells have the same typography. - row-item-label-text-font: $cell-font-family, - row-item-label-text-line-height: $cell-line-height, - row-item-label-text-size: $cell-font-size, - row-item-label-text-weight: $cell-font-weight, - row-item-label-text-tracking: $cell-letter-spacing, - - footer-supporting-text-font: $cell-font-family, - footer-supporting-text-line-height: $cell-line-height, - footer-supporting-text-size: $cell-font-size, - footer-supporting-text-weight: $cell-font-weight, - footer-supporting-text-tracking: $cell-letter-spacing, - ); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $scale: theming.clamp-density(inspection.get-theme-density($theme), -4); - $header-scale: ( - 0: 56px, - -1: 52px, - -2: 48px, - -3: 44px, - -4: 40px - ); - $cell-scale: ( - 0: 52px, - -1: 48px, - -2: 44px, - -3: 40px, - -4: 36px - ); - - @return ( - header-container-height: map.get($header-scale, $scale), - footer-container-height: map.get($cell-scale, $scale), - row-item-container-height: map.get($cell-scale, $scale), - ); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + base: ( + table-row-item-outline-width: 1px, + ), + color: ( + table-background-color: map.get($system, surface), + table-header-headline-color: map.get($system, on-surface), + table-row-item-label-text-color: map.get($system, on-surface), + table-row-item-outline-color: map.get($system, outline), + ), + typography: ( + table-header-headline-font: map.get($system, label-medium-font), + table-header-headline-line-height: map.get($system, label-medium-line-height), + table-header-headline-size: map.get($system, label-medium-size), + table-header-headline-weight: map.get($system, label-medium-weight), + table-header-headline-tracking: map.get($system, label-medium-tracking), + + // Plain cells and footer cells have the same typography. + table-row-item-label-text-font: map.get($system, body-medium-font), + table-row-item-label-text-line-height: map.get($system, body-medium-line-height), + table-row-item-label-text-size: map.get($system, body-medium-size), + table-row-item-label-text-weight: map.get($system, body-medium-weight), + table-row-item-label-text-tracking: map.get($system, body-medium-tracking), + + table-footer-supporting-text-font: map.get($system, body-medium-font), + table-footer-supporting-text-line-height: map.get($system, body-medium-line-height), + table-footer-supporting-text-size: map.get($system, body-medium-size), + table-footer-supporting-text-weight: map.get($system, body-medium-weight), + table-footer-supporting-text-tracking: map.get($system, body-medium-tracking), + ), + density: ( + table-header-container-height: map.get(( + 0: 56px, + -1: 52px, + -2: 48px, + -3: 44px, + -4: 40px + ), $density-scale), + table-footer-container-height: map.get(( + 0: 52px, + -1: 48px, + -2: 44px, + -3: 40px, + -4: 36px + ), $density-scale), + table-row-item-container-height: map.get(( + 0: 52px, + -1: 48px, + -2: 44px, + -3: 40px, + -4: 36px + ), $density-scale), + ), ); } diff --git a/src/material/table/_m3-table.scss b/src/material/table/_m3-table.scss index 8d22e8722170..27d9f9594052 100644 --- a/src/material/table/_m3-table.scss +++ b/src/material/table/_m3-table.scss @@ -1,44 +1,52 @@ @use 'sass:map'; @use 'sass:list'; -@use '../core/style/sass-utils'; @use '../core/tokens/m3-utils'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, table); +@use '../core/tokens/m3'; /// Generates custom tokens for the mat-table. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-table -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: sass-utils.merge-all( - m3-utils.generate-typography-tokens($systems, header-headline, title-small), - m3-utils.generate-typography-tokens($systems, row-item-label-text, body-medium), - m3-utils.generate-typography-tokens( - $systems, footer-supporting-text, body-medium), - ( - row-item-outline-width: m3-utils.hardcode(1px, $exclude-hardcoded), - background-color: map.get($systems, md-sys-color, surface), - header-headline-color: map.get($systems, md-sys-color, on-surface), - row-item-label-text-color: map.get($systems, md-sys-color, on-surface), - row-item-outline-color: map.get($systems, md-sys-color, outline), +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); + + @return ( + base: ( + table-row-item-outline-width: 1px, ), + color: ( + table-background-color: map.get($system, surface), + table-header-headline-color: map.get($system, on-surface), + table-row-item-label-text-color: map.get($system, on-surface), + table-row-item-outline-color: map.get($system, outline), + ), + typography: ( + table-header-headline-font: map.get($system, title-small-font), + table-header-headline-line-height: map.get($system, title-small-line-height), + table-header-headline-size: map.get($system, title-small-size), + table-header-headline-tracking: map.get($system, title-small-tracking), + table-header-headline-weight: map.get($system, title-small-weight), + table-row-item-label-text-font: map.get($system, body-medium-font), + table-row-item-label-text-line-height: map.get($system, body-medium-line-height), + table-row-item-label-text-size: map.get($system, body-medium-size), + table-row-item-label-text-tracking: map.get($system, body-medium-tracking), + table-row-item-label-text-weight: map.get($system, body-medium-weight), + table-footer-supporting-text-font: map.get($system, body-medium-font), + table-footer-supporting-text-line-height: map.get($system, body-medium-line-height), + table-footer-supporting-text-size: map.get($system, body-medium-size), + table-footer-supporting-text-tracking: map.get($system, body-medium-tracking), + table-footer-supporting-text-weight: map.get($system, body-medium-weight), + ), + density: get-density-tokens(map.get($system, density-scale)), ); - - @return m3-utils.namespace($prefix, $tokens, $token-slots); } // Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $scale: theming.clamp-density(inspection.get-theme-density($theme), -4); +@function get-density-tokens($scale) { + $scale: theming.clamp-density($scale, -4); $index: ($scale * -1) + 1; @return ( - header-container-height: list.nth((56px, 52px, 48px, 44px, 40px), $index), - footer-container-height: list.nth((52px, 48px, 44px, 40px, 36px), $index), - row-item-container-height: list.nth((52px, 48px, 44px, 40px, 36px), $index), + table-header-container-height: list.nth((56px, 52px, 48px, 44px, 40px), $index), + table-footer-container-height: list.nth((52px, 48px, 44px, 40px, 36px), $index), + table-row-item-container-height: list.nth((52px, 48px, 44px, 40px, 36px), $index), ); } diff --git a/src/material/table/_table-flex-styles.scss b/src/material/table/_table-flex-styles.scss index 164a402e6a77..b07da2d44046 100644 --- a/src/material/table/_table-flex-styles.scss +++ b/src/material/table/_table-flex-styles.scss @@ -1,5 +1,7 @@ @use '../core/tokens/token-utils'; -@use './m2-table'; +@use './m3-table'; + +$fallbacks: m3-table.get-tokens(); // Flex-based table structure $header-row-height: 56px; @@ -12,18 +14,16 @@ $row-horizontal-padding: 24px; display: block; } - @include token-utils.use-tokens(m2-table.$prefix, m2-table.get-token-slots()) { - mat-header-row { - min-height: token-utils.slot(header-container-height, $header-row-height); - } + mat-header-row { + min-height: token-utils.slot(table-header-container-height, $fallbacks, $header-row-height); + } - mat-row { - min-height: token-utils.slot(row-item-container-height, $row-height); - } + mat-row { + min-height: token-utils.slot(table-row-item-container-height, $fallbacks, $row-height); + } - mat-footer-row { - min-height: token-utils.slot(footer-container-height, $row-height); - } + mat-footer-row { + min-height: token-utils.slot(table-footer-container-height, $fallbacks, $row-height); } mat-row, mat-header-row, mat-footer-row { diff --git a/src/material/table/_table-theme.scss b/src/material/table/_table-theme.scss index 29c2cfa6a738..226241daa096 100644 --- a/src/material/table/_table-theme.scss +++ b/src/material/table/_table-theme.scss @@ -1,101 +1,76 @@ @use 'sass:map'; @use './m2-table'; @use './m3-table'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; @use '../core/typography/typography'; @use '../core/tokens/token-utils'; -@use '../core/style/sass-utils'; @mixin base($theme) { + $tokens: map.get(m2-table.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-table.$prefix, m2-table.get-unthemable-tokens()); - } + $tokens: map.get(m3-table.get-tokens($theme), base); } + + @include token-utils.values($tokens); } @mixin color($theme) { + $tokens: map.get(m2-table.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-table.$prefix, m2-table.get-color-tokens($theme)); - } + $tokens: map.get(m3-table.get-tokens($theme), color); } + + @include token-utils.values($tokens); } @mixin typography($theme) { + $tokens: map.get(m2-table.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-table.$prefix, m2-table.get-typography-tokens($theme)); - } + $tokens: map.get(m3-table.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } @mixin density($theme) { + $tokens: map.get(m2-table.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - @include token-utils.create-token-values(m3-table.$prefix, m3-table.get-density-tokens($theme)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-table.$prefix, m2-table.get-density-tokens($theme)); - } + $tokens: map.get(m3-table.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-table.$prefix, - tokens: m2-table.get-token-slots(), + namespace: table, + tokens: token-utils.get-overrides(m3-table.get-tokens(), table) ), ); } @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } @mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-table') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - @if inspection.theme-has($theme, density) { - @include density($theme); - } - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - @include token-utils.create-token-values( - m2-table.$prefix, - map.get($tokens, m2-table.$prefix) - ); } } diff --git a/src/material/table/table.md b/src/material/table/table.md index 78f0ee5a67fa..c578e5625ea0 100644 --- a/src/material/table/table.md +++ b/src/material/table/table.md @@ -5,7 +5,7 @@ This table builds on the foundation of the CDK data-table and uses a similar int data input and template, except that its element and attribute selectors will be prefixed with `mat-` instead of `cdk-`. For more information on the interface and a detailed look at how the table is implemented, see the -[guide covering the CDK data-table](https://material.angular.io/guide/cdk-table). +[guide covering the CDK data-table](https://material.angular.dev/guide/cdk-table). ### Getting Started @@ -167,7 +167,7 @@ Otherwise if you are implementing the logic to paginate your data, you will want paginator's `(page)` output and pass the right slice of data to your table. For more information on using and configuring the ``, check out the -[mat-paginator docs](https://material.angular.io/components/paginator/overview). +[mat-paginator docs](https://material.angular.dev/components/paginator/overview). The `MatPaginator` is one provided solution to paginating your table's data, but it is not the only option. In fact, the table can work with any custom pagination UI or strategy since the `MatTable` @@ -178,7 +178,7 @@ and its interface is not tied to any one specific implementation. #### Sorting To add sorting behavior to the table, add the `matSort` directive to the table and add -`mat-sort-header` to each column header cell that should trigger sorting. Note that you have to import `MatSortModule` in order to initialize the `matSort` directive (see [API docs](https://material.angular.io/components/sort/api)). +`mat-sort-header` to each column header cell that should trigger sorting. Note that you have to import `MatSortModule` in order to initialize the `matSort` directive (see [API docs](https://material.angular.dev/components/sort/api)). ```html @@ -210,7 +210,7 @@ table, since it will not automatically check the array for changes. For more information on using and configuring the sorting behavior, check out the -[matSort docs](https://material.angular.io/components/sort/overview). +[matSort docs](https://material.angular.dev/components/sort/overview). The `MatSort` is one provided solution to sorting your table's data, but it is not the only option. In fact, the table can work with any custom sorting UI or strategy since the `MatTable` and diff --git a/src/material/table/table.scss b/src/material/table/table.scss index 5dab30bae5d6..5ec66248528a 100644 --- a/src/material/table/table.scss +++ b/src/material/table/table.scss @@ -1,8 +1,10 @@ @use '../core/style/vendor-prefixes'; @use '../core/tokens/token-utils'; -@use './m2-table'; +@use './m3-table'; @use './table-flex-styles'; +$fallbacks: m3-table.get-tokens(); + .mat-mdc-table-sticky { // Note that the table can either set this class or an inline style to make something sticky. // We set the style as `!important` so that we get an identical specificity in both cases @@ -10,12 +12,10 @@ position: sticky !important; } -$token-prefix: m2-table.$prefix; -$token-slots: m2-table.get-token-slots(); - @mixin _cell-border { - border-bottom-color: token-utils.slot(row-item-outline-color, true); - border-bottom-width: token-utils.slot(row-item-outline-width, true); + border-bottom-color: token-utils.slot( + table-row-item-outline-color, $fallbacks, rgba(0, 0, 0, 0.12)); + border-bottom-width: token-utils.slot(table-row-item-outline-width, $fallbacks, 1px); border-bottom-style: solid; } @@ -27,10 +27,7 @@ $token-slots: m2-table.get-token-slots(); border-spacing: 0; table-layout: auto; white-space: normal; - - @include token-utils.use-tokens($token-prefix, $token-slots) { - background-color: token-utils.slot(background-color); - } + background-color: token-utils.slot(table-background-color, $fallbacks); } .mdc-data-table__cell { @@ -49,81 +46,80 @@ $token-slots: m2-table.get-token-slots(); padding: 0 16px; } -@include token-utils.use-tokens($token-prefix, $token-slots) { - // TODO(crisbeto): these tokens have default values in order to make the initial token - // work easier to land in g3. Eventually we should remove them. - .mat-mdc-header-row { - @include vendor-prefixes.smooth-font; - height: token-utils.slot(header-container-height, 56px); - color: token-utils.slot(header-headline-color, true); - font-family: token-utils.slot(header-headline-font, true); - line-height: token-utils.slot(header-headline-line-height); - font-size: token-utils.slot(header-headline-size, 14px); - font-weight: token-utils.slot(header-headline-weight, 500); - } +// TODO(crisbeto): these tokens have default values in order to make the initial token +// work easier to land in g3. Eventually we should remove them. +.mat-mdc-header-row { + @include vendor-prefixes.smooth-font; + height: token-utils.slot(table-header-container-height, $fallbacks, 56px); + color: token-utils.slot(table-header-headline-color, $fallbacks, rgba(0, 0, 0, 0.87)); + font-family: token-utils.slot(table-header-headline-font, $fallbacks, (Roboto, sans-serif)); + line-height: token-utils.slot(table-header-headline-line-height, $fallbacks); + font-size: token-utils.slot(table-header-headline-size, $fallbacks, 14px); + font-weight: token-utils.slot(table-header-headline-weight, $fallbacks, 500); +} - .mat-mdc-row { - height: token-utils.slot(row-item-container-height, 52px); - color: token-utils.slot(row-item-label-text-color, true); - } +.mat-mdc-row { + height: token-utils.slot(table-row-item-container-height, $fallbacks, 52px); + color: token-utils.slot(table-row-item-label-text-color, $fallbacks, rgba(0, 0, 0, 0.87)); +} - // Note that while it's redundant to apply the typography both to the row - // and the content element since the cell inherit from both of them, - // applying it only to one results in sub-pixel differences in the - // letter spacing which leads to a lot of internal screenshot diffs. - .mat-mdc-row, - .mdc-data-table__content { - @include vendor-prefixes.smooth-font; - font-family: token-utils.slot(row-item-label-text-font, true); - line-height: token-utils.slot(row-item-label-text-line-height); - font-size: token-utils.slot(row-item-label-text-size, 14px); - font-weight: token-utils.slot(row-item-label-text-weight); - } +// Note that while it's redundant to apply the typography both to the row +// and the content element since the cell inherit from both of them, +// applying it only to one results in sub-pixel differences in the +// letter spacing which leads to a lot of internal screenshot diffs. +.mat-mdc-row, +.mdc-data-table__content { + @include vendor-prefixes.smooth-font; + font-family: token-utils.slot(table-row-item-label-text-font, $fallbacks, (Roboto, sans-serif)); + line-height: token-utils.slot(table-row-item-label-text-line-height, $fallbacks); + font-size: token-utils.slot(table-row-item-label-text-size, $fallbacks, 14px); + font-weight: token-utils.slot(table-row-item-label-text-weight, $fallbacks); +} + +.mat-mdc-footer-row { + @include vendor-prefixes.smooth-font; + height: token-utils.slot(table-footer-container-height, $fallbacks, 52px); + color: token-utils.slot(table-row-item-label-text-color, $fallbacks, rgba(0, 0, 0, 0.87)); + font-family: token-utils.slot( + table-footer-supporting-text-font, $fallbacks, (Roboto, sans-serif)); + line-height: token-utils.slot(table-footer-supporting-text-line-height, $fallbacks); + font-size: token-utils.slot(table-footer-supporting-text-size, $fallbacks, 14px); + font-weight: token-utils.slot(table-footer-supporting-text-weight, $fallbacks); + letter-spacing: token-utils.slot(table-footer-supporting-text-tracking, $fallbacks); +} + +.mat-mdc-header-cell { + @include _cell-border; + letter-spacing: token-utils.slot(table-header-headline-tracking, $fallbacks); + font-weight: inherit; + line-height: inherit; + box-sizing: border-box; + text-overflow: ellipsis; + overflow: hidden; + outline: none; + text-align: left; - .mat-mdc-footer-row { - @include vendor-prefixes.smooth-font; - height: token-utils.slot(footer-container-height, 52px); - color: token-utils.slot(row-item-label-text-color, true); - font-family: token-utils.slot(footer-supporting-text-font, true); - line-height: token-utils.slot(footer-supporting-text-line-height); - font-size: token-utils.slot(footer-supporting-text-size, 14px); - font-weight: token-utils.slot(footer-supporting-text-weight); - letter-spacing: token-utils.slot(footer-supporting-text-tracking); + [dir='rtl'] & { + text-align: right; } - .mat-mdc-header-cell { - @include _cell-border; - letter-spacing: token-utils.slot(header-headline-tracking); - font-weight: inherit; - line-height: inherit; - box-sizing: border-box; - text-overflow: ellipsis; - overflow: hidden; - outline: none; - text-align: left; - - [dir='rtl'] & { - text-align: right; - } - - .mdc-data-table__row:last-child > & { - border-bottom: none; - } + .mdc-data-table__row:last-child > & { + border-bottom: none; } +} - .mat-mdc-cell { - @include _cell-border; - letter-spacing: token-utils.slot(row-item-label-text-tracking); - line-height: inherit; +.mat-mdc-cell { + @include _cell-border; + letter-spacing: token-utils.slot(table-row-item-label-text-tracking, $fallbacks); + line-height: inherit; - .mdc-data-table__row:last-child > & { - border-bottom: none; - } + .mdc-data-table__row:last-child > & { + border-bottom: none; } +} - .mat-mdc-footer-cell { - letter-spacing: token-utils.slot(row-item-label-text-tracking); - } +.mat-mdc-footer-cell { + letter-spacing: token-utils.slot(table-row-item-label-text-tracking, $fallbacks); } // MDC table rows are styled with a top border, whereas our legacy flex table styles rows with diff --git a/src/material/tabs/BUILD.bazel b/src/material/tabs/BUILD.bazel index e1125c36b23b..def50ed2cabb 100644 --- a/src/material/tabs/BUILD.bazel +++ b/src/material/tabs/BUILD.bazel @@ -18,6 +18,7 @@ sass_library( ], deps = [ "//src/material/core/style:sass_utils", + "//src/material/core/theming:_inspection", "//src/material/core/tokens:m3_utils", ], ) @@ -41,6 +42,7 @@ sass_library( "_tabs-theme.scss", ], deps = [ + ":m2", "//src/material/core/style:sass_utils", "//src/material/core/theming", "//src/material/core/theming:_inspection", diff --git a/src/material/tabs/README.md b/src/material/tabs/README.md index 5f068875e5aa..d7e5441f45c2 100644 --- a/src/material/tabs/README.md +++ b/src/material/tabs/README.md @@ -1 +1 @@ -Please see the official documentation at https://material.angular.io/components/component/tabs +Please see the official documentation at https://material.angular.dev/components/component/tabs diff --git a/src/material/tabs/_m2-tabs.scss b/src/material/tabs/_m2-tabs.scss index ebcd1cf40841..85d392439a7a 100644 --- a/src/material/tabs/_m2-tabs.scss +++ b/src/material/tabs/_m2-tabs.scss @@ -1,60 +1,63 @@ @use 'sass:map'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; -@use '../core/style/sass-utils'; @use '../core/tokens/m2-utils'; +@use '../core/tokens/m3-utils'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, tab); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); + $density-scale: theming.clamp-density(map.get($system, density-scale), -4); -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -// -// Tokens that are available in MDC, but not used in Angular Material should be mapped to `null`. -// `null` indicates that we are intentionally choosing not to emit a slot or value for the token in -// our CSS. -@function get-unthemable-tokens() { @return ( - // This is specified both here and in the density tokens, because it determines the size of the - // tab itself and there are internal tests who don't configure the theme correctly. - container-height: 48px, - // For some period of time, the MDC tabs removed the divider. This has been added back in - // and will be present in M3. - divider-color: transparent, - divider-height: 0, - active-indicator-height: 2px, - // Currently set to zero, but used by the gmat styles to make the indicator rounded. - active-indicator-shape: 0, + base: ( + // This is specified both here and in the density tokens, because it determines the size of + // the tab itself and there are internal tests who don't configure the theme correctly. + tab-container-height: 48px, + tab-divider-color: transparent, + tab-divider-height: 0, + tab-active-indicator-height: 2px, + tab-active-indicator-shape: 0, + ), + color: private-get-color-palette-color-tokens($theme, primary), + typography: ( + tab-label-text-font: map.get($system, label-small-font), + tab-label-text-size: map.get($system, label-small-size), + tab-label-text-tracking: map.get($system, label-small-tracking), + tab-label-text-line-height: map.get($system, label-small-line-height), + tab-label-text-weight: map.get($system, label-small-weight), + ), + density: ( + tab-container-height: map.get(( + 0: 48px, + -1: 44px, + -2: 40px, + -3: 36px, + -4: 32px, + ), $density-scale), + ), ); } // Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme, $palette-name: primary, $exclude: ()) { - $is-dark: inspection.get-theme-type($theme) == dark; - $inactive-label-text-color: inspection.get-theme-color($theme, foreground, text, 0.6); - $active-label-text-color: inspection.get-theme-color($theme, $palette-name, default); - $ripple-color: inspection.get-theme-color($theme, $palette-name, default); +@function private-get-color-palette-color-tokens($theme, $color-variant, $exclude: ()) { + $system: m2-utils.get-system($theme); + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); $tokens: ( - disabled-ripple-color: inspection.get-theme-color($theme, foreground, disabled), - pagination-icon-color: inspection.get-theme-color($theme, foreground, icon, 1), - - // Note: MDC has equivalents of these tokens, but they lead to much higher selector specificity. - inactive-label-text-color: $inactive-label-text-color, - active-label-text-color: $active-label-text-color, - - // Tokens needed to implement the gmat styles. Externally they don't change. - active-ripple-color: $ripple-color, - inactive-ripple-color: $ripple-color, - inactive-focus-label-text-color: $inactive-label-text-color, - inactive-hover-label-text-color: $inactive-label-text-color, - active-focus-label-text-color: $active-label-text-color, - active-hover-label-text-color: $active-label-text-color, - active-focus-indicator-color: $active-label-text-color, - active-hover-indicator-color: $active-label-text-color, - active-indicator-color: inspection.get-theme-color($theme, $palette-name, default), - background-color: inspection.get-theme-color($theme, $palette-name, default), - foreground-color: inspection.get-theme-color($theme, $palette-name, default-contrast), + tab-disabled-ripple-color: map.get($system, on-surface-variant), + tab-pagination-icon-color: map.get($system, on-surface), + tab-inactive-label-text-color: map.get($system, on-surface-variant), + tab-active-label-text-color: map.get($system, primary), + tab-active-ripple-color: map.get($system, primary), + tab-inactive-ripple-color: map.get($system, primary), + tab-inactive-focus-label-text-color: map.get($system, on-surface-variant), + tab-inactive-hover-label-text-color: map.get($system, on-surface-variant), + tab-active-focus-label-text-color: map.get($system, primary), + tab-active-hover-label-text-color: map.get($system, primary), + tab-active-focus-indicator-color: map.get($system, primary), + tab-active-hover-indicator-color: map.get($system, primary), + tab-active-indicator-color: map.get($system, primary), + tab-background-color: map.get($system, primary), + tab-foreground-color: map.get($system, on-primary), ); @each $token in $exclude { @@ -63,41 +66,3 @@ $prefix: (mat, tab); @return $tokens; } - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return ( - // Note: MDC has equivalents of these tokens, but they lead to much higher selector specificity. - label-text-font: inspection.get-theme-typography($theme, button, font-family), - label-text-size: inspection.get-theme-typography($theme, button, font-size), - label-text-tracking: inspection.get-theme-typography($theme, button, letter-spacing), - label-text-line-height: inspection.get-theme-typography($theme, button, line-height), - label-text-weight: inspection.get-theme-typography($theme, button, font-weight), - ); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $scale: theming.clamp-density(inspection.get-theme-density($theme), -4); - - @return ( - container-height: map.get(( - 0: 48px, - -1: 44px, - -2: 40px, - -3: 36px, - -4: 32px, - ), $scale), - ); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) - ); -} diff --git a/src/material/tabs/_m3-tabs.scss b/src/material/tabs/_m3-tabs.scss index db91da546ea8..b5236583183b 100644 --- a/src/material/tabs/_m3-tabs.scss +++ b/src/material/tabs/_m3-tabs.scss @@ -1,70 +1,58 @@ @use '../core/tokens/m3-utils'; -@use '../core/style/sass-utils'; @use 'sass:map'; @use 'sass:list'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, tab); +@use '../core/tokens/m3'; /// Generates the tokens for MDC tab -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of tokens for the MDC tab -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: sass-utils.merge-all( - m3-utils.generate-typography-tokens($systems, label-text, title-small), - ( - active-focus-indicator-color: map.get($systems, md-sys-color, primary), - active-focus-label-text-color: map.get($systems, md-sys-color, on-surface), - active-hover-indicator-color: map.get($systems, md-sys-color, primary), - active-hover-label-text-color: map.get($systems, md-sys-color, on-surface), - active-indicator-color: map.get($systems, md-sys-color, primary), - active-indicator-height: m3-utils.hardcode(2px, $exclude-hardcoded), - active-indicator-shape: m3-utils.hardcode(0, $exclude-hardcoded), - active-label-text-color: map.get($systems, md-sys-color, on-surface), - active-ripple-color: map.get($systems, md-sys-color, on-surface), - divider-color: map.get($systems, md-sys-color, surface-variant), - divider-height: m3-utils.hardcode(1px, $exclude-hardcoded), - inactive-focus-label-text-color: map.get($systems, md-sys-color, on-surface), - inactive-hover-label-text-color: map.get($systems, md-sys-color, on-surface), - inactive-label-text-color: map.get($systems, md-sys-color, on-surface), - inactive-ripple-color: map.get($systems, md-sys-color, on-surface), - pagination-icon-color: map.get($systems, md-sys-color, on-surface), - ), - ); +/// @param {String} $color-variant The color variant to use for the component +@function get-tokens($theme: m3.$sys-theme, $color-variant: null) { + $system: m3-utils.get-system($theme); + @if $color-variant { + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); + } - $variant-tokens: ( - // Color variants - primary: (), // Default, no overrides needed - secondary: ( - active-focus-indicator-color: map.get($systems, md-sys-color, secondary), - active-hover-indicator-color: map.get($systems, md-sys-color, secondary), - active-indicator-color: map.get($systems, md-sys-color, secondary), + @return ( + base: ( + tab-active-indicator-height: 2px, + tab-active-indicator-shape: 0, + tab-divider-height: 1px, ), - tertiary: ( - active-focus-indicator-color: map.get($systems, md-sys-color, tertiary), - active-hover-indicator-color: map.get($systems, md-sys-color, tertiary), - active-indicator-color: map.get($systems, md-sys-color, tertiary), + color: ( + tab-active-focus-indicator-color: map.get($system, primary), + tab-active-focus-label-text-color: map.get($system, on-surface), + tab-active-hover-indicator-color: map.get($system, primary), + tab-active-hover-label-text-color: map.get($system, on-surface), + tab-active-indicator-color: map.get($system, primary), + tab-active-label-text-color: map.get($system, on-surface), + tab-active-ripple-color: map.get($system, on-surface), + tab-divider-color: map.get($system, surface-variant), + tab-inactive-focus-label-text-color: map.get($system, on-surface), + tab-inactive-hover-label-text-color: map.get($system, on-surface), + tab-inactive-label-text-color: map.get($system, on-surface), + tab-inactive-ripple-color: map.get($system, on-surface), + tab-pagination-icon-color: map.get($system, on-surface), + tab-disabled-ripple-color: map.get($system, on-surface-variant), + tab-background-color: null, + tab-foreground-color: null, ), - error: ( - active-focus-indicator-color: map.get($systems, md-sys-color, error), - active-hover-indicator-color: map.get($systems, md-sys-color, error), - active-indicator-color: map.get($systems, md-sys-color, error), - ) + typography: ( + tab-label-text-font: map.get($system, title-small-font), + tab-label-text-line-height: map.get($system, title-small-line-height), + tab-label-text-size: map.get($system, title-small-size), + tab-label-text-tracking: map.get($system, title-small-tracking), + tab-label-text-weight: map.get($system, title-small-weight), + ), + density: get-density-tokens(map.get($system, density-scale)), ); - - @return m3-utils.namespace($prefix, ($tokens, $variant-tokens), $token-slots); } // Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $scale: theming.clamp-density(inspection.get-theme-density($theme), -4); +@function get-density-tokens($scale) { + $scale: theming.clamp-density($scale, -4); $index: ($scale * -1) + 1; @return ( - container-height: list.nth((48px, 44px, 40px, 36px, 32px), $index), + tab-container-height: list.nth((48px, 44px, 40px, 36px, 32px), $index), ); } diff --git a/src/material/tabs/_tabs-common.scss b/src/material/tabs/_tabs-common.scss index 3c0047953b9b..7523f6010dfd 100644 --- a/src/material/tabs/_tabs-common.scss +++ b/src/material/tabs/_tabs-common.scss @@ -1,7 +1,9 @@ @use '../core/style/vendor-prefixes'; -@use './m2-tabs'; +@use './m3-tabs'; @use '../core/tokens/token-utils'; +$fallbacks: m3-tabs.get-tokens(); + $mat-tab-animation-duration: 500ms !default; // Combines the various structural styles we need for the tab group and tab nav bar. @@ -19,6 +21,7 @@ $mat-tab-animation-duration: 500ms !default; white-space: nowrap; cursor: pointer; z-index: 1; + touch-action: manipulation; } .mdc-tab__content { @@ -98,17 +101,12 @@ $mat-tab-animation-duration: 500ms !default; // Tabs might be `button` elements so we have to reset the user agent styling. background: none; - @include token-utils.use-tokens(m2-tabs.$prefix, m2-tabs.get-token-slots()) { - height: token-utils.slot(container-height); - } - - @include token-utils.use-tokens(m2-tabs.$prefix, m2-tabs.get-token-slots()) { - font-family: token-utils.slot(label-text-font); - font-size: token-utils.slot(label-text-size); - letter-spacing: token-utils.slot(label-text-tracking); - line-height: token-utils.slot(label-text-line-height); - font-weight: token-utils.slot(label-text-weight); - } + height: token-utils.slot(tab-container-height, $fallbacks); + font-family: token-utils.slot(tab-label-text-font, $fallbacks); + font-size: token-utils.slot(tab-label-text-size, $fallbacks); + letter-spacing: token-utils.slot(tab-label-text-tracking, $fallbacks); + line-height: token-utils.slot(tab-label-text-line-height, $fallbacks); + font-weight: token-utils.slot(tab-label-text-weight, $fallbacks); &.mdc-tab { // MDC's tabs stretch to fit the header by default, whereas stretching on our current ones @@ -118,50 +116,46 @@ $mat-tab-animation-duration: 500ms !default; } .mdc-tab-indicator__content--underline { - @include token-utils.use-tokens(m2-tabs.$prefix, m2-tabs.get-token-slots()) { - border-color: token-utils.slot(active-indicator-color); - border-top-width: token-utils.slot(active-indicator-height); - border-radius: token-utils.slot(active-indicator-shape); - } + border-color: token-utils.slot(tab-active-indicator-color, $fallbacks); + border-top-width: token-utils.slot(tab-active-indicator-height, $fallbacks); + border-radius: token-utils.slot(tab-active-indicator-shape, $fallbacks); + } + + &:hover .mdc-tab__text-label { + color: token-utils.slot(tab-inactive-hover-label-text-color, $fallbacks); + } + + &:focus .mdc-tab__text-label { + color: token-utils.slot(tab-inactive-focus-label-text-color, $fallbacks); } - @include token-utils.use-tokens(m2-tabs.$prefix, m2-tabs.get-token-slots()) { - &:hover .mdc-tab__text-label { - color: token-utils.slot(inactive-hover-label-text-color); + &.mdc-tab--active { + .mdc-tab__text-label { + color: token-utils.slot(tab-active-label-text-color, $fallbacks); } - &:focus .mdc-tab__text-label { - color: token-utils.slot(inactive-focus-label-text-color); + .mdc-tab__ripple::before, + .mat-ripple-element { + background-color: token-utils.slot(tab-active-ripple-color, $fallbacks); } - &.mdc-tab--active { + &:hover { .mdc-tab__text-label { - color: token-utils.slot(active-label-text-color); + color: token-utils.slot(tab-active-hover-label-text-color, $fallbacks); } - .mdc-tab__ripple::before, - .mat-ripple-element { - background-color: token-utils.slot(active-ripple-color); + .mdc-tab-indicator__content--underline { + border-color: token-utils.slot(tab-active-hover-indicator-color, $fallbacks); } + } - &:hover { - .mdc-tab__text-label { - color: token-utils.slot(active-hover-label-text-color); - } - - .mdc-tab-indicator__content--underline { - border-color: token-utils.slot(active-hover-indicator-color); - } + &:focus { + .mdc-tab__text-label { + color: token-utils.slot(tab-active-focus-label-text-color, $fallbacks); } - &:focus { - .mdc-tab__text-label { - color: token-utils.slot(active-focus-label-text-color); - } - - .mdc-tab-indicator__content--underline { - border-color: token-utils.slot(active-focus-indicator-color); - } + .mdc-tab-indicator__content--underline { + border-color: token-utils.slot(tab-active-focus-indicator-color, $fallbacks); } } } @@ -183,11 +177,9 @@ $mat-tab-animation-duration: 500ms !default; pointer-events: none; } - @include token-utils.use-tokens(m2-tabs.$prefix, m2-tabs.get-token-slots()) { - .mdc-tab__ripple::before, - .mat-ripple-element { - background-color: token-utils.slot(disabled-ripple-color); - } + .mdc-tab__ripple::before, + .mat-ripple-element { + background-color: token-utils.slot(tab-disabled-ripple-color, $fallbacks); } } @@ -203,16 +195,11 @@ $mat-tab-animation-duration: 500ms !default; bottom: 0; opacity: 0; pointer-events: none; - - @include token-utils.use-tokens(m2-tabs.$prefix, m2-tabs.get-token-slots()) { - background-color: token-utils.slot(inactive-ripple-color); - } + background-color: token-utils.slot(tab-inactive-ripple-color, $fallbacks); } .mdc-tab__text-label { - @include token-utils.use-tokens(m2-tabs.$prefix, m2-tabs.get-token-slots()) { - color: token-utils.slot(inactive-label-text-color); - } + color: token-utils.slot(tab-inactive-label-text-color, $fallbacks); // We support projecting icons into the tab. These styles ensure that they're centered. display: inline-flex; @@ -245,9 +232,7 @@ $mat-tab-animation-duration: 500ms !default; .mat-ripple-element { opacity: 0.12; - @include token-utils.use-tokens(m2-tabs.$prefix, m2-tabs.get-token-slots()) { - background-color: token-utils.slot(inactive-ripple-color); - } + background-color: token-utils.slot(tab-inactive-ripple-color, $fallbacks); } } @@ -285,10 +270,7 @@ $mat-tab-animation-duration: 500ms !default; .mat-ripple-element { opacity: 0.12; - - @include token-utils.use-tokens(m2-tabs.$prefix, m2-tabs.get-token-slots()) { - background-color: token-utils.slot(inactive-ripple-color); - } + background-color: token-utils.slot(tab-inactive-ripple-color, $fallbacks); } .mat-mdc-tab-header-pagination-controls-enabled & { @@ -319,10 +301,7 @@ $mat-tab-animation-duration: 500ms !default; border-width: 2px 2px 0 0; height: 8px; width: 8px; - - @include token-utils.use-tokens(m2-tabs.$prefix, m2-tabs.get-token-slots()) { - border-color: token-utils.slot(pagination-icon-color); - } + border-color: token-utils.slot(tab-pagination-icon-color, $fallbacks); } .mat-mdc-tab-header-pagination-disabled { @@ -368,9 +347,7 @@ $mat-tab-animation-duration: 500ms !default; // list, but it ended up being hugely breaking internally. .cdk-drop-list &, &.cdk-drop-list { - @include token-utils.use-tokens(m2-tabs.$prefix, m2-tabs.get-token-slots()) { - min-height: token-utils.slot(container-height); - } + min-height: token-utils.slot(tab-container-height, $fallbacks); } } @@ -382,72 +359,68 @@ $mat-tab-animation-duration: 500ms !default; flex-grow: 1; overflow: hidden; z-index: 1; - @include token-utils.use-tokens(m2-tabs.$prefix, m2-tabs.get-token-slots()) { - border-bottom-style: solid; - border-bottom-width: token-utils.slot(divider-height); - border-bottom-color: token-utils.slot(divider-color); - - @if ($inverted-header-selector) { - #{$inverted-header-selector} & { - border-bottom: none; - border-top-style: solid; - border-top-width: token-utils.slot(divider-height); - border-top-color: token-utils.slot(divider-color); - } + border-bottom-style: solid; + border-bottom-width: token-utils.slot(tab-divider-height, $fallbacks); + border-bottom-color: token-utils.slot(tab-divider-color, $fallbacks); + + @if ($inverted-header-selector) { + #{$inverted-header-selector} & { + border-bottom: none; + border-top-style: solid; + border-top-width: token-utils.slot(tab-divider-height, $fallbacks); + border-top-color: token-utils.slot(tab-divider-color, $fallbacks); } } } @mixin paginated-tab-header-with-background($header-selector, $tab-selector) { &.mat-tabs-with-background { - @include token-utils.use-tokens(m2-tabs.$prefix, m2-tabs.get-token-slots()) { - // Note that these selectors target direct descendants so - // that the styles don't apply to any nested tab groups. - > #{$header-selector}, > .mat-mdc-tab-header-pagination { - // Set background color for the tab group - background-color: token-utils.slot(background-color); + // Note that these selectors target direct descendants so + // that the styles don't apply to any nested tab groups. + > #{$header-selector}, > .mat-mdc-tab-header-pagination { + // Set background color for the tab group + background-color: token-utils.slot(tab-background-color, $fallbacks); + } + + // Note: this is only scoped to primary, because the legacy tabs had the incorrect behavior + // where setting both a background and `mat-accent` would add the background, but keep + // accent on the selected tab. There are some internal apps whose design depends on this now + // so we have to replicate it here. + &.mat-primary > #{$header-selector} { + // Set labels to contrast against background + #{$tab-selector} .mdc-tab__text-label { + color: token-utils.slot(tab-foreground-color, $fallbacks); } - // Note: this is only scoped to primary, because the legacy tabs had the incorrect behavior - // where setting both a background and `mat-accent` would add the background, but keep - // accent on the selected tab. There are some internal apps whose design depends on this now - // so we have to replicate it here. - &.mat-primary > #{$header-selector} { - // Set labels to contrast against background - #{$tab-selector} .mdc-tab__text-label { - color: token-utils.slot(foreground-color); + .mdc-tab-indicator__content--underline { + border-color: token-utils.slot(tab-foreground-color, $fallbacks); + } + } + + &:not(.mat-primary) > #{$header-selector} { + #{$tab-selector}:not(.mdc-tab--active) { + .mdc-tab__text-label { + color: token-utils.slot(tab-foreground-color, $fallbacks); } .mdc-tab-indicator__content--underline { - border-color: token-utils.slot(foreground-color); + border-color: token-utils.slot(tab-foreground-color, $fallbacks); } } + } - &:not(.mat-primary) > #{$header-selector} { - #{$tab-selector}:not(.mdc-tab--active) { - .mdc-tab__text-label { - color: token-utils.slot(foreground-color); - } - - .mdc-tab-indicator__content--underline { - border-color: token-utils.slot(foreground-color); - } - } + > #{$header-selector}, > .mat-mdc-tab-header-pagination { + .mat-mdc-tab-header-pagination-chevron, + .mat-focus-indicator::before { + border-color: token-utils.slot(tab-foreground-color, $fallbacks); } - > #{$header-selector}, > .mat-mdc-tab-header-pagination { - .mat-mdc-tab-header-pagination-chevron, - .mat-focus-indicator::before { - border-color: token-utils.slot(foreground-color); - } - - .mat-ripple-element, .mdc-tab__ripple::before { - background-color: token-utils.slot(foreground-color); - } + .mat-ripple-element, .mdc-tab__ripple::before { + background-color: token-utils.slot(tab-foreground-color, $fallbacks); + } - .mat-mdc-tab-header-pagination-chevron { - color: token-utils.slot(foreground-color); - } + .mat-mdc-tab-header-pagination-chevron { + color: token-utils.slot(tab-foreground-color, $fallbacks); } } } diff --git a/src/material/tabs/_tabs-theme.scss b/src/material/tabs/_tabs-theme.scss index dd87a7bcc104..1c3afa5f42d3 100644 --- a/src/material/tabs/_tabs-theme.scss +++ b/src/material/tabs/_tabs-theme.scss @@ -1,79 +1,67 @@ -@use '../core/style/sass-utils'; @use './m2-tabs'; @use './m3-tabs'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; @use '../core/typography/typography'; @use '../core/tokens/token-utils'; @use 'sass:map'; -/// Outputs base theme styles (styles not dependent on the color, typography, or density settings) -/// for the mat-tab. -/// @param {Map} $theme The theme to generate base styles for. @mixin base($theme) { + $tokens: map.get(m2-tabs.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-tabs.$prefix, m2-tabs.get-unthemable-tokens()); - } + $tokens: map.get(m3-tabs.get-tokens($theme), base); } + + @include token-utils.values($tokens); } /// Outputs color theme styles for the mat-tab. /// @param {Map} $theme The theme to generate color styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the tab indicator: primary, secondary, -/// tertiary, or error (If not specified, default primary color will be used). -@mixin color($theme, $options...) { +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin color($theme, $color-variant: null) { @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color), $options...); + $tokens: map.get(m3-tabs.get-tokens($theme, $color-variant), color); + @include token-utils.values($tokens); } @else { .mat-mdc-tab-group, .mat-mdc-tab-nav-bar { - @include token-utils.create-token-values-mixed( - m2-tabs.$prefix, m2-tabs.get-color-tokens($theme, primary, - $exclude: (background-color, foreground-color))); + @include token-utils.values( + m2-tabs.private-get-color-palette-color-tokens($theme, primary, + $exclude: (tab-background-color, tab-foreground-color))); &.mat-accent { - @include token-utils.create-token-values-mixed( - m2-tabs.$prefix, m2-tabs.get-color-tokens($theme, accent, - $exclude: (background-color, foreground-color))); + @include token-utils.values( + m2-tabs.private-get-color-palette-color-tokens($theme, secondary, + $exclude: (tab-background-color, tab-foreground-color))); } &.mat-warn { - @include token-utils.create-token-values-mixed( - m2-tabs.$prefix, m2-tabs.get-color-tokens($theme, warn, - $exclude: (background-color, foreground-color))); + @include token-utils.values( + m2-tabs.private-get-color-palette-color-tokens($theme, error, + $exclude: (tab-background-color, tab-foreground-color))); } &.mat-background-primary { - $tokens: m2-tabs.get-color-tokens($theme, primary); - @include token-utils.create-token-values-mixed( - m2-tabs.$prefix, ( - background-color: map.get($tokens, background-color), - foreground-color: map.get($tokens, foreground-color), - )); + $tokens: m2-tabs.private-get-color-palette-color-tokens($theme, primary); + @include token-utils.values(( + tab-background-color: map.get($tokens, tab-background-color), + tab-foreground-color: map.get($tokens, tab-foreground-color) + )); } &.mat-background-accent { - $tokens: m2-tabs.get-color-tokens($theme, accent); - @include token-utils.create-token-values-mixed( - m2-tabs.$prefix, ( - background-color: map.get($tokens, background-color), - foreground-color: map.get($tokens, foreground-color), - )); + $tokens: m2-tabs.private-get-color-palette-color-tokens($theme, secondary); + @include token-utils.values(( + tab-background-color: map.get($tokens, tab-background-color), + tab-foreground-color: map.get($tokens, tab-foreground-color), + )); } &.mat-background-warn { - $tokens: m2-tabs.get-color-tokens($theme, warn); - @include token-utils.create-token-values-mixed( - m2-tabs.$prefix, ( - background-color: map.get($tokens, background-color), - foreground-color: map.get($tokens, foreground-color), - )); + $tokens: m2-tabs.private-get-color-palette-color-tokens($theme, error); + @include token-utils.values(( + tab-background-color: map.get($tokens, tab-background-color), + tab-foreground-color: map.get($tokens, tab-foreground-color), + )); } } } @@ -83,11 +71,10 @@ /// @param {Map} $theme The theme to generate typography styles for. @mixin typography($theme) { @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); + @include token-utils.values(map.get(m3-tabs.get-tokens($theme), typography)); } @else { .mat-mdc-tab-header { - @include token-utils.create-token-values-mixed( - m2-tabs.$prefix, m2-tabs.get-typography-tokens($theme)); + @include token-utils.values(map.get(m2-tabs.get-tokens($theme), typography)); } } } @@ -96,11 +83,10 @@ /// @param {Map} $theme The theme to generate density styles for. @mixin density($theme) { @if inspection.get-theme-version($theme) == 1 { - @include token-utils.create-token-values(m3-tabs.$prefix, m3-tabs.get-density-tokens($theme)); + @include token-utils.values(map.get(m3-tabs.get-tokens($theme), density)); } @else { .mat-mdc-tab-header { - @include token-utils.create-token-values-mixed( - m2-tabs.$prefix, m2-tabs.get-density-tokens($theme)); + @include token-utils.values(map.get(m2-tabs.get-tokens($theme), density)); } } } @@ -109,8 +95,8 @@ @function _define-overrides() { @return ( ( - namespace: m2-tabs.$prefix, - tokens: m2-tabs.get-token-slots(), + namespace: tab, + tokens: token-utils.get-overrides(m3-tabs.get-tokens(), tab) ), ); } @@ -118,43 +104,28 @@ /// Outputs the CSS variable values for the given tokens. /// @param {Map} $tokens The token values to emit. @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } /// Outputs all (base, color, typography, and density) theme styles for the mat-tab. /// @param {Map} $theme The theme to generate styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the tab indicator: primary, secondary, -/// tertiary, or error (If not specified, default primary color will be used). -@mixin theme($theme, $options...) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-tabs') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme), $options...); - @if inspection.theme-has($theme, density) { - @include density($theme); - } - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } +/// @param {String} $color-variant The color variant to use for the component (M3 only) +@mixin theme($theme, $color-variant: null) { + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme, $color-variant); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } } } - -@mixin _theme-from-tokens($tokens, $options...) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - $tabs-tokens: token-utils.get-tokens-for($tokens, m2-tabs.$prefix, $options...); - $tabs-tokens-without-options: token-utils.get-tokens-for($tokens, m2-tabs.$prefix); - - @include token-utils.create-token-values(m2-tabs.$prefix, $tabs-tokens-without-options); - @include token-utils.create-token-values(m2-tabs.$prefix, $tabs-tokens); -} diff --git a/src/material/tabs/paginated-tab-header.ts b/src/material/tabs/paginated-tab-header.ts index 6dbde9e09556..407b49418c48 100644 --- a/src/material/tabs/paginated-tab-header.ts +++ b/src/material/tabs/paginated-tab-header.ts @@ -119,7 +119,7 @@ export abstract class MatPaginatedTabHeader private _scrollDistanceChanged: boolean; /** Used to manage focus between the tabs. */ - private _keyManager: FocusKeyManager; + protected _keyManager: FocusKeyManager | undefined; /** Cached text content of the header. */ private _currentTextContent: string; @@ -218,7 +218,9 @@ export abstract class MatPaginatedTabHeader // Allow focus to land on disabled tabs, as per https://w3c.github.io/aria-practices/#kbd_disabled_controls .skipPredicate(() => false); - this._keyManager.updateActiveItem(this._selectedIndex); + // Fall back to the first link as being active if there isn't a selected one. + // This is relevant primarily for the tab nav bar. + this._keyManager.updateActiveItem(Math.max(this._selectedIndex, 0)); // Note: We do not need to realign after the first render for proper functioning of the tabs // the resize events above should fire when we first start observing the element. However, @@ -243,7 +245,7 @@ export abstract class MatPaginatedTabHeader realign(); }); }); - this._keyManager.withHorizontalOrientation(this._getLayoutDirection()); + this._keyManager?.withHorizontalOrientation(this._getLayoutDirection()); }); // If there is a change in the focus key manager we need to emit the `indexFocused` @@ -339,7 +341,7 @@ export abstract class MatPaginatedTabHeader } break; default: - this._keyManager.onKeydown(event); + this._keyManager?.onKeydown(event); } } diff --git a/src/material/tabs/tab-body.spec.ts b/src/material/tabs/tab-body.spec.ts index 08b8dc206ccf..ab4cea850053 100644 --- a/src/material/tabs/tab-body.spec.ts +++ b/src/material/tabs/tab-body.spec.ts @@ -1,30 +1,30 @@ -import {Direction, Directionality} from '@angular/cdk/bidi'; +import {Direction} from '@angular/cdk/bidi'; import {PortalModule, TemplatePortal} from '@angular/cdk/portal'; import {CdkScrollable, ScrollingModule} from '@angular/cdk/scrolling'; +import {provideFakeDirectionality} from '@angular/cdk/testing/private'; import { AfterViewInit, Component, TemplateRef, ViewChild, ViewContainerRef, + WritableSignal, inject, signal, } from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; -import {MatRippleModule} from '../core'; import {By} from '@angular/platform-browser'; -import {Subject} from 'rxjs'; +import {MatRippleModule} from '../core'; import {MatTabBody, MatTabBodyPortal} from './tab-body'; describe('MatTabBody', () => { - let dir: Direction = 'ltr'; - let dirChange: Subject = new Subject(); + let dir: WritableSignal; beforeEach(waitForAsync(() => { - dir = 'ltr'; + dir = signal('ltr'); TestBed.configureTestingModule({ imports: [PortalModule, MatRippleModule, MatTabBody, MatTabBodyPortal, SimpleTabBodyApp], - providers: [{provide: Directionality, useFactory: () => ({value: dir, change: dirChange})}], + providers: [provideFakeDirectionality(dir)], }); })); @@ -40,7 +40,7 @@ describe('MatTabBody', () => { let fixture: ComponentFixture; beforeEach(() => { - dir = 'ltr'; + dir.set('ltr'); fixture = TestBed.createComponent(SimpleTabBodyApp); fixture.detectChanges(); }); @@ -74,7 +74,7 @@ describe('MatTabBody', () => { let fixture: ComponentFixture; beforeEach(() => { - dir = 'rtl'; + dir.set('rtl'); fixture = TestBed.createComponent(SimpleTabBodyApp); fixture.detectChanges(); }); @@ -112,8 +112,7 @@ describe('MatTabBody', () => { expect(fixture.componentInstance.tabBody._position).toBe('right'); - dirChange.next('rtl'); - dir = 'rtl'; + dir.set('rtl'); fixture.detectChanges(); diff --git a/src/material/tabs/tab-group.ts b/src/material/tabs/tab-group.ts index 1d561907b607..8077f02e1396 100644 --- a/src/material/tabs/tab-group.ts +++ b/src/material/tabs/tab-group.ts @@ -124,10 +124,10 @@ export class MatTabGroup /** * Theme color of the tab group. This API is supported in M2 themes only, it - * has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/tabs/styling. + * has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/tabs/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() color: ThemePalette; @@ -217,10 +217,10 @@ export class MatTabGroup /** * Theme color of the background of the tab group. This API is supported in M2 themes only, it - * has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/tabs/styling. + * has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/tabs/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants * * @deprecated The background color should be customized through Sass theming APIs. * @breaking-change 20.0.0 Remove this input diff --git a/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts b/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts index d23b685fcfac..1b99f060ae69 100644 --- a/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts +++ b/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts @@ -1,28 +1,29 @@ -import {Direction, Directionality} from '@angular/cdk/bidi'; +import {Direction} from '@angular/cdk/bidi'; import {ENTER, SPACE} from '@angular/cdk/keycodes'; import {SharedResizeObserver} from '@angular/cdk/observers/private'; import { dispatchFakeEvent, dispatchKeyboardEvent, dispatchMouseEvent, + provideFakeDirectionality, } from '@angular/cdk/testing/private'; -import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core'; -import {ComponentFixture, TestBed, fakeAsync, tick, waitForAsync} from '@angular/core/testing'; -import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '../../core'; +import {Component, QueryList, signal, ViewChild, ViewChildren, WritableSignal} from '@angular/core'; +import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {Subject} from 'rxjs'; +import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '../../core'; import {MAT_TABS_CONFIG} from '../index'; import {MatTabsModule} from '../module'; import {MatTabLink, MatTabNav} from './tab-nav-bar'; describe('MatTabNavBar', () => { - let dir: Direction = 'ltr'; - let dirChange = new Subject(); + let dir: WritableSignal; let globalRippleOptions: RippleGlobalOptions; let resizeEvents: Subject; beforeEach(waitForAsync(() => { globalRippleOptions = {}; + dir = signal('ltr'); TestBed.configureTestingModule({ imports: [ @@ -33,7 +34,7 @@ describe('MatTabNavBar', () => { ], providers: [ {provide: MAT_RIPPLE_GLOBAL_OPTIONS, useFactory: () => globalRippleOptions}, - {provide: Directionality, useFactory: () => ({value: dir, change: dirChange})}, + provideFakeDirectionality(dir), ], }); @@ -97,24 +98,6 @@ describe('MatTabNavBar', () => { .toBe(true); }); - it('should update the tabindex if links are disabled', () => { - const tabLinkElements = fixture.debugElement - .queryAll(By.css('a')) - .map(tabLinkDebugEl => tabLinkDebugEl.nativeElement); - - expect(tabLinkElements.map(tabLink => tabLink.tabIndex)) - .withContext('Expected first element to be keyboard focusable by default') - .toEqual([0, -1, -1]); - - fixture.componentInstance.disabled = true; - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - - expect(tabLinkElements.every(tabLink => tabLink.tabIndex === -1)) - .withContext('Expected element to no longer be keyboard focusable if disabled.') - .toBe(true); - }); - it('should mark disabled links', () => { const tabLinkElement = fixture.debugElement.query(By.css('a')).nativeElement; @@ -147,7 +130,7 @@ describe('MatTabNavBar', () => { spyOn(inkBar, 'alignToElement'); - dirChange.next(); + dir.set('rtl'); tick(); fixture.detectChanges(); @@ -293,6 +276,15 @@ describe('MatTabNavBar', () => { expect(tabLinks[2].tabIndex).toBe(-1); }); + it('should set a tabindex even if the only tab is disabled', () => { + const fixture = TestBed.createComponent(TabBarWithDisabledTabOnInit); + fixture.detectChanges(); + + const tab: HTMLElement = fixture.nativeElement.querySelector('.mat-mdc-tab-link'); + expect(tab.getAttribute('aria-disabled')).toBe('true'); + expect(tab.tabIndex).toBe(0); + }); + it('should setup aria-controls properly', () => { const fixture = TestBed.createComponent(SimpleTabNavBarTestApp); fixture.detectChanges(); @@ -632,3 +624,14 @@ class TabBarWithInactiveTabsOnInit { class TabsWithCustomAnimationDuration { links = ['First', 'Second', 'Third']; } + +@Component({ + template: ` + + Tab panel + `, + imports: [MatTabsModule], +}) +class TabBarWithDisabledTabOnInit {} diff --git a/src/material/tabs/tab-nav-bar/tab-nav-bar.ts b/src/material/tabs/tab-nav-bar/tab-nav-bar.ts index 88597047df37..c40ff234a247 100644 --- a/src/material/tabs/tab-nav-bar/tab-nav-bar.ts +++ b/src/material/tabs/tab-nav-bar/tab-nav-bar.ts @@ -22,6 +22,8 @@ import { ViewEncapsulation, inject, HostAttributeToken, + signal, + computed, } from '@angular/core'; import { MAT_RIPPLE_GLOBAL_OPTIONS, @@ -39,7 +41,7 @@ import {BehaviorSubject, Subject} from 'rxjs'; import {startWith, takeUntil} from 'rxjs/operators'; import {ENTER, SPACE} from '@angular/cdk/keycodes'; import {MAT_TABS_CONFIG, MatTabsConfig} from '../tab-config'; -import {MatPaginatedTabHeader} from '../paginated-tab-header'; +import {MatPaginatedTabHeader, MatPaginatedTabHeaderItem} from '../paginated-tab-header'; import {CdkObserveContent} from '@angular/cdk/observers'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; @@ -70,6 +72,8 @@ import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; imports: [MatRipple, CdkObserveContent], }) export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit, AfterViewInit { + _focusedItem = signal(null); + /** Whether the ink bar should fit its width to the size of the tab label content. */ @Input({transform: booleanAttribute}) get fitInkBarToContent(): boolean { @@ -102,10 +106,10 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit /** * Theme color of the background of the tab nav. This API is supported in M2 themes only, it - * has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/tabs/styling. + * has no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/tabs/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() get backgroundColor(): ThemePalette { @@ -127,14 +131,20 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit /** Whether the ripple effect is disabled or not. */ @Input({transform: booleanAttribute}) - disableRipple: boolean = false; + get disableRipple() { + return this._disableRipple(); + } + set disableRipple(value: boolean) { + this._disableRipple.set(value); + } + private _disableRipple = signal(false); /** * Theme color of the nav bar. This API is supported in M2 themes only, it has - * no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/tabs/styling. + * no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/tabs/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() color: ThemePalette = 'primary'; @@ -183,6 +193,11 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit .subscribe(() => this.updateActiveLink()); super.ngAfterContentInit(); + + // Turn the `change` stream into a signal to try and avoid "changed after checked" errors. + this._keyManager!.change.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => + this._focusedItem.set(this._keyManager?.activeItem || null), + ); } override ngAfterViewInit() { @@ -203,12 +218,13 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit for (let i = 0; i < items.length; i++) { if (items[i].active) { this.selectedIndex = i; - this._changeDetectorRef.markForCheck(); - if (this.tabPanel) { this.tabPanel._activeTabId = items[i].id; } - + // Updating the `selectedIndex` won't trigger the `change` event on + // the key manager so we need to set the signal from here. + this._focusedItem.set(items[i]); + this._changeDetectorRef.markForCheck(); return; } } @@ -219,6 +235,10 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit _getRole(): string | null { return this.tabPanel ? 'tablist' : this._elementRef.nativeElement.getAttribute('role'); } + + _hasFocus(link: MatTabLink): boolean { + return this._keyManager?.activeItem === link; + } } /** @@ -238,7 +258,7 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit '[attr.aria-disabled]': 'disabled', '[attr.aria-selected]': '_getAriaSelected()', '[attr.id]': 'id', - '[attr.tabIndex]': '_getTabIndex()', + '[attr.tabIndex]': '_tabIndex()', '[attr.role]': '_getRole()', '[class.mat-mdc-tab-disabled]': 'disabled', '[class.mdc-tab--active]': 'active', @@ -260,6 +280,10 @@ export class MatTabLink /** Whether the tab link is active or not. */ protected _isActive: boolean = false; + protected _tabIndex = computed(() => + this._tabNavBar._focusedItem() === this ? this.tabIndex : -1, + ); + /** Whether the link is active. */ @Input({transform: booleanAttribute}) get active(): boolean { @@ -279,7 +303,13 @@ export class MatTabLink /** Whether ripples are disabled on the tab link. */ @Input({transform: booleanAttribute}) - disableRipple: boolean = false; + get disableRipple() { + return this._disableRipple(); + } + set disableRipple(value: boolean) { + this._disableRipple.set(value); + } + private _disableRipple = signal(false); @Input({ transform: (value: unknown) => (value == null ? 0 : numberAttribute(value)), @@ -393,14 +423,6 @@ export class MatTabLink _getRole(): string | null { return this._tabNavBar.tabPanel ? 'tab' : this.elementRef.nativeElement.getAttribute('role'); } - - _getTabIndex(): number { - if (this._tabNavBar.tabPanel) { - return this._isActive && !this.disabled ? 0 : -1; - } else { - return this.disabled ? -1 : this.tabIndex; - } - } } /** diff --git a/src/material/timepicker/_m2-timepicker.scss b/src/material/timepicker/_m2-timepicker.scss index 430b9b8678bd..d7da794d6e25 100644 --- a/src/material/timepicker/_m2-timepicker.scss +++ b/src/material/timepicker/_m2-timepicker.scss @@ -1,44 +1,19 @@ -@use '../core/tokens/m2-utils'; -@use '../core/theming/inspection'; -@use '../core/style/sass-utils'; @use '../core/style/elevation'; +@use '../core/tokens/m2-utils'; +@use 'sass:map'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, timepicker); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { @return ( - container-shape: 4px, - container-elevation-shadow: elevation.get-box-shadow(8), - ); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - @return ( - container-background-color: inspection.get-theme-color($theme, background, card) - ); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return (); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + base: ( + timepicker-container-shape: 4px, + timepicker-container-elevation-shadow: elevation.get-box-shadow(8), + ), + color: ( + timepicker-container-background-color: map.get($system, surface) + ), + typography: (), + density: (), ); } diff --git a/src/material/timepicker/_m3-timepicker.scss b/src/material/timepicker/_m3-timepicker.scss index 3edc539c0926..8c8e3c5663f5 100644 --- a/src/material/timepicker/_m3-timepicker.scss +++ b/src/material/timepicker/_m3-timepicker.scss @@ -1,22 +1,21 @@ @use 'sass:map'; @use '../core/style/elevation'; @use '../core/tokens/m3-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, timepicker); +@use '../core/tokens/m3'; /// Generates custom tokens for the mat-timepicker. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-timepicker -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: ( - container-background-color: map.get($systems, md-sys-color, surface-container), - container-shape: map.get($systems, md-sys-shape, corner-extra-small), - container-elevation-shadow: - m3-utils.hardcode(elevation.get-box-shadow(2), $exclude-hardcoded), - ); +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); - @return m3-utils.namespace($prefix, $tokens, $token-slots); + @return ( + base: ( + timepicker-container-shape: map.get($system, corner-extra-small), + ), + color: ( + timepicker-container-background-color: map.get($system, surface-container), + timepicker-container-elevation-shadow: elevation.get-box-shadow(2), + ), + typography: (), + density: (), + ); } diff --git a/src/material/timepicker/_timepicker-theme.scss b/src/material/timepicker/_timepicker-theme.scss index ae76f7aba02e..29fc4dc7348d 100644 --- a/src/material/timepicker/_timepicker-theme.scss +++ b/src/material/timepicker/_timepicker-theme.scss @@ -1,82 +1,52 @@ @use 'sass:map'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; @use '../core/typography/typography'; -@use '../core/style/sass-utils'; @use './m2-timepicker'; +@use './m3-timepicker'; @use '../core/tokens/token-utils'; -/// Outputs base theme styles (styles not dependent on the color, typography, or density settings) -/// for the mat-timepicker. -/// @param {Map} $theme The theme to generate base styles for. @mixin base($theme) { + $tokens: map.get(m2-timepicker.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-timepicker.$prefix, - m2-timepicker.get-unthemable-tokens() - ); - } + $tokens: map.get(m3-timepicker.get-tokens($theme), base); } + + @include token-utils.values($tokens); } -/// Outputs color theme styles for the mat-timepicker. -/// @param {Map} $theme The theme to generate color styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the main selection: primary, secondary, tertiary, -/// or error (If not specified, default primary color will be used). -@mixin color($theme, $options...) { +@mixin color($theme) { + $tokens: map.get(m2-timepicker.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color), $options...); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-timepicker.$prefix, - m2-timepicker.get-color-tokens($theme) - ); - } + $tokens: map.get(m3-timepicker.get-tokens($theme), color); } + + @include token-utils.values($tokens); } -/// Outputs typography theme styles for the mat-timepicker. -/// @param {Map} $theme The theme to generate typography styles for. @mixin typography($theme) { + $tokens: map.get(m2-timepicker.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-timepicker.$prefix, - m2-timepicker.get-typography-tokens($theme) - ); - } + $tokens: map.get(m3-timepicker.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } -/// Outputs density theme styles for the mat-timepicker. -/// @param {Map} $theme The theme to generate density styles for. @mixin density($theme) { + $tokens: map.get(m2-timepicker.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - // There are no M3 density tokens for this component - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-timepicker.$prefix, - m2-timepicker.get-density-tokens($theme) - ); - } + $tokens: map.get(m3-timepicker.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-timepicker.$prefix, - tokens: m2-timepicker.get-token-slots(), + namespace: timepicker, + tokens: token-utils.get-overrides(m3-timepicker.get-tokens(), timepicker) ), ); } @@ -84,41 +54,28 @@ /// Outputs the CSS variable values for the given tokens. /// @param {Map} $tokens The token values to emit. @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } /// Outputs all (base, color, typography, and density) theme styles for the mat-timepicker. /// @param {Map} $theme The theme to generate styles for. -/// @param {ArgList} Additional optional arguments (only supported for M3 themes): -/// $color-variant: The color variant to use for the main selection: primary, secondary, tertiary, -/// or error (If not specified, default primary color will be used). -@mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-timepicker') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } +/// @param {String} $color-variant The color variant to use for the component +@mixin theme($theme, $color-variant: null) { + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - @include token-utils.create-token-values( - m2-timepicker.$prefix, - map.get($tokens, m2-timepicker.$prefix) - ); } } diff --git a/src/material/timepicker/timepicker.scss b/src/material/timepicker/timepicker.scss index e81e5ea23bc8..391a565d7271 100644 --- a/src/material/timepicker/timepicker.scss +++ b/src/material/timepicker/timepicker.scss @@ -1,6 +1,8 @@ @use '@angular/cdk'; @use '../core/tokens/token-utils'; -@use './m2-timepicker'; +@use './m3-timepicker'; + +$fallbacks: m3-timepicker.get-tokens(); @keyframes _mat-timepicker-enter { from { @@ -40,12 +42,10 @@ mat-timepicker { // panel which is required for scroll calculations (see #30974). position: relative; - @include token-utils.use-tokens(m2-timepicker.$prefix, m2-timepicker.get-token-slots()) { - border-bottom-left-radius: token-utils.slot(container-shape); - border-bottom-right-radius: token-utils.slot(container-shape); - box-shadow: token-utils.slot(container-elevation-shadow); - background-color: token-utils.slot(container-background-color); - } + border-bottom-left-radius: token-utils.slot(timepicker-container-shape, $fallbacks); + border-bottom-right-radius: token-utils.slot(timepicker-container-shape, $fallbacks); + box-shadow: token-utils.slot(timepicker-container-elevation-shadow, $fallbacks); + background-color: token-utils.slot(timepicker-container-background-color, $fallbacks); @include cdk.high-contrast { outline: solid 1px; @@ -54,11 +54,8 @@ mat-timepicker { .mat-timepicker-above & { border-bottom-left-radius: 0; border-bottom-right-radius: 0; - - @include token-utils.use-tokens(m2-timepicker.$prefix, m2-timepicker.get-token-slots()) { - border-top-left-radius: token-utils.slot(container-shape); - border-top-right-radius: token-utils.slot(container-shape); - } + border-top-left-radius: token-utils.slot(timepicker-container-shape, $fallbacks); + border-top-right-radius: token-utils.slot(timepicker-container-shape, $fallbacks); } } diff --git a/src/material/toolbar/README.md b/src/material/toolbar/README.md index 840ec88d90b5..72dd4eca7442 100644 --- a/src/material/toolbar/README.md +++ b/src/material/toolbar/README.md @@ -1 +1 @@ -Please see the official documentation at https://material.angular.io/components/component/toolbar \ No newline at end of file +Please see the official documentation at https://material.angular.dev/components/component/toolbar diff --git a/src/material/toolbar/_m2-toolbar.scss b/src/material/toolbar/_m2-toolbar.scss index c4b2ca60a8e1..cffbceff1cb6 100644 --- a/src/material/toolbar/_m2-toolbar.scss +++ b/src/material/toolbar/_m2-toolbar.scss @@ -1,75 +1,43 @@ @use 'sass:map'; -@use '../core/tokens/m2-utils'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; -@use '../core/style/sass-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, toolbar); - -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - @return (); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - @return private-get-color-palette-color-tokens( - $background-color: inspection.get-theme-color($theme, background, app-bar), - $text-color: inspection.get-theme-color($theme, foreground, text), - ); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return ( - title-text-font: inspection.get-theme-typography($theme, headline-6, font-family), - title-text-line-height: inspection.get-theme-typography($theme, headline-6, line-height), - title-text-size: inspection.get-theme-typography($theme, headline-6, font-size), - title-text-tracking: inspection.get-theme-typography($theme, headline-6, letter-spacing), - title-text-weight: inspection.get-theme-typography($theme, headline-6, font-weight), - ); -} +@use '../core/tokens/m2-utils'; -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $density-scale: theming.clamp-density(inspection.get-theme-density($theme), -3); - $standard-scale: ( - 0: 64px, - -1: 60px, - -2: 56px, - -3: 52px, - ); - - $mobile-scale: ( - 0: 56px, - -1: 52px, - -2: 48px, - -3: 44px, - ); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); + $density-scale: theming.clamp-density(map.get($system, density-scale), -3); @return ( - standard-height: map.get($standard-scale, $density-scale), - mobile-height: map.get($mobile-scale, $density-scale), + base: (), + color: private-get-color-palette-color-tokens( + map.get($system, surface), map.get($system, on-surface)), + typography: ( + toolbar-title-text-font: map.get($system, title-small-font), + toolbar-title-text-line-height: map.get($system, title-small-line-height), + toolbar-title-text-size: map.get($system, title-small-size), + toolbar-title-text-tracking: map.get($system, title-small-tracking), + toolbar-title-text-weight: map.get($system, title-small-weight), + ), + density: ( + toolbar-standard-height: map.get(( + 0: 64px, + -1: 60px, + -2: 56px, + -3: 52px, + ), $density-scale), + toolbar-mobile-height: map.get(( + 0: 56px, + -1: 52px, + -2: 48px, + -3: 44px, + ), $density-scale), + ), ); } // Generates the tokens used to theme the toolbar based on a palette. @function private-get-color-palette-color-tokens($background-color, $text-color) { @return ( - container-background-color: $background-color, - container-text-color: $text-color, - ); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + toolbar-container-background-color: $background-color, + toolbar-container-text-color: $text-color, ); } diff --git a/src/material/toolbar/_m3-toolbar.scss b/src/material/toolbar/_m3-toolbar.scss index 08ab80368650..90ba878639f3 100644 --- a/src/material/toolbar/_m3-toolbar.scss +++ b/src/material/toolbar/_m3-toolbar.scss @@ -1,37 +1,41 @@ @use 'sass:map'; @use 'sass:list'; -@use '../core/style/sass-utils'; @use '../core/tokens/m3-utils'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, toolbar); +@use '../core/tokens/m3'; /// Generates custom tokens for the mat-toolbar. /// @param {Map} $systems The MDC system tokens /// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values /// @param {Map} $token-slots Possible token slots /// @return {Map} A set of custom tokens for the mat-toolbar -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: sass-utils.merge-all( - m3-utils.generate-typography-tokens($systems, title-text, title-large), - ( - container-background-color: map.get($systems, md-sys-color, surface), - container-text-color: map.get($systems, md-sys-color, on-surface), - ) - ); +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); - @return m3-utils.namespace($prefix, $tokens, $token-slots); + @return ( + base: (), + color: ( + toolbar-container-background-color: map.get($system, surface), + toolbar-container-text-color: map.get($system, on-surface), + ), + typography: ( + toolbar-title-text-font: map.get($system, title-large-font), + toolbar-title-text-line-height: map.get($system, title-large-line-height), + toolbar-title-text-size: map.get($system, title-large-size), + toolbar-title-text-tracking: map.get($system, title-large-tracking), + toolbar-title-text-weight: map.get($system, title-large-weight), + ), + density: get-density-tokens(map.get($system, density-scale)), + ); } // Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $scale: theming.clamp-density(inspection.get-theme-density($theme), -3); +@function get-density-tokens($scale) { + $scale: theming.clamp-density($scale, -3); $index: ($scale * -1) + 1; @return ( - standard-height: list.nth((64px, 60px, 56px, 52px), $index), - mobile-height: list.nth((56px, 52px, 48px, 44px), $index), + toolbar-standard-height: list.nth((64px, 60px, 56px, 52px), $index), + toolbar-mobile-height: list.nth((56px, 52px, 48px, 44px), $index), ); } diff --git a/src/material/toolbar/_toolbar-theme.scss b/src/material/toolbar/_toolbar-theme.scss index cca9e3c9e26a..a9663c6ba958 100644 --- a/src/material/toolbar/_toolbar-theme.scss +++ b/src/material/toolbar/_toolbar-theme.scss @@ -1,89 +1,88 @@ @use 'sass:map'; -@use '../core/style/sass-utils'; @use '../core/theming/inspection'; -@use '../core/theming/theming'; -@use '../core/theming/validation'; @use '../core/tokens/token-utils'; @use '../core/typography/typography'; @use './m2-toolbar'; @use './m3-toolbar'; @mixin _palette-styles($theme, $palette-name) { - @include token-utils.create-token-values-mixed( - m2-toolbar.$prefix, - m2-toolbar.private-get-color-palette-color-tokens( - $background-color: inspection.get-theme-color($theme, $palette-name), - $text-color: inspection.get-theme-color($theme, $palette-name, default-contrast) - ) + $tokens: m2-toolbar.private-get-color-palette-color-tokens( + $background-color: inspection.get-theme-color($theme, $palette-name), + $text-color: inspection.get-theme-color($theme, $palette-name, default-contrast) ); + @include token-utils.values($tokens); } @mixin base($theme) { + $tokens: map.get(m2-toolbar.get-tokens($theme), base); + @if inspection.get-theme-version($theme) == 1 { + $tokens: map.get(m3-toolbar.get-tokens($theme), base); + } + + @include token-utils.values($tokens); } @mixin color($theme) { + $tokens: map.get(m2-toolbar.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-toolbar.$prefix, - m2-toolbar.get-color-tokens($theme) - ); - } + $tokens: map.get(m3-toolbar.get-tokens($theme), color); + } + @include token-utils.values($tokens); + + @if inspection.get-theme-version($theme) != 1 { .mat-toolbar { &.mat-primary { - @include _palette-styles($theme, primary); + $tokens: m2-toolbar.private-get-color-palette-color-tokens( + $background-color: inspection.get-theme-color($theme, primary), + $text-color: inspection.get-theme-color($theme, primary, default-contrast) + ); + @include token-utils.values($tokens); } &.mat-accent { - @include _palette-styles($theme, accent); + $tokens: m2-toolbar.private-get-color-palette-color-tokens( + $background-color: inspection.get-theme-color($theme, accent), + $text-color: inspection.get-theme-color($theme, accent, default-contrast) + ); + @include token-utils.values($tokens); } &.mat-warn { - @include _palette-styles($theme, warn); + $tokens: m2-toolbar.private-get-color-palette-color-tokens( + $background-color: inspection.get-theme-color($theme, warn), + $text-color: inspection.get-theme-color($theme, warn, default-contrast) + ); + @include token-utils.values($tokens); } } } } -@mixin typography($theme) { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - // TODO(mmalerba): Stop calling this and resolve resulting screen diffs. - $theme: inspection.private-get-typography-back-compat-theme($theme); - - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-toolbar.$prefix, - m2-toolbar.get-typography-tokens($theme) - ); + @mixin typography($theme) { + $tokens: map.get(m2-toolbar.get-tokens($theme), typography); + @if inspection.get-theme-version($theme) == 1 { + $tokens: map.get(m3-toolbar.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } -} -@mixin density($theme) { - @if inspection.get-theme-version($theme) == 1 { - @include token-utils.create-token-values( - m3-toolbar.$prefix, m3-toolbar.get-density-tokens($theme)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-toolbar.$prefix, - m2-toolbar.get-density-tokens($theme) - ); + @mixin density($theme) { + $tokens: map.get(m2-toolbar.get-tokens($theme), density); + @if inspection.get-theme-version($theme) == 1 { + $tokens: map.get(m3-toolbar.get-tokens($theme), density); } + + @include token-utils.values($tokens); } -} /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-toolbar.$prefix, - tokens: m2-toolbar.get-token-slots(), + namespace: toolbar, + tokens: token-utils.get-overrides(m3-toolbar.get-tokens(), toolbar) ), ); } @@ -91,39 +90,25 @@ /// Outputs the CSS variable values for the given tokens. /// @param {Map} $tokens The token values to emit. @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } @mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-toolbar') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - @if inspection.theme-has($theme, density) { - @include density($theme); - } - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - @include token-utils.create-token-values( - m2-toolbar.$prefix, - map.get($tokens, m2-toolbar.$prefix) - ); } } diff --git a/src/material/toolbar/toolbar.scss b/src/material/toolbar/toolbar.scss index ee13ec3ba952..b1adb154c406 100644 --- a/src/material/toolbar/toolbar.scss +++ b/src/material/toolbar/toolbar.scss @@ -1,31 +1,27 @@ @use '@angular/cdk'; -@use '../button/m2-button'; @use '../core/style/variables'; @use '../core/tokens/token-utils'; -@use './m2-toolbar'; +@use './m3-toolbar'; $row-padding: 16px !default; -$token-prefix: m2-toolbar.$prefix; -$token-slots: m2-toolbar.get-token-slots(); +$fallbacks: m3-toolbar.get-tokens(); // @deprecated @breaking-change 8.0.0 // TODO: Remove once internal g3 apps no longer depend on this variable. Tracked with: COMP-303. $height-mobile-portrait: 56px !default; .mat-toolbar { - @include token-utils.use-tokens($token-prefix, $token-slots) { - background: token-utils.slot(container-background-color); - color: token-utils.slot(container-text-color); - - &, h1, h2, h3, h4, h5, h6 { - font-family: token-utils.slot(title-text-font); - font-size: token-utils.slot(title-text-size); - line-height: token-utils.slot(title-text-line-height); - font-weight: token-utils.slot(title-text-weight); - letter-spacing: token-utils.slot(title-text-tracking); - margin: 0; - } + background: token-utils.slot(toolbar-container-background-color, $fallbacks); + color: token-utils.slot(toolbar-container-text-color, $fallbacks); + + &, h1, h2, h3, h4, h5, h6 { + font-family: token-utils.slot(toolbar-title-text-font, $fallbacks); + font-size: token-utils.slot(toolbar-title-text-size, $fallbacks); + line-height: token-utils.slot(toolbar-title-text-line-height, $fallbacks); + font-weight: token-utils.slot(toolbar-title-text-weight, $fallbacks); + letter-spacing: token-utils.slot(toolbar-title-text-tracking, $fallbacks); + margin: 0; } @include cdk.high-contrast { @@ -52,16 +48,9 @@ $height-mobile-portrait: 56px !default; } .mat-mdc-button-base.mat-mdc-button-base.mat-unthemed { - $color-token: null; - - @include token-utils.use-tokens($token-prefix, $token-slots) { - $color-token: token-utils.slot(container-text-color); - } - - @include token-utils.use-tokens(m2-button.$prefix, m2-button.get-token-slots()) { - #{token-utils.get-token-variable-name(text-label-text-color)}: #{$color-token}; - #{token-utils.get-token-variable-name(outlined-label-text-color)}: #{$color-token}; - } + $color-token: token-utils.slot(toolbar-container-text-color, $fallbacks); + --mat-button-text-label-text-color: #{$color-token}; + --mat-button-outlined-label-text-color: #{$color-token}; } } @@ -80,12 +69,10 @@ $height-mobile-portrait: 56px !default; // Disable text wrapping inside of the toolbar. Developers are still able to overwrite it. white-space: nowrap; - @include token-utils.use-tokens($token-prefix, $token-slots) { - height: token-utils.slot(standard-height); + height: token-utils.slot(toolbar-standard-height, $fallbacks); - @media (variables.$xsmall) { - height: token-utils.slot(mobile-height); - } + @media (variables.$xsmall) { + height: token-utils.slot(toolbar-mobile-height, $fallbacks); } } @@ -95,14 +82,12 @@ $height-mobile-portrait: 56px !default; flex-direction: column; width: 100%; - @include token-utils.use-tokens($token-prefix, $token-slots) { - min-height: token-utils.slot(standard-height); + min-height: token-utils.slot(toolbar-standard-height, $fallbacks); - // As per specs, toolbars should have a different height in mobile devices. This has been - // specified in the old guidelines and is still observable in the new specifications by - // looking at the spec images. See: https://material.io/design/components/app-bars-top.html#anatomy - @media (variables.$xsmall) { - min-height: token-utils.slot(mobile-height); - } + // As per specs, toolbars should have a different height in mobile devices. This has been + // specified in the old guidelines and is still observable in the new specifications by + // looking at the spec images. See: https://material.io/design/components/app-bars-top.html#anatomy + @media (variables.$xsmall) { + min-height: token-utils.slot(toolbar-mobile-height, $fallbacks); } } diff --git a/src/material/toolbar/toolbar.ts b/src/material/toolbar/toolbar.ts index 561217990174..d0fc68fd8fac 100644 --- a/src/material/toolbar/toolbar.ts +++ b/src/material/toolbar/toolbar.ts @@ -51,10 +51,10 @@ export class MatToolbar implements AfterViewInit { // TODO: should be typed as `ThemePalette` but internal apps pass in arbitrary strings. /** * Theme color of the toolbar. This API is supported in M2 themes only, it has - * no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/toolbar/styling. + * no effect in M3 themes. For color customization in M3, see https://material.angular.dev/components/toolbar/styling. * * For information on applying color variants in M3, see - * https://material.angular.io/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants + * https://material.angular.dev/guide/material-2-theming#optional-add-backwards-compatibility-styles-for-color-variants */ @Input() color?: string | null; diff --git a/src/material/tooltip/README.md b/src/material/tooltip/README.md index c82fc6795bae..a290691811e9 100644 --- a/src/material/tooltip/README.md +++ b/src/material/tooltip/README.md @@ -1 +1 @@ -Please see the official documentation at https://material.angular.io/components/component/tooltip +Please see the official documentation at https://material.angular.dev/components/component/tooltip diff --git a/src/material/tooltip/_m2-tooltip.scss b/src/material/tooltip/_m2-tooltip.scss index d865185e432f..03f4822efe36 100644 --- a/src/material/tooltip/_m2-tooltip.scss +++ b/src/material/tooltip/_m2-tooltip.scss @@ -1,54 +1,24 @@ -@use '../core/theming/inspection'; -@use '../core/style/sass-utils'; @use '../core/tokens/m2-utils'; +@use 'sass:map'; -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, tooltip); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -// -// Tokens that are available in MDC, but not used in Angular Material should be mapped to `null`. -// `null` indicates that we are intentionally choosing not to emit a slot or value for the token in -// our CSS. -@function get-unthemable-tokens() { @return ( - container-shape: 4px, - supporting-text-line-height: 16px, - ); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - - @return ( - container-color: inspection.get-theme-color($theme, background, tooltip), - supporting-text-color: #fff, - ); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return ( - supporting-text-font: inspection.get-theme-typography($theme, caption, font-family), - supporting-text-size: inspection.get-theme-typography($theme, caption, font-size), - supporting-text-weight: inspection.get-theme-typography($theme, caption, font-weight), - supporting-text-tracking: inspection.get-theme-typography($theme, caption, letter-spacing), - ); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - @return (); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + base: ( + tooltip-container-shape: 4px, + tooltip-supporting-text-line-height: 16px, + ), + color: ( + tooltip-container-color: map.get($system, inverse-surface), + tooltip-supporting-text-color: map.get($system, inverse-on-surface), + ), + typography: ( + tooltip-supporting-text-font: map.get($system, body-small-font), + tooltip-supporting-text-size: map.get($system, body-small-size), + tooltip-supporting-text-weight: map.get($system, body-small-weight), + tooltip-supporting-text-tracking: map.get($system, body-small-tracking), + ), + density: (), ); } diff --git a/src/material/tooltip/_m3-tooltip.scss b/src/material/tooltip/_m3-tooltip.scss index bc4d802c0a6e..ead229a8620c 100644 --- a/src/material/tooltip/_m3-tooltip.scss +++ b/src/material/tooltip/_m3-tooltip.scss @@ -1,25 +1,25 @@ @use '../core/tokens/m3-utils'; @use 'sass:map'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, tooltip); +@use '../core/tokens/m3'; /// Generates the tokens for MDC plain-tooltip -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of tokens for the MDC plain-tooltip -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: ( - container-color: map.get($systems, md-sys-color, inverse-surface), - container-shape: map.get($systems, md-sys-shape, corner-extra-small), - supporting-text-color: map.get($systems, md-sys-color, inverse-on-surface), - supporting-text-font: map.get($systems, md-sys-typescale, body-small-font), - supporting-text-line-height: map.get($systems, md-sys-typescale, body-small-line-height), - supporting-text-size: map.get($systems, md-sys-typescale, body-small-size), - supporting-text-tracking: map.get($systems, md-sys-typescale, body-small-tracking), - supporting-text-weight: map.get($systems, md-sys-typescale, body-small-weight) - ); +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); - @return m3-utils.namespace($prefix, $tokens, $token-slots); + @return ( + base: (), + color: ( + tooltip-container-color: map.get($system, inverse-surface), + tooltip-container-shape: map.get($system, corner-extra-small), + tooltip-supporting-text-color: map.get($system, inverse-on-surface), + ), + typography: ( + tooltip-supporting-text-font: map.get($system, body-small-font), + tooltip-supporting-text-line-height: map.get($system, body-small-line-height), + tooltip-supporting-text-size: map.get($system, body-small-size), + tooltip-supporting-text-tracking: map.get($system, body-small-tracking), + tooltip-supporting-text-weight: map.get($system, body-small-weight) + ), + density: (), + ); } diff --git a/src/material/tooltip/_tooltip-theme.scss b/src/material/tooltip/_tooltip-theme.scss index df629ea41c40..513c292be536 100644 --- a/src/material/tooltip/_tooltip-theme.scss +++ b/src/material/tooltip/_tooltip-theme.scss @@ -1,103 +1,76 @@ -@use '../core/style/sass-utils'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; @use '../core/tokens/token-utils'; @use '../core/typography/typography'; @use './m2-tooltip'; +@use './m3-tooltip'; +@use 'sass:map'; @mixin base($theme) { + $tokens: map.get(m2-tooltip.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { - // Add default values for tokens not related to color, typography, or density. - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-tooltip.$prefix, - m2-tooltip.get-unthemable-tokens() - ); - } + $tokens: map.get(m3-tooltip.get-tokens($theme), base); } + + @include token-utils.values($tokens); } @mixin color($theme) { + $tokens: map.get(m2-tooltip.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-tooltip.$prefix, - m2-tooltip.get-color-tokens($theme) - ); - } + $tokens: map.get(m3-tooltip.get-tokens($theme), color); } + + @include token-utils.values($tokens); } @mixin typography($theme) { + $tokens: map.get(m2-tooltip.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-tooltip.$prefix, - m2-tooltip.get-typography-tokens($theme) - ); - } + $tokens: map.get(m3-tooltip.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } @mixin density($theme) { + $tokens: map.get(m2-tooltip.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - // There are no M3 density tokens for this component - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-tooltip.$prefix, - m2-tooltip.get-density-tokens($theme) - ); - } + $tokens: map.get(m3-tooltip.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-tooltip.$prefix, - tokens: m2-tooltip.get-token-slots(), + namespace: tooltip, + tokens: token-utils.get-overrides(m3-tooltip.get-tokens(), tooltip) ), ); } @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } @mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-tooltip') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if $tokens != () { - $tokens: token-utils.get-tokens-for($tokens, m2-tooltip.$prefix); - @include token-utils.create-token-values(m2-tooltip.$prefix, $tokens); } } diff --git a/src/material/tooltip/tooltip.scss b/src/material/tooltip/tooltip.scss index 5da06030c133..6b6f8bcdd9d6 100644 --- a/src/material/tooltip/tooltip.scss +++ b/src/material/tooltip/tooltip.scss @@ -1,6 +1,8 @@ -@use './m2-tooltip'; +@use './m3-tooltip'; @use '../core/tokens/token-utils'; +$fallbacks: m3-tooltip.get-tokens(); + .mat-mdc-tooltip { // We don't use MDC's positioning so this has to be relative. position: relative; @@ -61,16 +63,14 @@ // MDC and removing them is likely to cause screenshot differences. will-change: transform, opacity; - @include token-utils.use-tokens(m2-tooltip.$prefix, m2-tooltip.get-token-slots()) { - background-color: token-utils.slot(container-color); - color: token-utils.slot(supporting-text-color); - border-radius: token-utils.slot(container-shape); - font-family: token-utils.slot(supporting-text-font); - font-size: token-utils.slot(supporting-text-size); - font-weight: token-utils.slot(supporting-text-weight); - line-height: token-utils.slot(supporting-text-line-height); - letter-spacing: token-utils.slot(supporting-text-tracking); - } + background-color: token-utils.slot(tooltip-container-color, $fallbacks); + color: token-utils.slot(tooltip-supporting-text-color, $fallbacks); + border-radius: token-utils.slot(tooltip-container-shape, $fallbacks); + font-family: token-utils.slot(tooltip-supporting-text-font, $fallbacks); + font-size: token-utils.slot(tooltip-supporting-text-size, $fallbacks); + font-weight: token-utils.slot(tooltip-supporting-text-weight, $fallbacks); + line-height: token-utils.slot(tooltip-supporting-text-line-height, $fallbacks); + letter-spacing: token-utils.slot(tooltip-supporting-text-tracking, $fallbacks); // Renders an outline in high contrast mode. &::before { diff --git a/src/material/tooltip/tooltip.spec.ts b/src/material/tooltip/tooltip.spec.ts index 6f11bfd899c8..a83129363c43 100644 --- a/src/material/tooltip/tooltip.spec.ts +++ b/src/material/tooltip/tooltip.spec.ts @@ -1,5 +1,5 @@ import {FocusMonitor} from '@angular/cdk/a11y'; -import {Direction, Directionality} from '@angular/cdk/bidi'; +import {Direction} from '@angular/cdk/bidi'; import {ESCAPE} from '@angular/cdk/keycodes'; import {CdkScrollable, OverlayContainer, OverlayModule} from '@angular/cdk/overlay'; import {Platform} from '@angular/cdk/platform'; @@ -12,24 +12,26 @@ import { dispatchKeyboardEvent, dispatchMouseEvent, patchElementFocus, + provideFakeDirectionality, } from '@angular/cdk/testing/private'; import { ChangeDetectionStrategy, Component, DebugElement, ElementRef, + signal, ViewChild, + WritableSignal, } from '@angular/core'; import { ComponentFixture, - TestBed, fakeAsync, flush, + TestBed, tick, waitForAsync, } from '@angular/core/testing'; import {By} from '@angular/platform-browser'; -import {Subject} from 'rxjs'; import { MAT_TOOLTIP_DEFAULT_OPTIONS, MatTooltip, @@ -43,31 +45,15 @@ const initialTooltipMessage = 'initial tooltip message'; describe('MatTooltip', () => { let overlayContainerElement: HTMLElement; - let dir: {value: Direction; change: Subject}; + let dir: WritableSignal; let platform: Platform; let focusMonitor: FocusMonitor; beforeEach(waitForAsync(() => { + dir = signal('ltr'); + TestBed.configureTestingModule({ - imports: [ - MatTooltipModule, - OverlayModule, - BasicTooltipDemo, - ScrollableTooltipDemo, - OnPushTooltipDemo, - DynamicTooltipsDemo, - TooltipOnTextFields, - TooltipOnDraggableElement, - DataBoundAriaLabelTooltip, - ], - providers: [ - { - provide: Directionality, - useFactory: () => { - return (dir = {value: 'ltr', change: new Subject()}); - }, - }, - ], + providers: [provideFakeDirectionality(dir)], }); overlayContainerElement = TestBed.inject(OverlayContainer).getContainerElement(); @@ -164,7 +150,6 @@ describe('MatTooltip', () => { it('should be able to override the default show and hide delays', fakeAsync(() => { TestBed.resetTestingModule().configureTestingModule({ - imports: [MatTooltipModule, OverlayModule, BasicTooltipDemo], providers: [ { provide: MAT_TOOLTIP_DEFAULT_OPTIONS, @@ -199,8 +184,6 @@ describe('MatTooltip', () => { it('should be able to override the default position', fakeAsync(() => { TestBed.resetTestingModule().configureTestingModule({ - imports: [MatTooltipModule, OverlayModule], - declarations: [TooltipDemoWithoutPositionBinding], providers: [ { provide: MAT_TOOLTIP_DEFAULT_OPTIONS, @@ -226,8 +209,6 @@ describe('MatTooltip', () => { it('should be able to define a default (global) tooltip class', fakeAsync(() => { TestBed.resetTestingModule().configureTestingModule({ - declarations: [TooltipDemoWithoutTooltipClassBinding], - imports: [MatTooltipModule, OverlayModule], providers: [ { provide: MAT_TOOLTIP_DEFAULT_OPTIONS, @@ -253,8 +234,6 @@ describe('MatTooltip', () => { it('should be able to provide tooltip class over the custom default one', fakeAsync(() => { TestBed.resetTestingModule().configureTestingModule({ - declarations: [TooltipDemoWithTooltipClassBinding], - imports: [MatTooltipModule, OverlayModule], providers: [ { provide: MAT_TOOLTIP_DEFAULT_OPTIONS, @@ -285,11 +264,7 @@ describe('MatTooltip', () => { return; } - TestBed.resetTestingModule().configureTestingModule({ - imports: [MatTooltipModule, OverlayModule], - declarations: [WideTooltipDemo], - }); - + TestBed.resetTestingModule(); const wideFixture = TestBed.createComponent(WideTooltipDemo); wideFixture.detectChanges(); tooltipDirective = wideFixture.debugElement @@ -318,8 +293,6 @@ describe('MatTooltip', () => { } TestBed.resetTestingModule().configureTestingModule({ - imports: [MatTooltipModule, OverlayModule], - declarations: [WideTooltipDemo], providers: [ { provide: MAT_TOOLTIP_DEFAULT_OPTIONS, @@ -350,8 +323,6 @@ describe('MatTooltip', () => { it('should be able to disable tooltip interactivity', fakeAsync(() => { TestBed.resetTestingModule().configureTestingModule({ - imports: [MatTooltipModule, OverlayModule], - declarations: [TooltipDemoWithoutPositionBinding], providers: [ { provide: MAT_TOOLTIP_DEFAULT_OPTIONS, @@ -512,7 +483,8 @@ describe('MatTooltip', () => { assertTooltipInstance(tooltipDirective, true); const spy = spyOn(tooltipDirective as any, '_updatePosition').and.callThrough(); - dir.change.next('rtl'); + dir.set('rtl'); + fixture.detectChanges(); assertTooltipInstance(tooltipDirective, true); expect(spy).toHaveBeenCalled(); @@ -683,7 +655,7 @@ describe('MatTooltip', () => { expect(tooltipDirective._getOrigin().main).toEqual(rightOrigin); // Test expectations in RTL - dir.value = 'rtl'; + dir.set('rtl'); tooltipDirective.position = 'before'; expect(tooltipDirective._getOrigin().main).toEqual(leftOrigin); tooltipDirective.position = 'after'; @@ -703,7 +675,7 @@ describe('MatTooltip', () => { expect(tooltipDirective._getOverlayPosition().main).toEqual(rightOverlayPosition); // Test expectations in RTL - dir.value = 'rtl'; + dir.set('rtl'); tooltipDirective.position = 'before'; expect(tooltipDirective._getOverlayPosition().main).toEqual(leftOverlayPosition); tooltipDirective.position = 'after'; @@ -720,7 +692,7 @@ describe('MatTooltip', () => { }); it('should pass the layout direction to the tooltip', fakeAsync(() => { - dir.value = 'rtl'; + dir.set('rtl'); tooltipDirective.show(); tick(0); @@ -737,7 +709,7 @@ describe('MatTooltip', () => { })); it('should keep the overlay direction in sync with the trigger direction', fakeAsync(() => { - dir.value = 'rtl'; + dir.set('rtl'); tooltipDirective.show(); tick(0); fixture.detectChanges(); @@ -755,7 +727,7 @@ describe('MatTooltip', () => { fixture.detectChanges(); finishCurrentTooltipAnimation(overlayContainerElement, false); - dir.value = 'ltr'; + dir.set('ltr'); tooltipDirective.show(); tick(0); fixture.detectChanges(); @@ -989,7 +961,7 @@ describe('MatTooltip', () => { fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - dir.value = 'ltr'; + dir.set('ltr'); tooltipDirective.position = 'after'; fixture.changeDetectorRef.markForCheck(); tooltipDirective.show(0); @@ -1007,7 +979,7 @@ describe('MatTooltip', () => { tooltipDirective.hide(0); fixture.detectChanges(); tick(0); - dir.value = 'rtl'; + dir.set('rtl'); tooltipDirective.show(0); fixture.detectChanges(); tick(0); @@ -1585,7 +1557,6 @@ describe('MatTooltip', () => { }); @Component({ - selector: 'app', template: ` @if (showButton) { @@ -1711,9 +1679,8 @@ class TooltipOnDraggableElement { } @Component({ - selector: 'app', template: ``, - standalone: false, + imports: [MatTooltip], }) class TooltipDemoWithoutPositionBinding { message: any = initialTooltipMessage; @@ -1722,9 +1689,8 @@ class TooltipDemoWithoutPositionBinding { } @Component({ - selector: 'app', template: ``, - standalone: false, + imports: [MatTooltip], }) class TooltipDemoWithoutTooltipClassBinding { message = initialTooltipMessage; @@ -1733,11 +1699,10 @@ class TooltipDemoWithoutTooltipClassBinding { } @Component({ - selector: 'app', template: ` `, - standalone: false, + imports: [MatTooltip], }) class TooltipDemoWithTooltipClassBinding { message: any = initialTooltipMessage; @@ -1746,10 +1711,9 @@ class TooltipDemoWithTooltipClassBinding { } @Component({ - selector: 'app', styles: `button { width: 500px; height: 500px; }`, template: ``, - standalone: false, + imports: [MatTooltip], }) class WideTooltipDemo { message = 'Test'; diff --git a/src/material/tooltip/tooltip.zone.spec.ts b/src/material/tooltip/tooltip.zone.spec.ts index 4d8c8cf1de4d..541ab76edc9b 100644 --- a/src/material/tooltip/tooltip.zone.spec.ts +++ b/src/material/tooltip/tooltip.zone.spec.ts @@ -1,6 +1,5 @@ -import {Directionality} from '@angular/cdk/bidi'; import {CdkScrollable, OverlayModule} from '@angular/cdk/overlay'; -import {dispatchFakeEvent} from '@angular/cdk/testing/private'; +import {dispatchFakeEvent, provideFakeDirectionality} from '@angular/cdk/testing/private'; import { Component, DebugElement, @@ -10,7 +9,6 @@ import { } from '@angular/core'; import {ComponentFixture, TestBed, fakeAsync, tick, waitForAsync} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; -import {Subject} from 'rxjs'; import {MatTooltipModule} from './module'; import {MatTooltip} from './tooltip'; @@ -20,13 +18,7 @@ describe('MatTooltip Zone.js integration', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [MatTooltipModule, OverlayModule, ScrollableTooltipDemo], - providers: [ - provideZoneChangeDetection(), - { - provide: Directionality, - useFactory: () => ({value: 'ltr', change: new Subject()}), - }, - ], + providers: [provideZoneChangeDetection(), provideFakeDirectionality('rtl')], }); })); diff --git a/src/material/tree/_m2-tree.scss b/src/material/tree/_m2-tree.scss index 0e0ee8f6dbc5..6d657165a5ad 100644 --- a/src/material/tree/_m2-tree.scss +++ b/src/material/tree/_m2-tree.scss @@ -1,62 +1,30 @@ @use 'sass:map'; -@use '../core/tokens/m2-utils'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; -@use '../core/style/sass-utils'; - -// The prefix used to generate the fully qualified name for tokens in this file. -$prefix: (mat, tree); - -// Tokens that can't be configured through Angular Material's current theming API, -// but may be in a future version of the theming API. -@function get-unthemable-tokens() { - @return (); -} - -// Tokens that can be configured through Angular Material's color theming API. -@function get-color-tokens($theme) { - $foreground-base: inspection.get-theme-color($theme, foreground, base); - - @return ( - container-background-color: inspection.get-theme-color($theme, background, card), - node-text-color: inspection.get-theme-color($theme, foreground, text), - ); -} - -// Tokens that can be configured through Angular Material's typography theming API. -@function get-typography-tokens($theme) { - @return ( - node-text-font: inspection.get-theme-typography($theme, body-2, font-family), - node-text-size: inspection.get-theme-typography($theme, body-2, font-size), - node-text-weight: inspection.get-theme-typography($theme, body-2, font-weight), +@use '../core/tokens/m2-utils'; - // TODO(crisbeto): provide tokens for line height and letter spacing to match other components. - ); -} - -// Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $density-scale: theming.clamp-density(inspection.get-theme-density($theme), -4); - $min-height-scale: ( - 0: 48px, - -1: 44px, - -2: 40px, - -3: 36px, - -4: 28px, - ); +@function get-tokens($theme) { + $system: m2-utils.get-system($theme); + $density-scale: theming.clamp-density(map.get($system, density-scale), -4); @return ( - node-min-height: map.get($min-height-scale, $density-scale) - ); -} - -// Combines the tokens generated by the above functions into a single map with placeholder values. -// This is used to create token slots. -@function get-token-slots() { - @return sass-utils.deep-merge-all( - get-unthemable-tokens(), - get-color-tokens(m2-utils.$placeholder-color-config), - get-typography-tokens(m2-utils.$placeholder-typography-config), - get-density-tokens(m2-utils.$placeholder-density-config) + base: (), + color: ( + tree-container-background-color: map.get($system, surface), + tree-node-text-color: map.get($system, on-surface), + ), + typography: ( + // TODO(crisbeto): provide tokens for line height and letter spacing + tree-node-text-font: map.get($system, body-medium-font), + tree-node-text-size: map.get($system, body-medium-size), + tree-node-text-weight: map.get($system, body-medium-weight),), + density: ( + tree-node-min-height: map.get(( + 0: 48px, + -1: 44px, + -2: 40px, + -3: 36px, + -4: 28px, + ), $density-scale) + ), ); } diff --git a/src/material/tree/_m3-tree.scss b/src/material/tree/_m3-tree.scss index 7fb28ab600c7..9e921b069f21 100644 --- a/src/material/tree/_m3-tree.scss +++ b/src/material/tree/_m3-tree.scss @@ -2,34 +2,36 @@ @use 'sass:list'; @use '../core/tokens/m3-utils'; @use '../core/theming/theming'; -@use '../core/theming/inspection'; +@use '../core/tokens/m3'; // The prefix used to generate the fully qualified name for tokens in this file. $prefix: (mat, tree); /// Generates custom tokens for the mat-tree. -/// @param {Map} $systems The MDC system tokens -/// @param {Boolean} $exclude-hardcoded Whether to exclude hardcoded token values -/// @param {Map} $token-slots Possible token slots -/// @return {Map} A set of custom tokens for the mat-tree -@function get-tokens($systems, $exclude-hardcoded, $token-slots) { - $tokens: ( - container-background-color: map.get($systems, md-sys-color, surface), - node-text-color: map.get($systems, md-sys-color, on-surface), - node-text-font: map.get($systems, md-sys-typescale, body-large-font), - node-text-size: map.get($systems, md-sys-typescale, body-large-size), - node-text-weight: map.get($systems, md-sys-typescale, body-large-weight), - ); +@function get-tokens($theme: m3.$sys-theme) { + $system: m3-utils.get-system($theme); - @return m3-utils.namespace($prefix, $tokens, $token-slots); + @return ( + base: (), + color: ( + tree-container-background-color: map.get($system, surface), + tree-node-text-color: map.get($system, on-surface), + ), + typography: ( + tree-node-text-font: map.get($system, body-large-font), + tree-node-text-size: map.get($system, body-large-size), + tree-node-text-weight: map.get($system, body-large-weight), + ), + density: get-density-tokens(map.get($system, density-scale)), + ); } // Tokens that can be configured through Angular Material's density theming API. -@function get-density-tokens($theme) { - $scale: theming.clamp-density(inspection.get-theme-density($theme), -4); +@function get-density-tokens($scale) { + $scale: theming.clamp-density($scale, -4); $index: ($scale * -1) + 1; @return ( - node-min-height: list.nth((48px, 44px, 40px, 36px, 28px), $index), + tree-node-min-height: list.nth((48px, 44px, 40px, 36px, 28px), $index), ); } diff --git a/src/material/tree/_tree-theme.scss b/src/material/tree/_tree-theme.scss index 70671db570c1..e17db37b896c 100644 --- a/src/material/tree/_tree-theme.scss +++ b/src/material/tree/_tree-theme.scss @@ -1,108 +1,76 @@ @use 'sass:map'; -@use '../core/theming/theming'; @use '../core/theming/inspection'; -@use '../core/theming/validation'; @use '../core/typography/typography'; -@use '../core/style/sass-utils'; @use '../core/tokens/token-utils'; @use './m2-tree'; @use './m3-tree'; @mixin base($theme) { + $tokens: map.get(m2-tree.get-tokens($theme), base); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, base)); - } @else { + $tokens: map.get(m3-tree.get-tokens($theme), base); } + + @include token-utils.values($tokens); } @mixin color($theme) { + $tokens: map.get(m2-tree.get-tokens($theme), color); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, color)); - } @else { - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-tree.$prefix, - m2-tree.get-color-tokens($theme) - ); - } + $tokens: map.get(m3-tree.get-tokens($theme), color); } + + @include token-utils.values($tokens); } @mixin typography($theme) { + $tokens: map.get(m2-tree.get-tokens($theme), typography); @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); - } @else { - // TODO(mmalerba): Stop calling this and resolve resulting screen diffs. - $theme: inspection.private-get-typography-back-compat-theme($theme); - - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-tree.$prefix, - m2-tree.get-typography-tokens($theme) - ); - } + $tokens: map.get(m3-tree.get-tokens($theme), typography); } + + @include token-utils.values($tokens); } @mixin density($theme) { + $tokens: map.get(m2-tree.get-tokens($theme), density); @if inspection.get-theme-version($theme) == 1 { - @include token-utils.create-token-values(m3-tree.$prefix, m3-tree.get-density-tokens($theme)); - } @else { - $density-scale: inspection.get-theme-density($theme); - - @include sass-utils.current-selector-or-root() { - @include token-utils.create-token-values-mixed( - m2-tree.$prefix, - m2-tree.get-density-tokens($theme) - ); - } + $tokens: map.get(m3-tree.get-tokens($theme), density); } + + @include token-utils.values($tokens); } /// Defines the tokens that will be available in the `overrides` mixin and for docs extraction. @function _define-overrides() { @return ( ( - namespace: m2-tree.$prefix, - tokens: m2-tree.get-token-slots(), + namespace: tree, + tokens: token-utils.get-overrides(m3-tree.get-tokens(), tree) ), ); } @mixin overrides($tokens: ()) { - @include token-utils.batch-create-token-values($tokens, _define-overrides()...); + @include token-utils.batch-create-token-values($tokens, _define-overrides()); } @mixin theme($theme) { - @include theming.private-check-duplicate-theme-styles($theme, 'mat-tree') { - @if inspection.get-theme-version($theme) == 1 { - @include _theme-from-tokens(inspection.get-theme-tokens($theme)); - @if inspection.theme-has($theme, density) { - @include density($theme); - } - } @else { - @include base($theme); - @if inspection.theme-has($theme, color) { - @include color($theme); - } - @if inspection.theme-has($theme, density) { - @include density($theme); - } - @if inspection.theme-has($theme, typography) { - @include typography($theme); - } + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); } - } -} - -@mixin _theme-from-tokens($tokens) { - @include validation.selector-defined( - 'Calls to Angular Material theme mixins with an M3 theme must be wrapped in a selector' - ); - @if ($tokens != ()) { - @include token-utils.create-token-values( - m2-tree.$prefix, - map.get($tokens, m2-tree.$prefix) - ); } } diff --git a/src/material/tree/tree-using-legacy-key-manager.spec.ts b/src/material/tree/tree-using-legacy-key-manager.spec.ts index 674cccd852a1..1d41b33141cf 100644 --- a/src/material/tree/tree-using-legacy-key-manager.spec.ts +++ b/src/material/tree/tree-using-legacy-key-manager.spec.ts @@ -10,12 +10,7 @@ describe('MatTree when provided LegacyTreeKeyManager', () => { let fixture: ComponentFixture; beforeEach(() => { - TestBed.configureTestingModule({ - imports: [MatTreeModule], - declarations: [SimpleMatTreeApp], - providers: [NOOP_TREE_KEY_MANAGER_FACTORY_PROVIDER], - }); - + TestBed.configureTestingModule({providers: [NOOP_TREE_KEY_MANAGER_FACTORY_PROVIDER]}); fixture = TestBed.createComponent(SimpleMatTreeApp); fixture.detectChanges(); }); @@ -98,7 +93,7 @@ class MinimalTestData { `, - standalone: false, + imports: [MatTreeModule], }) class SimpleMatTreeApp { isExpandable = (node: MinimalTestData) => node.children.length > 0; diff --git a/src/material/tree/tree-using-tree-control.spec.ts b/src/material/tree/tree-using-tree-control.spec.ts index 2bb9875a4b73..cdff071e2daf 100644 --- a/src/material/tree/tree-using-tree-control.spec.ts +++ b/src/material/tree/tree-using-tree-control.spec.ts @@ -6,7 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ import {FlatTreeControl, NestedTreeControl, TreeControl} from '@angular/cdk/tree'; -import {Component, ViewChild, Type} from '@angular/core'; +import {Component, ViewChild} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {BehaviorSubject, Observable} from 'rxjs'; import {map} from 'rxjs/operators'; @@ -21,30 +22,19 @@ import { describe('MatTree', () => { /** Represents an indent for expectNestedTreeToMatch */ const _ = {}; - let treeElement: HTMLElement; let underlyingDataSource: FakeDataSource; - function configureMatTreeTestingModule(declarations: Type[]) { - TestBed.configureTestingModule({ - imports: [MatTreeModule], - declarations: declarations, - }); - } - describe('flat tree', () => { describe('should initialize', () => { let fixture: ComponentFixture; let component: SimpleMatTreeApp; beforeEach(() => { - configureMatTreeTestingModule([SimpleMatTreeApp]); fixture = TestBed.createComponent(SimpleMatTreeApp); - component = fixture.componentInstance; underlyingDataSource = component.underlyingDataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -120,13 +110,10 @@ describe('MatTree', () => { let component: MatTreeAppWithToggle; beforeEach(() => { - configureMatTreeTestingModule([MatTreeAppWithToggle]); fixture = TestBed.createComponent(MatTreeAppWithToggle); - component = fixture.componentInstance; underlyingDataSource = component.underlyingDataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -252,13 +239,10 @@ describe('MatTree', () => { let component: WhenNodeMatTreeApp; beforeEach(() => { - configureMatTreeTestingModule([WhenNodeMatTreeApp]); fixture = TestBed.createComponent(WhenNodeMatTreeApp); - component = fixture.componentInstance; underlyingDataSource = component.underlyingDataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -280,10 +264,8 @@ describe('MatTree', () => { let fixture: ComponentFixture; beforeEach(() => { - configureMatTreeTestingModule([MatTreeWithNullOrUndefinedChild]); fixture = TestBed.createComponent(MatTreeWithNullOrUndefinedChild); treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -301,10 +283,8 @@ describe('MatTree', () => { let fixture: ComponentFixture; beforeEach(() => { - configureMatTreeTestingModule([MatNestedTreeWithNullOrUndefinedChild]); fixture = TestBed.createComponent(MatNestedTreeWithNullOrUndefinedChild); treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -322,13 +302,10 @@ describe('MatTree', () => { let component: NestedMatTreeApp; beforeEach(() => { - configureMatTreeTestingModule([NestedMatTreeApp]); fixture = TestBed.createComponent(NestedMatTreeApp); - component = fixture.componentInstance; underlyingDataSource = component.underlyingDataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -428,13 +405,10 @@ describe('MatTree', () => { let component: WhenNodeNestedMatTreeApp; beforeEach(() => { - configureMatTreeTestingModule([WhenNodeNestedMatTreeApp]); fixture = TestBed.createComponent(WhenNodeNestedMatTreeApp); - component = fixture.componentInstance; underlyingDataSource = component.underlyingDataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -454,13 +428,10 @@ describe('MatTree', () => { let component: NestedMatTreeAppWithToggle; beforeEach(() => { - configureMatTreeTestingModule([NestedMatTreeAppWithToggle]); fixture = TestBed.createComponent(NestedMatTreeAppWithToggle); - component = fixture.componentInstance; underlyingDataSource = component.underlyingDataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -579,10 +550,8 @@ describe('MatTree', () => { let tree: MatTree; beforeEach(() => { - configureMatTreeTestingModule([NestedMatTreeApp]); fixture = TestBed.createComponent(NestedMatTreeApp); fixture.detectChanges(); - component = fixture.componentInstance; underlyingDataSource = component.underlyingDataSource as FakeDataSource; const data = underlyingDataSource.data; @@ -590,7 +559,6 @@ describe('MatTree', () => { underlyingDataSource.addChild(child, false); underlyingDataSource.addChild(child, false); fixture.detectChanges(); - tree = component.tree; treeElement = fixture.nativeElement.querySelector('mat-tree'); nodes = getNodes(treeElement); @@ -884,7 +852,7 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { `, - standalone: false, + imports: [MatTreeModule], }) class SimpleMatTreeApp { getLevel = (node: TestData) => node.level; @@ -962,7 +930,7 @@ const TREE_DATA: FoodNode[] = [ `, - standalone: false, + imports: [MatTreeModule], }) class MatTreeWithNullOrUndefinedChild { private _transformer = (node: FoodNode, level: number) => { @@ -999,7 +967,7 @@ class MatTreeWithNullOrUndefinedChild { `, - standalone: false, + imports: [MatTreeModule], }) class MatNestedTreeWithNullOrUndefinedChild { treeControl: NestedTreeControl; @@ -1025,7 +993,7 @@ class MatNestedTreeWithNullOrUndefinedChild { `, - standalone: false, + imports: [MatTreeModule, AsyncPipe], }) class NestedMatTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -1056,13 +1024,13 @@ class NestedMatTreeApp { >>> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} -
    - -
    + @if (treeControl.isExpanded(node)) { + + }
    `, - standalone: false, + imports: [MatTreeModule], }) class WhenNodeNestedMatTreeApp { isSpecial = (_: number, node: TestData) => node.isSpecial; @@ -1095,7 +1063,7 @@ class WhenNodeNestedMatTreeApp { `, - standalone: false, + imports: [MatTreeModule], }) class MatTreeAppWithToggle { toggleRecursively: boolean = true; @@ -1137,13 +1105,14 @@ class MatTreeAppWithToggle { matTreeNodeToggle [matTreeNodeToggleRecursive]="toggleRecursively"> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} -
    + + @if (treeControl.isExpanded(node)) { -
    + } `, - standalone: false, + imports: [MatTreeModule, AsyncPipe], }) class NestedMatTreeAppWithToggle { toggleRecursively: boolean = true; @@ -1180,7 +1149,7 @@ class NestedMatTreeAppWithToggle { `, - standalone: false, + imports: [MatTreeModule], }) class WhenNodeMatTreeApp { isSpecial = (_: number, node: TestData) => node.isSpecial; diff --git a/src/material/tree/tree.md b/src/material/tree/tree.md index a9eba1238d0c..e2411fa4cda0 100644 --- a/src/material/tree/tree.md +++ b/src/material/tree/tree.md @@ -55,11 +55,11 @@ In order to use the tree, you must define a tree node template. There are two ty template defines the look of the tree node, expansion/collapsing control and the structure for nested children nodes. -A node definition is specified via any element with `matNodeDef`. This directive exports the node +A node definition is specified via any element with `matTreeNodeDef`. This directive exports the node data to be used in any bindings in the node template. ```html - + {{node.key}}: {{node.value}} ``` @@ -80,7 +80,7 @@ When using nested tree nodes, the node template must contain a `matTreeNodeOutle where the children of the node will be rendered. ```html - + {{node.value}} @@ -96,7 +96,7 @@ a tree node recursively by setting `[matTreeNodeToggleRecursive]` to true. activation. For icon buttons, ensure that `aria-label` is provided. ```html - + @@ -119,7 +119,7 @@ The `matTreeNodePadding` can be placed in a flat tree's node template to display information of a flat tree node. ```html - + {{node.value}} ``` @@ -134,10 +134,10 @@ The tree may include multiple node templates, where a template is chosen for a particular data node via the `when` predicate of the template. ```html - + {{node.value}} - + [ A special node {{node.value}} ] ``` @@ -209,11 +209,11 @@ interaction. ```html ``` In this example, `$event` contains the node's data and is equivalent to the implicit data passed in -the `matNodeDef` context. +the `matTreeNodeDef` context. diff --git a/src/material/tree/tree.scss b/src/material/tree/tree.scss index c6f11a992633..1b90dff8f231 100644 --- a/src/material/tree/tree.scss +++ b/src/material/tree/tree.scss @@ -1,22 +1,19 @@ @use '../core/tokens/token-utils'; -@use './m2-tree'; +@use './m3-tree'; + +$fallbacks: m3-tree.get-tokens(); .mat-tree { display: block; - - @include token-utils.use-tokens(m2-tree.$prefix, m2-tree.get-token-slots()) { - background-color: token-utils.slot(container-background-color); - } + background-color: token-utils.slot(tree-container-background-color, $fallbacks); } .mat-tree-node, .mat-nested-tree-node { - @include token-utils.use-tokens(m2-tree.$prefix, m2-tree.get-token-slots()) { - color: token-utils.slot(node-text-color); - font-family: token-utils.slot(node-text-font); - font-size: token-utils.slot(node-text-size); - font-weight: token-utils.slot(node-text-weight); - } + color: token-utils.slot(tree-node-text-color, $fallbacks); + font-family: token-utils.slot(tree-node-text-font, $fallbacks); + font-size: token-utils.slot(tree-node-text-size, $fallbacks); + font-weight: token-utils.slot(tree-node-text-weight, $fallbacks); } .mat-tree-node { @@ -25,11 +22,9 @@ flex: 1; word-wrap: break-word; - @include token-utils.use-tokens(m2-tree.$prefix, m2-tree.get-token-slots()) { - // TODO: before tokens were introduced, the `min-height` only applied to the - // non-nested tree node. Should it apply to the nested one as well? - min-height: token-utils.slot(node-min-height); - } + // TODO: before tokens were introduced, the `min-height` only applied to the + // non-nested tree node. Should it apply to the nested one as well? + min-height: token-utils.slot(tree-node-min-height, $fallbacks); } .mat-nested-tree-node { diff --git a/src/material/tree/tree.spec.ts b/src/material/tree/tree.spec.ts index 1a62d6b1505b..9fe78c00b211 100644 --- a/src/material/tree/tree.spec.ts +++ b/src/material/tree/tree.spec.ts @@ -5,8 +5,9 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -import {Component, ViewChild, Type} from '@angular/core'; +import {Component, ViewChild} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {AsyncPipe} from '@angular/common'; import {BehaviorSubject, Observable} from 'rxjs'; import {map} from 'rxjs/operators'; import {MatTree, MatTreeModule, MatTreeNestedDataSource} from './index'; @@ -14,30 +15,19 @@ import {MatTree, MatTreeModule, MatTreeNestedDataSource} from './index'; describe('MatTree', () => { /** Represents an indent for expectNestedTreeToMatch */ const _ = {}; - let treeElement: HTMLElement; let underlyingDataSource: FakeDataSource; - function configureMatTreeTestingModule(declarations: Type[]) { - TestBed.configureTestingModule({ - imports: [MatTreeModule], - declarations: declarations, - }); - } - describe('flat tree', () => { describe('should initialize', () => { let fixture: ComponentFixture; let component: SimpleMatTreeApp; beforeEach(() => { - configureMatTreeTestingModule([SimpleMatTreeApp]); fixture = TestBed.createComponent(SimpleMatTreeApp); - component = fixture.componentInstance; underlyingDataSource = component.dataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -113,13 +103,10 @@ describe('MatTree', () => { let component: MatTreeAppWithToggle; beforeEach(() => { - configureMatTreeTestingModule([MatTreeAppWithToggle]); fixture = TestBed.createComponent(MatTreeAppWithToggle); - component = fixture.componentInstance; underlyingDataSource = component.dataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -241,13 +228,10 @@ describe('MatTree', () => { let component: WhenNodeMatTreeApp; beforeEach(() => { - configureMatTreeTestingModule([WhenNodeMatTreeApp]); fixture = TestBed.createComponent(WhenNodeMatTreeApp); - component = fixture.componentInstance; underlyingDataSource = component.dataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -269,10 +253,8 @@ describe('MatTree', () => { let fixture: ComponentFixture; beforeEach(() => { - configureMatTreeTestingModule([MatTreeWithNullOrUndefinedChild]); fixture = TestBed.createComponent(MatTreeWithNullOrUndefinedChild); treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -290,10 +272,8 @@ describe('MatTree', () => { let fixture: ComponentFixture; beforeEach(() => { - configureMatTreeTestingModule([MatNestedTreeWithNullOrUndefinedChild]); fixture = TestBed.createComponent(MatNestedTreeWithNullOrUndefinedChild); treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -311,7 +291,6 @@ describe('MatTree', () => { let component: NestedMatTreeApp; beforeEach(() => { - configureMatTreeTestingModule([NestedMatTreeApp]); fixture = TestBed.createComponent(NestedMatTreeApp); component = fixture.componentInstance; @@ -417,13 +396,10 @@ describe('MatTree', () => { let component: WhenNodeNestedMatTreeApp; beforeEach(() => { - configureMatTreeTestingModule([WhenNodeNestedMatTreeApp]); fixture = TestBed.createComponent(WhenNodeNestedMatTreeApp); - component = fixture.componentInstance; underlyingDataSource = component.underlyingDataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -443,13 +419,10 @@ describe('MatTree', () => { let component: NestedMatTreeAppWithToggle; beforeEach(() => { - configureMatTreeTestingModule([NestedMatTreeAppWithToggle]); fixture = TestBed.createComponent(NestedMatTreeAppWithToggle); - component = fixture.componentInstance; underlyingDataSource = component.dataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); - fixture.detectChanges(); }); @@ -574,7 +547,6 @@ describe('MatTree', () => { let tree: MatTree; beforeEach(() => { - configureMatTreeTestingModule([NestedMatTreeApp]); fixture = TestBed.createComponent(NestedMatTreeApp); fixture.detectChanges(); @@ -585,7 +557,6 @@ describe('MatTree', () => { underlyingDataSource.addChild(child, false); underlyingDataSource.addChild(child, false); fixture.detectChanges(); - tree = component.tree; treeElement = fixture.nativeElement.querySelector('mat-tree'); nodes = getNodes(treeElement); @@ -884,7 +855,7 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { `, - standalone: false, + imports: [MatTreeModule], }) class SimpleMatTreeApp { getLevel = (node: TestData) => node.level; @@ -934,7 +905,7 @@ const TREE_DATA: FoodNode[] = [ `, - standalone: false, + imports: [MatTreeModule], }) class MatTreeWithNullOrUndefinedChild { childrenAccessor = (node: FoodNode): FoodNode[] => node.children || []; @@ -955,7 +926,7 @@ class MatTreeWithNullOrUndefinedChild { `, - standalone: false, + imports: [MatTreeModule], }) class MatNestedTreeWithNullOrUndefinedChild { childrenAccessor = (node: FoodNode): FoodNode[] => node.children || []; @@ -978,7 +949,7 @@ class MatNestedTreeWithNullOrUndefinedChild { `, - standalone: false, + imports: [MatTreeModule, AsyncPipe], }) class NestedMatTreeApp { childrenAccessor = (node: TestData) => node.observableChildren; @@ -1015,7 +986,7 @@ class NestedMatTreeApp { `, - standalone: false, + imports: [MatTreeModule], }) class WhenNodeNestedMatTreeApp { isSpecial = (_: number, node: TestData) => node.isSpecial; @@ -1050,7 +1021,7 @@ class WhenNodeNestedMatTreeApp { `, - standalone: false, + imports: [MatTreeModule], }) class MatTreeAppWithToggle { toggleRecursively: boolean = true; @@ -1079,7 +1050,7 @@ class MatTreeAppWithToggle { `, - standalone: false, + imports: [MatTreeModule, AsyncPipe], }) class NestedMatTreeAppWithToggle { toggleRecursively: boolean = true; @@ -1112,7 +1083,7 @@ class NestedMatTreeAppWithToggle { `, - standalone: false, + imports: [MatTreeModule], }) class WhenNodeMatTreeApp { isSpecial = (_: number, node: TestData) => node.isSpecial; diff --git a/src/universal-app/BUILD.bazel b/src/universal-app/BUILD.bazel index e5d73c154a5b..caf61fa1fed9 100644 --- a/src/universal-app/BUILD.bazel +++ b/src/universal-app/BUILD.bazel @@ -1,13 +1,13 @@ -load("@bazel_skylib//rules:build_test.bzl", "build_test") +load("@aspect_rules_esbuild//esbuild:defs.bzl", "esbuild") load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_run_binary") -load("//tools:defaults.bzl", "http_server", "ng_project", "protractor_web_test_suite", "sass_binary", "ts_project") load("@aspect_rules_ts//ts:defs.bzl", rules_js_tsconfig = "ts_config") -load("@aspect_rules_esbuild//esbuild:defs.bzl", "esbuild") +load("@bazel_skylib//rules:build_test.bzl", "build_test") load("//src/cdk:config.bzl", "CDK_TARGETS") load("//src/cdk-experimental:config.bzl", "CDK_EXPERIMENTAL_TARGETS") load("//src/components-examples:config.bzl", "ALL_EXAMPLES") load("//src/material:config.bzl", "MATERIAL_TARGETS") load("//src/material-experimental:config.bzl", "MATERIAL_EXPERIMENTAL_TARGETS") +load("//tools:defaults.bzl", "http_server", "ng_project", "protractor_web_test_suite", "sass_binary", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -64,7 +64,6 @@ ts_project( "//:node_modules/@angular/platform-server", "//:node_modules/@bazel/runfiles", "//:node_modules/@types/node", - "//:node_modules/reflect-metadata", "//:node_modules/zone.js", ], ) diff --git a/src/universal-app/prerender.ts b/src/universal-app/prerender.ts index af21a895e4e5..8bc101bf3cd5 100644 --- a/src/universal-app/prerender.ts +++ b/src/universal-app/prerender.ts @@ -1,4 +1,3 @@ -import 'reflect-metadata'; import 'zone.js'; import {ErrorHandler} from '@angular/core'; diff --git a/src/youtube-player/youtube-player.spec.ts b/src/youtube-player/youtube-player.spec.ts index b0cfafebdb2a..ab552cc0b6bf 100644 --- a/src/youtube-player/youtube-player.spec.ts +++ b/src/youtube-player/youtube-player.spec.ts @@ -515,7 +515,7 @@ describe('YoutubePlayer', () => { let calls = playerCtorSpy.calls.all(); expect(calls.length).toBe(1); - expect(calls[0].args[1]).toEqual(jasmine.objectContaining({playerVars, videoId: undefined})); + expect(calls[0].args[1]).toEqual(jasmine.objectContaining({playerVars})); playerSpy.destroy.calls.reset(); playerCtorSpy.calls.reset(); diff --git a/src/youtube-player/youtube-player.ts b/src/youtube-player/youtube-player.ts index c5008ceb3ae6..e88844e39f99 100644 --- a/src/youtube-player/youtube-player.ts +++ b/src/youtube-player/youtube-player.ts @@ -587,17 +587,22 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { // Important! We need to create the Player object outside of the `NgZone`, because it kicks // off a 250ms setInterval which will continually trigger change detection if we don't. + const params: YT.PlayerOptions = { + host: this.disableCookies ? 'https://www.youtube-nocookie.com' : undefined, + width: this.width, + height: this.height, + // Calling `playVideo` on load doesn't appear to actually play + // the video so we need to trigger it through `playerVars` instead. + playerVars: playVideo ? {...(this.playerVars || {}), autoplay: 1} : this.playerVars, + }; + // We only want to injecct a videoId if one is provided, otherwise loading a playlist via + // playerVars.list, the missing videoId will create a null value in the youtube iframe url + // and that can trigger a JS error `Invalid video id` in widget api. + if (this.videoId) { + params.videoId = this.videoId; + } const player = this._ngZone.runOutsideAngular( - () => - new YT.Player(this.youtubeContainer.nativeElement, { - videoId: this.videoId, - host: this.disableCookies ? 'https://www.youtube-nocookie.com' : undefined, - width: this.width, - height: this.height, - // Calling `playVideo` on load doesn't appear to actually play - // the video so we need to trigger it through `playerVars` instead. - playerVars: playVideo ? {...(this.playerVars || {}), autoplay: 1} : this.playerVars, - }), + () => new YT.Player(this.youtubeContainer.nativeElement, params), ); const whenReady = (event: YT.PlayerEvent) => { @@ -710,9 +715,9 @@ export class YouTubePlayer implements AfterViewInit, OnChanges, OnDestroy { player.addEventListener(name, listener); }, listener => { - // The API seems to throw when we try to unbind from a destroyed player and it doesn't - // expose whether the player has been destroyed so we have to wrap it in a try/catch to - // prevent the entire stream from erroring out. + // The API seems to throw when we try to unbind from a destroyed player and it + // doesn'texpose whether the player has been destroyed so we have to wrap it in a + // try/catch to prevent the entire stream from erroring out. try { player?.removeEventListener?.(name, listener); } catch {} diff --git a/test/BUILD.bazel b/test/BUILD.bazel index fcf31a1ec9e7..e572fd95464d 100644 --- a/test/BUILD.bazel +++ b/test/BUILD.bazel @@ -1,5 +1,5 @@ -load("//tools:defaults.bzl", "ts_project") load("@aspect_rules_ts//ts:defs.bzl", rules_js_tsconfig = "ts_config") +load("//tools:defaults.bzl", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -21,7 +21,6 @@ ts_project( deps = [ "//:node_modules/@angular/core", "//:node_modules/@angular/platform-browser", - "//:node_modules/reflect-metadata", "//:node_modules/zone.js", ], ) diff --git a/test/angular-test.init.ts b/test/angular-test.init.ts index 6961b9c7effc..28ae9585c2a7 100644 --- a/test/angular-test.init.ts +++ b/test/angular-test.init.ts @@ -8,24 +8,13 @@ import 'zone.js'; import 'zone.js/testing'; -import 'reflect-metadata'; -import {ErrorHandler, NgModule, provideExperimentalZonelessChangeDetection} from '@angular/core'; +import {NgModule, provideCheckNoChangesConfig, provideZonelessChangeDetection} from '@angular/core'; import {TestBed} from '@angular/core/testing'; import {BrowserTestingModule, platformBrowserTesting} from '@angular/platform-browser/testing'; @NgModule({ - providers: [ - provideExperimentalZonelessChangeDetection(), - { - provide: ErrorHandler, - useValue: { - handleError: (e: any) => { - throw e; - }, - }, - }, - ], + providers: [provideZonelessChangeDetection(), provideCheckNoChangesConfig({exhaustive: true})], }) export class TestModule {} diff --git a/tools/adev-api-extraction/extract_api_to_json.bzl b/tools/adev-api-extraction/extract_api_to_json.bzl index d71409dd9adc..37964b4dc9c4 100644 --- a/tools/adev-api-extraction/extract_api_to_json.bzl +++ b/tools/adev-api-extraction/extract_api_to_json.bzl @@ -10,6 +10,9 @@ def _extract_api_to_json(ctx): args.set_param_file_format("multiline") args.use_param_file("%s", use_always = True) + # Pass the repo name for the extracted APIs. This will be something like "angular/angular". + args.add(ctx.attr.repo) + # Pass the module_name for the extracted APIs. This will be something like "@angular/core". args.add(ctx.attr.module_name) @@ -97,6 +100,10 @@ extract_api_to_json = rule( "module_label": attr.string( doc = """Module label to be used for the extracted symbols. To be used as display name, for example in API docs""", ), + "repo": attr.string( + doc = """The name of the github repository the api belongs to""", + mandatory = True, + ), "extra_entries": attr.label_list( doc = """JSON files that contain extra entries to append to the final collection.""", allow_files = True, diff --git a/tools/adev-api-extraction/index.mts b/tools/adev-api-extraction/index.mts index c9d7b358f9b8..e8ad78c5c65b 100644 --- a/tools/adev-api-extraction/index.mts +++ b/tools/adev-api-extraction/index.mts @@ -26,6 +26,7 @@ function main() { const rawParamLines = readFileSync(paramFilePath, {encoding: 'utf8'}).split('\n'); const [ + repo, moduleName, moduleLabel, serializedPrivateModules, @@ -91,25 +92,30 @@ function main() { const normalized = moduleName.replace('@', '').replace(/[\/]/g, '_'); - const output = JSON.stringify({ - moduleLabel: moduleLabel || moduleName, - moduleName: moduleName, - normalizedModuleName: normalized, - entries: combinedEntries, - symbols: [ - // Symbols referenced, originating from other packages - ...apiDoc.symbols.entries(), - - // Exported symbols from the current package - ...apiDoc.entries.map(entry => [entry.name, moduleName]), - - // Also doing it for every member of classes/interfaces - ...apiDoc.entries.flatMap(entry => [ - [entry.name, moduleName], - ...getEntriesFromMembers(entry).map(member => [member, moduleName]), - ]), - ], - } as EntryCollection); + const output = JSON.stringify( + { + repo, + moduleLabel: moduleLabel || moduleName, + moduleName: moduleName, + normalizedModuleName: normalized, + entries: combinedEntries, + symbols: [ + // Symbols referenced, originating from other packages + ...apiDoc.symbols.entries(), + + // Exported symbols from the current package + ...apiDoc.entries.map(entry => [entry.name, moduleName]), + + // Also doing it for every member of classes/interfaces + ...apiDoc.entries.flatMap(entry => [ + [entry.name, moduleName], + ...getEntriesFromMembers(entry).map(member => [member, moduleName]), + ]), + ], + } as EntryCollection, + null, + 2, + ); writeFileSync(outputFilenameExecRootRelativePath, output, {encoding: 'utf8'}); } diff --git a/tools/defaults.bzl b/tools/defaults.bzl index 94536aa7e108..7d033f2935ce 100644 --- a/tools/defaults.bzl +++ b/tools/defaults.bzl @@ -1,23 +1,23 @@ # Re-export of Bazel rules with repository-wide defaults -load("@rules_pkg//:pkg.bzl", "pkg_tar") -load("@rules_sass//src:index.bzl", _sass_binary = "sass_binary", _sass_library = "sass_library") -load("@rules_angular//src/ng_package:index.bzl", _ng_package = "ng_package") -load("//:packages.bzl", "NO_STAMP_NPM_PACKAGE_SUBSTITUTIONS", "NPM_PACKAGE_SUBSTITUTIONS") -load("//:pkg-externals.bzl", "PKG_EXTERNALS") -load("//tools/markdown-to-html:index.bzl", _markdown_to_html = "markdown_to_html") -load("//tools/extract-tokens:index.bzl", _extract_tokens = "extract_tokens") -load("//tools/bazel:ng_package_interop.bzl", "ng_package_interop") +load("@aspect_rules_jasmine//jasmine:defs.bzl", _jasmine_test = "jasmine_test") +load("@aspect_rules_js//npm:defs.bzl", _npm_package = "npm_package") load("@devinfra//bazel/http-server:index.bzl", _http_server = "http_server") load("@devinfra//bazel/spec-bundling:index_rjs.bzl", _spec_bundle = "spec_bundle") -load("//tools/bazel:web_test_suite.bzl", _ng_web_test_suite = "ng_web_test_suite") -load("@aspect_rules_js//npm:defs.bzl", _npm_package = "npm_package") +load("@devinfra//bazel/ts_project:index.bzl", "strict_deps_test") +load("@rules_angular//src/ng_package:index.bzl", _ng_package = "ng_package") load("@rules_angular//src/ng_package/text_replace:index.bzl", _text_replace = "text_replace") load("@rules_angular//src/ng_project:index.bzl", _ng_project = "ng_project") load("@rules_angular//src/ts_project:index.bzl", _ts_project = "ts_project") -load("@devinfra//bazel/ts_project:index.bzl", "strict_deps_test") -load("@aspect_rules_jasmine//jasmine:defs.bzl", _jasmine_test = "jasmine_test") load("@rules_browsers//src/protractor_test:index.bzl", "protractor_test") +load("@rules_pkg//:pkg.bzl", "pkg_tar") +load("@rules_sass//src:index.bzl", _sass_binary = "sass_binary", _sass_library = "sass_library") +load("//:packages.bzl", "NO_STAMP_NPM_PACKAGE_SUBSTITUTIONS", "NPM_PACKAGE_SUBSTITUTIONS") +load("//:pkg-externals.bzl", "PKG_EXTERNALS") +load("//tools/bazel:ng_package_interop.bzl", "ng_package_interop") +load("//tools/bazel:web_test_suite.bzl", _ng_web_test_suite = "ng_web_test_suite") +load("//tools/extract-tokens:index.bzl", _extract_tokens = "extract_tokens") +load("//tools/markdown-to-html:index.bzl", _markdown_to_html = "markdown_to_html") # Re-exports to simplify build file load statements markdown_to_html = _markdown_to_html @@ -208,9 +208,10 @@ def jasmine_test(name, data = [], args = [], **kwargs): chdir = native.package_name(), fixed_args = [ "--require=%s/node_modules/source-map-support/register.js" % relative_to_root, - "**/*spec.js", - "**/*spec.mjs", - "**/*spec.cjs", + # Escape so that the `js_binary` launcher triggers Bash expansion. + "'**/*+(.|_)spec.js'", + "'**/*+(.|_)spec.mjs'", + "'**/*+(.|_)spec.cjs'", ] + args, data = data + [ "//:node_modules/source-map-support", diff --git a/tools/dgeni/common/compute-api-url.ts b/tools/dgeni/common/compute-api-url.ts index 9ac5100f7190..886fd217d101 100644 --- a/tools/dgeni/common/compute-api-url.ts +++ b/tools/dgeni/common/compute-api-url.ts @@ -3,12 +3,12 @@ import {ModuleInfo} from '../processors/entry-point-grouper'; /** * Computes an URL that refers to the given API document in the docs. Note that this logic - * needs to be kept in sync with the routes from the material.angular.io CLI project. + * needs to be kept in sync with the routes from the material.angular.dev CLI project. */ export function computeApiDocumentUrl(apiDoc: Document, moduleInfo: ModuleInfo): string { // Base URL for the given package. This is currently either: - // 1) material.angular.io/cdk - // 2) material.angular.io/components + // 1) material.angular.dev/cdk + // 2) material.angular.dev/components const baseUrl = moduleInfo.packageName === 'cdk' ? 'cdk' : 'components'; return `${baseUrl}/${moduleInfo.entryPointName}/api#${apiDoc.name}`; } diff --git a/tools/dgeni/docs-package.ts b/tools/dgeni/docs-package.ts index c006065849b2..bbdc1b22a7c3 100644 --- a/tools/dgeni/docs-package.ts +++ b/tools/dgeni/docs-package.ts @@ -2,7 +2,6 @@ import {Package} from 'dgeni'; import {ReadTypeScriptModules} from 'dgeni-packages/typescript/processors/readTypeScriptModules'; import {Host} from 'dgeni-packages/typescript/services/ts-host/host'; import {TypeFormatFlags} from 'typescript'; -import {HighlightNunjucksExtension} from './nunjucks-tags/highlight'; import {patchLogService} from './patch-log-service'; import {AsyncFunctionsProcessor} from './processors/async-functions'; import {categorizer} from './processors/categorizer'; @@ -165,6 +164,4 @@ apiDocsPackage.config(function (templateFinder: any, templateEngine: any) { variableStart: '{$', variableEnd: '$}', }; - - templateEngine.tags.push(new HighlightNunjucksExtension()); }); diff --git a/tools/dgeni/nunjucks-tags/highlight.ts b/tools/dgeni/nunjucks-tags/highlight.ts deleted file mode 100644 index 9e42c6081026..000000000000 --- a/tools/dgeni/nunjucks-tags/highlight.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {highlightCodeBlock} from '../../highlight-files/highlight-code-block'; - -/** - * Nunjucks extension that supports rendering highlighted content. Content that is placed in - * between a {% highlight %} and {% end_highlight %} block will be automatically highlighted. - * - * HighlightJS cannot detect the code language automatically. Therefore, developers need to - * specify the language manually as first tag-block argument. - */ -export class HighlightNunjucksExtension { - /** Tags that will be parsed by this Nunjucks extension. */ - tags = ['highlight']; - - /** Disable auto-escaping for content that is rendered within this extension. */ - autoescape = false; - - parse(parser: any, nodes: any) { - const startToken = parser.nextToken(); - const args = parser.parseSignature(null, true); - - // Jump to the end of the "{% highlight }" block. - parser.advanceAfterBlockEnd(startToken.value); - - // Parse text content until {% end_highlight }" has been reached. - const textContent = parser.parseUntilBlocks('end_highlight'); - - // Jump to the end of the "{% highlight }" block. - parser.advanceAfterBlockEnd(); - - return new nodes.CallExtension(this, 'render', args, [textContent]); - } - - render(_context: any, language: string, contentFn: () => string) { - return highlightCodeBlock(contentFn(), language); - } -} diff --git a/tools/dgeni/templates/constant.template.html b/tools/dgeni/templates/constant.template.html index 6377a3ef266a..704df309c180 100644 --- a/tools/dgeni/templates/constant.template.html +++ b/tools/dgeni/templates/constant.template.html @@ -17,10 +17,6 @@
    diff --git a/tools/dgeni/templates/type-alias.template.html b/tools/dgeni/templates/type-alias.template.html index 397b1479798d..9da26b2bb097 100644 --- a/tools/dgeni/templates/type-alias.template.html +++ b/tools/dgeni/templates/type-alias.template.html @@ -17,10 +17,6 @@
    diff --git a/tools/extract-tokens/BUILD.bazel b/tools/extract-tokens/BUILD.bazel index 8f4763b38ccd..36dd4dec4c1c 100644 --- a/tools/extract-tokens/BUILD.bazel +++ b/tools/extract-tokens/BUILD.bazel @@ -5,7 +5,7 @@ package(default_visibility = ["//visibility:public"]) ts_project( name = "extract_tokens_lib", - srcs = glob(["**/*.ts"]), + srcs = glob(["**/*.mts"]), tsconfig = "//tools:tsconfig", deps = [ "//:node_modules/sass", @@ -18,5 +18,5 @@ js_binary( data = [ ":extract_tokens_lib", ], - entry_point = ":extract-tokens.js", + entry_point = ":extract-tokens.mjs", ) diff --git a/tools/extract-tokens/extract-tokens.ts b/tools/extract-tokens/extract-tokens.mts similarity index 68% rename from tools/extract-tokens/extract-tokens.ts rename to tools/extract-tokens/extract-tokens.mts index 8ada0a9fd7bb..c4fce5f53d75 100644 --- a/tools/extract-tokens/extract-tokens.ts +++ b/tools/extract-tokens/extract-tokens.mts @@ -2,7 +2,7 @@ import {readFileSync, writeFileSync} from 'fs'; import {pathToFileURL} from 'url'; import {relative, join, dirname} from 'path'; import {compileString} from 'sass'; -import {highlightCodeBlock} from '../highlight-files/highlight-code-block'; +import {highlightCodeBlock} from '../highlight-files/highlight-code-block.mjs'; /** Information extracted for a single token from the theme. */ interface ExtractedToken { @@ -43,40 +43,39 @@ interface ThemeData { } // Script that extracts the tokens from a specific Bazel target. -if (require.main === module) { - const [packagePath, outputPath, ...inputFiles] = process.argv.slice(2); - const themeFiles = inputFiles - // Filter out only the files within the package - // since the path also includes dependencies. - .filter(file => file.startsWith(packagePath)) - .map(file => { - // Assumption: all theme files start with an underscore since they're - // partials and they end with `-theme`. - // Assumption: the name under which the theme mixin will be available is the - // same as the file name without the underscore and `-theme.scss`. - const match = file.match(/_(.*)-theme\.scss$/); - return match ? {mixinPrefix: match[1], filePath: file} : null; - }) - .filter(file => !!file); - - if (themeFiles.length === 0) { - throw new Error(`Could not find theme files in ${packagePath}`); - } - const themes: ThemeData[] = []; +const [packagePath, outputPath, ...inputFiles] = process.argv.slice(2); +const themeFiles = inputFiles + // Filter out only the files within the package + // since the path also includes dependencies. + .filter(file => file.startsWith(packagePath)) + .map(file => { + // Assumption: all theme files start with an underscore since they're + // partials and they end with `-theme`. + // Assumption: the name under which the theme mixin will be available is the + // same as the file name without the underscore and `-theme.scss`. + const match = file.match(/_(.*)-theme\.scss$/); + return match ? {mixinPrefix: match[1], filePath: file} : null; + }) + .filter(file => !!file); + +if (themeFiles.length === 0) { + throw new Error(`Could not find theme files in ${packagePath}`); +} + +const themes: ThemeData[] = []; - themeFiles.forEach(theme => { - themes.push({ - name: theme.mixinPrefix, - // This can be derived from the `name` already, but we want the source - // of truth to be in this repo, instead of whatever page consumes the data. - overridesMixin: `${theme.mixinPrefix}-overrides`, - tokens: extractTokens(theme.filePath), - }); +themeFiles.forEach(theme => { + themes.push({ + name: theme.mixinPrefix, + // This can be derived from the `name` already, but we want the source + // of truth to be in this repo, instead of whatever page consumes the data. + overridesMixin: `${theme.mixinPrefix}-overrides`, + tokens: extractTokens(theme.filePath), }); +}); - writeFileSync(outputPath, JSON.stringify({example: getUsageExample(themes), themes})); -} +writeFileSync(outputPath, JSON.stringify({example: getUsageExample(themes), themes})); /** * Extracts the tokens from a theme file. @@ -206,7 +205,6 @@ function getTokenExtractionCode( const m3System = '___privateM3System'; const palettes = '___privatePalettes'; const sassUtils = '__privateSassUtils'; - const inferTokenType = '__privateInferFromValue'; const defineOverrides = '_define-overrides'; const corePath = relative(dirname(absoluteThemePath), join(srcPath, 'material/core')) || '.'; @@ -232,9 +230,9 @@ function getTokenExtractionCode( @error 'File must define a ${defineOverrides} function for docs extraction purposes'; } - $__all-color: ${m3Tokens}.generate-color-tokens(light, ${palettes}.$azure-palette, - ${palettes}.$azure-palette, ${palettes}.$azure-palette, 'mat-sys'); - $__all-typography: ${m3Tokens}.generate-typography-tokens(font, 100, 100, 100, 100, 'mat-sys'); + $__all-color: ${m3Tokens}.generate-color-tokens(light, ${palettes}.$azure-palette, 'mat-sys'); + $__all-typography: ${m3Tokens}.generate-typography-tokens(( + plain: (font), brand: (font), bold: 100, medium: 100, regular: 100), 'mat-sys'); $__all-density: ${m3System}.get-density-tokens(0); $__all-base: ${m3Tokens}.generate-base-tokens(); $__results: (); @@ -254,91 +252,49 @@ function getTokenExtractionCode( @return $result; } - // Uses some simple heuristics to determine the type of a token based on its name or value. - @function ${inferTokenType}($name, $value) { - @if ($value == null) { - @return null; - } - - $type: ${meta}.type-of($value); - $inferred-type: null; - - // Note: Sass' string.index returns a 1-based index or null (if the value can't be found) - // so it's safe to just null check it in the conditions below. - @if ($type == 'color' or ${str}.index($name, 'shadow') or ${str}.index($name, 'opacity')) { - $inferred-type: color; - } @else if ( - ${str}.index($name, 'font') or - ${str}.index($name, 'line-height') or - ${str}.index($name, 'tracking') or - ${str}.index($name, 'weight') or - (${str}.index($name, 'text') and ${str}.index($name, 'size')) or - (${str}.index($name, 'text') and ${str}.index($name, 'transform')) - ) { - $inferred-type: typography; - } @else if (${str}.index($name, 'width') or ${str}.index($name, 'height')) { - $inferred-type: density; - } @else if ($type == 'string' or ${str}.index($name, 'shape')) { - $inferred-type: base; - } - - @return $inferred-type; - } - @each $map in $__override-tokens { - $namespace: ${map}.get($map, namespace); $tokens: ${map}.get($map, tokens); + $namespace: ${map}.get($map, namespace); $prefix: ${map}.get($map, prefix) or ''; - $color: ${map}.get($__all-color, $namespace) or (); - $base: ${map}.get($__all-base, $namespace) or (); - $typography: ${map}.get($__all-typography, $namespace) or (); - $density: ${map}.get($__all-density, $namespace) or ();\ - - @each $name, $resolved-value in $tokens { - $color-value: ${map}.get($color, $name); - $base-value: ${map}.get($base, $name); - $typography-value: ${map}.get($typography, $name); - $density-value: ${map}.get($density, $name); - - $type: ''; - $value: null; - - @if ($density-value) { - $type: density; - $value: $density-value; - } @else if ($typography-value) { - $type: typography; - $value: $typography-value; - } @else if ($color-value) { - $type: color; - $value: $color-value; - } @else { - $type: base; - $value: $base-value; - } - // If the token has a value, but could not be found in the token maps, try to infer its type - // from the name and value. This is fairly rare, but can happen for some hardcoded tokens. - @if ($value == null and $resolved-value) { - $fallback-type: ${inferTokenType}($name, $resolved-value); + @each $name, $value in ${map}.get($tokens, base) { + $__results: ${list}.append($__results, ( + name: ${str}.unquote($name), + value: $value, + type: base, + prefix: ${str}.unquote(${stringJoin}($namespace, '-')), + overridesName: ${str}.unquote($prefix + $name), + )) !global; + } - @if ($fallback-type == null) { - @error 'Cannot determine type of token "#{$name}". Token extraction script needs to be updated.'; - } + @each $name, $value in ${map}.get($tokens, color) { + $__results: ${list}.append($__results, ( + name: ${str}.unquote($name), + value: $value, + type: color, + prefix: ${str}.unquote(${stringJoin}($namespace, '-')), + overridesName: ${str}.unquote($prefix + $name), + )) !global; + } - $type: $fallback-type; - $value: $resolved-value; - } + @each $name, $value in ${map}.get($tokens, typography) { + $__results: ${list}.append($__results, ( + name: ${str}.unquote($name), + value: $value, + type: typography, + prefix: ${str}.unquote(${stringJoin}($namespace, '-')), + overridesName: ${str}.unquote($prefix + $name), + )) !global; + } - @if ($value) { - $__results: ${list}.append($__results, ( - name: ${str}.unquote($name), - value: $value, - type: $type, - prefix: ${str}.unquote(${stringJoin}($namespace, '-')), - overridesName: ${str}.unquote($prefix + $name), - )) !global; - } + @each $name, $value in ${map}.get($tokens, density) { + $__results: ${list}.append($__results, ( + name: ${str}.unquote($name), + value: $value, + type: density, + prefix: ${str}.unquote(${stringJoin}($namespace, '-')), + overridesName: ${str}.unquote($prefix + $name), + )) !global; } } diff --git a/tools/highlight-files/BUILD.bazel b/tools/highlight-files/BUILD.bazel index c59808a8dd5a..5a48f49633b3 100644 --- a/tools/highlight-files/BUILD.bazel +++ b/tools/highlight-files/BUILD.bazel @@ -1,11 +1,11 @@ -load("//tools:defaults.bzl", "ts_project") load("@aspect_rules_js//js:defs.bzl", "js_binary") +load("//tools:defaults.bzl", "ts_project") package(default_visibility = ["//visibility:public"]) ts_project( name = "sources", - srcs = glob(["**/*.ts"]), + srcs = glob(["**/*.mts"]), tsconfig = "//tools:tsconfig", deps = [ "//:node_modules/@types/fs-extra", @@ -20,5 +20,5 @@ js_binary( data = [ ":sources", ], - entry_point = ":highlight-files.js", + entry_point = ":highlight-files.mjs", ) diff --git a/tools/highlight-files/highlight-code-block.ts b/tools/highlight-files/highlight-code-block.mts similarity index 84% rename from tools/highlight-files/highlight-code-block.ts rename to tools/highlight-files/highlight-code-block.mts index f0383dff6c71..ce7a38f15537 100644 --- a/tools/highlight-files/highlight-code-block.ts +++ b/tools/highlight-files/highlight-code-block.mts @@ -4,7 +4,7 @@ import highlightJs from 'highlight.js'; * Transforms a given code block into its corresponding HTML output. We do this using * highlight.js because it allows us to show colored code blocks in our documentation. */ -export function highlightCodeBlock(code: string, language: string) { +export function highlightCodeBlock(code: string, language?: string) { if (language) { return highlightJs.highlight(code, { language: language.toLowerCase() === 'ts' ? 'typescript' : language, diff --git a/tools/highlight-files/highlight-files.mts b/tools/highlight-files/highlight-files.mts new file mode 100644 index 000000000000..19d1633abad5 --- /dev/null +++ b/tools/highlight-files/highlight-files.mts @@ -0,0 +1,73 @@ +/** + * Script that will be used by the highlight_files Bazel rule in order to highlight + * multiple input files using highlight.js. The output will be HTML files. + */ + +import fsExtra from 'fs-extra'; +import {dirname, extname, join, relative} from 'path'; +import {highlightCodeBlock} from './highlight-code-block.mjs'; +import {regionParser} from '../region-parser/region-parser.mjs'; + +/** + * Determines the command line arguments for the current Bazel action. Since this action can + * have a large set of input files, Bazel may write the arguments into a parameter file. + * This function is responsible for handling normal argument passing or Bazel parameter files. + * Read more here: https://docs.bazel.build/versions/master/skylark/lib/Args.html#use_param_file + */ +function getBazelActionArguments() { + const args = process.argv.slice(2); + + // If Bazel uses a parameter file, we've specified that it passes the file in the following + // format: "arg0 arg1 --param-file={path_to_param_file}" + if (args[0].startsWith('--param-file=')) { + return fsExtra.readFileSync(args[0].split('=')[1], 'utf8').trim().split('\n'); + } + + return args; +} + +function detectAndHighlightRegionBlocks( + parsed: {contents: string; regions: {[p: string]: string}}, + basePath: string, + outDir: string, +) { + const fileExtension = extname(basePath).substring(1); + for (const [regionName, regionSnippet] of Object.entries(parsed.regions)) { + // Create files for each found region + if (!regionName) { + continue; + } + const highlightedRegion = highlightCodeBlock(regionSnippet, fileExtension); + // Convert "my-component-example.ts" into "my-component-example_region-ts.html" + const regionBaseOutputPath = basePath.replace( + `.${fileExtension}`, + `_${regionName}-${fileExtension}.html`, + ); + const regionOutputPath = join(outDir, regionBaseOutputPath); + fsExtra.ensureDirSync(dirname(regionOutputPath)); + fsExtra.writeFileSync(regionOutputPath, highlightedRegion); + } +} + +// The script expects the output directory as first argument. Second is the name of the +// package where this the highlight target is declared. All remaining arguments will be +// considered as markdown input files that need to be transformed. +const [outDir, packageName, ...inputFiles] = getBazelActionArguments(); + +// Walk through each input file and write transformed markdown output +// to the specified output directory. +for (const execPath of inputFiles) { + // Compute a relative path from the package to the actual input file. + // e.g `src/components-examples/cdk/<..>/example.ts` becomes `cdk/<..>/example.ts`. + const basePath = relative(packageName, execPath); + const fileExtension = extname(basePath).substring(1); + const parsed = regionParser(fsExtra.readFileSync(execPath, 'utf8'), fileExtension); + detectAndHighlightRegionBlocks(parsed, basePath, outDir); + // Convert "my-component-example.ts" into "my-component-example-ts.html" + const baseOutputPath = basePath.replace(`.${fileExtension}`, `-${fileExtension}.html`); + const outputPath = join(outDir, baseOutputPath); + const htmlOutput = highlightCodeBlock(parsed.contents, fileExtension); + + fsExtra.ensureDirSync(dirname(outputPath)); + fsExtra.writeFileSync(outputPath, htmlOutput); +} diff --git a/tools/highlight-files/highlight-files.ts b/tools/highlight-files/highlight-files.ts deleted file mode 100644 index ebfbaff8f579..000000000000 --- a/tools/highlight-files/highlight-files.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Script that will be used by the highlight_files Bazel rule in order to highlight - * multiple input files using highlight.js. The output will be HTML files. - */ - -import {readFileSync, writeFileSync, ensureDirSync} from 'fs-extra'; -import {dirname, extname, join, relative} from 'path'; -import {highlightCodeBlock} from './highlight-code-block'; -import {regionParser} from '../region-parser/region-parser'; - -/** - * Determines the command line arguments for the current Bazel action. Since this action can - * have a large set of input files, Bazel may write the arguments into a parameter file. - * This function is responsible for handling normal argument passing or Bazel parameter files. - * Read more here: https://docs.bazel.build/versions/master/skylark/lib/Args.html#use_param_file - */ -function getBazelActionArguments() { - const args = process.argv.slice(2); - - // If Bazel uses a parameter file, we've specified that it passes the file in the following - // format: "arg0 arg1 --param-file={path_to_param_file}" - if (args[0].startsWith('--param-file=')) { - return readFileSync(args[0].split('=')[1], 'utf8').trim().split('\n'); - } - - return args; -} - -function detectAndHighlightRegionBlocks( - parsed: {contents: string; regions: {[p: string]: string}}, - basePath: string, - outDir: string, -) { - const fileExtension = extname(basePath).substring(1); - for (const [regionName, regionSnippet] of Object.entries(parsed.regions)) { - // Create files for each found region - if (!regionName) { - continue; - } - const highlightedRegion = highlightCodeBlock(regionSnippet, fileExtension); - // Convert "my-component-example.ts" into "my-component-example_region-ts.html" - const regionBaseOutputPath = basePath.replace( - `.${fileExtension}`, - `_${regionName}-${fileExtension}.html`, - ); - const regionOutputPath = join(outDir, regionBaseOutputPath); - ensureDirSync(dirname(regionOutputPath)); - writeFileSync(regionOutputPath, highlightedRegion); - } -} - -if (require.main === module) { - // The script expects the output directory as first argument. Second is the name of the - // package where this the highlight target is declared. All remaining arguments will be - // considered as markdown input files that need to be transformed. - const [outDir, packageName, ...inputFiles] = getBazelActionArguments(); - - // Walk through each input file and write transformed markdown output - // to the specified output directory. - for (const execPath of inputFiles) { - // Compute a relative path from the package to the actual input file. - // e.g `src/components-examples/cdk/<..>/example.ts` becomes `cdk/<..>/example.ts`. - const basePath = relative(packageName, execPath); - const fileExtension = extname(basePath).substring(1); - const parsed = regionParser(readFileSync(execPath, 'utf8'), fileExtension); - detectAndHighlightRegionBlocks(parsed, basePath, outDir); - // Convert "my-component-example.ts" into "my-component-example-ts.html" - const baseOutputPath = basePath.replace(`.${fileExtension}`, `-${fileExtension}.html`); - const outputPath = join(outDir, baseOutputPath); - const htmlOutput = highlightCodeBlock(parsed.contents, fileExtension); - - ensureDirSync(dirname(outputPath)); - writeFileSync(outputPath, htmlOutput); - } -} diff --git a/tools/markdown-to-html/BUILD.bazel b/tools/markdown-to-html/BUILD.bazel index 0a0a08a74051..2e2c97c94ec6 100644 --- a/tools/markdown-to-html/BUILD.bazel +++ b/tools/markdown-to-html/BUILD.bazel @@ -6,15 +6,15 @@ package(default_visibility = ["//visibility:public"]) ts_project( name = "transform-markdown", srcs = glob( - ["**/*.ts"], + ["**/*.mts"], exclude = [ - "*.spec.ts", + "*.spec.mts", ], ), tsconfig = "//tools:tsconfig", deps = [ - "//:node_modules/@types/marked", "//:node_modules/marked", + "//:node_modules/slugify", "//tools/highlight-files:sources", ], ) @@ -24,14 +24,14 @@ js_binary( data = [ ":transform-markdown", ], - entry_point = ":transform-markdown.js", + entry_point = ":transform-markdown.mjs", ) ts_project( name = "unit_test_lib", testonly = True, srcs = glob( - ["*.spec.ts"], + ["*.spec.mts"], ), tsconfig = "//tools:tsconfig-test", visibility = ["//visibility:private"], diff --git a/tools/markdown-to-html/docs-marked-renderer.ts b/tools/markdown-to-html/docs-marked-renderer.mts similarity index 57% rename from tools/markdown-to-html/docs-marked-renderer.ts rename to tools/markdown-to-html/docs-marked-renderer.mts index 1ffe0ef69a84..1432b9860f51 100644 --- a/tools/markdown-to-html/docs-marked-renderer.ts +++ b/tools/markdown-to-html/docs-marked-renderer.mts @@ -1,9 +1,17 @@ -import {Renderer, Slugger} from 'marked'; +import {Renderer, Tokens} from 'marked'; import {basename, extname} from 'path'; +import slugify from 'slugify'; +import {highlightCodeBlock} from '../highlight-files/highlight-code-block.mjs'; /** Regular expression that matches example comments. */ const exampleCommentRegex = //g; +/** Marker inserted before the start of an example. */ +const exampleStartMarker = ''; + +/** Marker inserted after the end of an example. */ +const exampleEndMarker = ''; + /** * Custom renderer for marked that will be used to transform markdown files to HTML * files that can be used in the Angular Material docs. @@ -12,38 +20,45 @@ export class DocsMarkdownRenderer extends Renderer { /** Set of fragment links discovered in the currently rendered file. */ private _referencedFragments = new Set(); - /** - * Slugger provided by the `marked` package. Can be used to create unique - * ids for headings. - */ - private _slugger = new Slugger(); + /** IDs that have been generated during Markdown parsing. */ + private _seenIds = new Set(); /** * Transforms a markdown heading into the corresponding HTML output. In our case, we * want to create a header-link for each H2, H3, and H4 heading. This allows users to jump to * specific parts of the docs. */ - heading(label: string, level: number, raw: string) { - if (level === 2 || level === 3 || level === 4 || level === 5 || level === 6) { - const headingId = this._slugger.slug(raw); + heading(tag: Tokens.Heading) { + const depth = tag.depth; + const content = this.parser.parseInline(tag.tokens); + + if (depth === 2 || depth === 3 || depth === 4 || depth === 5 || depth === 6) { + const headingId = slugify.default(tag.text, {lower: true, strict: true}); + + this._seenIds.add(headingId); return ` - + - ${label} - + ${content} + `; } - return `${label}`; + return `${content}`; } /** Transforms markdown links into the corresponding HTML output. */ - link(href: string, title: string, text: string) { + link(link: Tokens.Link) { + const {href} = link; + // We only want to fix up markdown links that are relative and do not refer to guides already. // Otherwise we always map the link to the "guide/" path. // TODO(devversion): remove this logic and just disallow relative paths. if (!href.startsWith('http') && !href.startsWith('#') && !href.includes('guide/')) { - return super.link(`guide/${basename(href, extname(href))}`, title, text); + return super.link({ + ...link, + href: `guide/${basename(href, extname(href))}`, + }); } // Keep track of all fragments discovered in a file. @@ -51,7 +66,7 @@ export class DocsMarkdownRenderer extends Renderer { this._referencedFragments.add(href.slice(1)); } - return super.link(href, title, text); + return super.link(link); } /** @@ -76,8 +91,10 @@ export class DocsMarkdownRenderer extends Renderer { * turns into * `
    ` */ - html(html: string) { - html = html.replace(exampleCommentRegex, (_match: string, content: string) => { + html(content: Tokens.HTML | Tokens.Tag) { + return content.raw.replace(exampleCommentRegex, (_match: string, content: string) => { + let replacement: string; + // using [\s\S]* because .* does not match line breaks if (content.match(/\{[\s\S]*\}/g)) { const {example, file, region} = JSON.parse(content.trim()) as { @@ -85,15 +102,20 @@ export class DocsMarkdownRenderer extends Renderer { file: string; region: string; }; - return `
    `; } else { - return `
    `; + replacement = `
    `; } + + return `${exampleStartMarker}${replacement}${exampleEndMarker}`; }); + } - return super.html(html); + code(block: Tokens.Code): string { + const langClass = block.lang ? ` class="language-${block.lang}"` : ''; + return `
    ${highlightCodeBlock(block.text, block.lang)}
    `; } /** @@ -106,7 +128,7 @@ export class DocsMarkdownRenderer extends Renderer { // Collect any fragment links that do not resolve to existing fragments in the // rendered file. We want to error for broken fragment links. this._referencedFragments.forEach(id => { - if (this._slugger.seen[id] === undefined) { + if (!this._seenIds.has(id)) { failures.push(`Found link to "${id}". This heading does not exist.`); } }); @@ -117,9 +139,19 @@ export class DocsMarkdownRenderer extends Renderer { process.exit(1); } - this._slugger.seen = {}; + this._seenIds.clear(); this._referencedFragments.clear(); - return `
    ${output}
    `; + const markdownOpen = '
    '; + + // The markdown styling can interfere with the example's styles, because we don't use + // encapsulation. We work around it by replacing the opening marker, left by the previous + // step, with a closing tag, and replacing the closing marker with an opening tag. + // The result is that we exclude the example code from the markdown styling. + output = output + .replace(new RegExp(exampleStartMarker, 'g'), '
    ') + .replace(new RegExp(exampleEndMarker, 'g'), markdownOpen); + + return `${markdownOpen}${output}
    `; } } diff --git a/tools/markdown-to-html/docs-marked-renderer.spec.mts b/tools/markdown-to-html/docs-marked-renderer.spec.mts new file mode 100644 index 000000000000..e1003e1ba70b --- /dev/null +++ b/tools/markdown-to-html/docs-marked-renderer.spec.mts @@ -0,0 +1,129 @@ +import {DocsMarkdownRenderer} from './docs-marked-renderer.mjs'; +import * as marked from 'marked'; + +describe('DocsMarkdownRenderer', () => { + let renderer: DocsMarkdownRenderer; + beforeEach(() => { + renderer = new DocsMarkdownRenderer(); + }); + + function transform(markdown: string): string { + marked.setOptions({renderer}); + + return renderer.finalizeOutput(marked.parse(markdown, {async: false}), 'test.html'); + } + + it('generates regular headings for h1 and h2', () => { + expect(transform('# a')).toContain('

    a

    '); + expect(transform('## b')).toContain('

    { + expectEqualIgnoreLeadingWhitespace( + transform('### header 3'), + ` +
    + +
    + `, + ); + expectEqualIgnoreLeadingWhitespace( + transform('#### header 4'), + ` +
    + +
    + `, + ); + }); + + it('generates links', () => { + expect(transform('[some text](something "some title")')).toContain( + 'some text', + ); + expect(transform('[some text](#some-hash "some title")\n ### some hash')).toContain( + 'some text', + ); + expect(transform('[some text](https://google.com)')).toContain( + 'some text', + ); + }); + + it('generates html using new API', () => { + const result = transform(``); + expectEqualIgnoreLeadingWhitespace( + result, + '
    ', + ); + }); + + it('generates html using new API with no region', () => { + const result = transform(``); + expectEqualIgnoreLeadingWhitespace( + result, + '
    ', + ); + }); + + it('generates html using new API with no file and no region', () => { + const result = transform(``); + expectEqualIgnoreLeadingWhitespace( + result, + '
    ' + + '
    ' + + '
    ', + ); + }); + + it('generates html using old API', () => { + expect(transform('')).toEqual( + '
    ' + + '
    ' + + '
    ', + ); + }); + + it('does not allow id links with no matching id element', () => { + spyOn(console, 'error'); + spyOn(process, 'exit'); + transform('[text](#does-not-exist)'); + expect((console.error as jasmine.Spy).calls.allArgs()).toEqual([ + [jasmine.stringMatching(/Could not process file: test\.html/)], + [jasmine.stringMatching(/Found link to "does-not-exist"\. This heading does not exist/)], + ]); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + function expectEqualIgnoreLeadingWhitespace(actual: string, expected: string) { + expect(stripLeadingWhitespace(actual.trim())).toEqual(stripLeadingWhitespace(expected.trim())); + } + + function stripLeadingWhitespace(s: string) { + return s.replace(/^\s*/gm, ''); + } +}); diff --git a/tools/markdown-to-html/docs-marked-renderer.spec.ts b/tools/markdown-to-html/docs-marked-renderer.spec.ts deleted file mode 100644 index 69b6f68fcfe9..000000000000 --- a/tools/markdown-to-html/docs-marked-renderer.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -import {DocsMarkdownRenderer} from './docs-marked-renderer'; - -describe('DocsMarkdownRenderer', () => { - let renderer: DocsMarkdownRenderer; - beforeEach(() => { - renderer = new DocsMarkdownRenderer(); - }); - - it('generates regular headings for h1 and h2', () => { - expect(renderer.heading('a', 1, 'ignored')).toBe('

    a

    '); - expect(renderer.heading('b', 2, 'ignored')).toContain('

    { - const heading3 = renderer.heading('heading text', 3, 'link-id'); - expectEqualIgnoreLeadingWhitespace( - heading3, - ` -

    - `, - ); - const heading4 = renderer.heading('heading text', 4, 'second-link-id'); - expectEqualIgnoreLeadingWhitespace( - heading4, - ` - - `, - ); - }); - - it('handles duplicate ids for headings', () => { - expect(renderer.heading('first', 3, 'id')).toContain('id="id"'); - expect(renderer.heading('second', 3, 'id')).toContain('id="id-1"'); - }); - - it('generates links', () => { - expect(renderer.link('something', 'some title', 'some text')).toEqual( - 'some text', - ); - expect(renderer.link('guide/something', 'some title', 'some text')).toEqual( - 'some text', - ); - expect(renderer.link('#some-hash', 'some title', 'some text')).toEqual( - 'some text', - ); - expect(renderer.link('https://google.com', 'some title', 'some text')).toEqual( - 'some text', - ); - }); - - it('generates html using new API', () => { - const result = renderer.html(``); - expectEqualIgnoreLeadingWhitespace( - result, - `
    `, - ); - }); - - it('generates html using new API with no region', () => { - const result = renderer.html(``); - expectEqualIgnoreLeadingWhitespace( - result, - `
    `, - ); - }); - - it('generates html using new API with no file and no region', () => { - const result = renderer.html(``); - expectEqualIgnoreLeadingWhitespace( - result, - `
    `, - ); - }); - - it('generates html using old API', () => { - expect(renderer.html('')).toEqual( - '
    ', - ); - }); - - it('allows id links with matching id element', () => { - let output = renderer.link('#my-id', 'link title', 'link text'); - output += renderer.heading('heading text', 3, 'my-id'); - const result = renderer.finalizeOutput(output, 'filename.html'); - expect(result).toEqual(jasmine.stringMatching(/
    { - spyOn(console, 'error'); - spyOn(process, 'exit'); - let output = renderer.link('#my-id', 'link title', 'link text'); - renderer.finalizeOutput(output, 'filename.html'); - expect((console.error as jasmine.Spy).calls.allArgs()).toEqual([ - [jasmine.stringMatching(/Could not process file: filename.html.*/)], - [jasmine.stringMatching(/.*Found link to "my-id". This heading does not exist./)], - ]); - expect(process.exit).toHaveBeenCalledWith(1); - }); - - function expectEqualIgnoreLeadingWhitespace(actual: string, expected: string) { - expect(stripLeadingWhitespace(actual)).toEqual(stripLeadingWhitespace(expected)); - } - - function stripLeadingWhitespace(s: string) { - return s.replace(/^\s*/gm, ''); - } -}); diff --git a/tools/markdown-to-html/transform-markdown.mts b/tools/markdown-to-html/transform-markdown.mts new file mode 100644 index 000000000000..aca89ec5b93a --- /dev/null +++ b/tools/markdown-to-html/transform-markdown.mts @@ -0,0 +1,31 @@ +/** + * Script that will be used by the markdown_to_html Bazel rule in order to transform + * multiple markdown files into the equivalent HTML output. + */ + +import {readFileSync, writeFileSync} from 'fs'; +import {marked} from 'marked'; +import {join} from 'path'; +import {DocsMarkdownRenderer} from './docs-marked-renderer.mjs'; + +// Custom markdown renderer for transforming markdown files for the docs. +const markdownRenderer = new DocsMarkdownRenderer(); + +// Setup our custom docs renderer by default. +marked.setOptions({renderer: markdownRenderer}); + +// The script expects the bazel-bin path as first argument. All remaining arguments will be +// considered as markdown input files that need to be transformed. +const [bazelBinPath, ...inputFiles] = process.argv.slice(2); + +// Walk through each input file and write transformed markdown output to the specified +// Bazel bin directory. +inputFiles.forEach(inputPath => { + const outputPath = join(bazelBinPath, `${inputPath}.html`); + const htmlOutput = markdownRenderer.finalizeOutput( + marked.parse(readFileSync(inputPath, 'utf8'), {async: false}), + inputPath, + ); + + writeFileSync(outputPath, htmlOutput); +}); diff --git a/tools/markdown-to-html/transform-markdown.ts b/tools/markdown-to-html/transform-markdown.ts deleted file mode 100644 index 705cd7660419..000000000000 --- a/tools/markdown-to-html/transform-markdown.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Script that will be used by the markdown_to_html Bazel rule in order to transform - * multiple markdown files into the equivalent HTML output. - */ - -import {readFileSync, writeFileSync} from 'fs'; -import marked from 'marked'; -import {join} from 'path'; -import {highlightCodeBlock} from '../highlight-files/highlight-code-block'; -import {DocsMarkdownRenderer} from './docs-marked-renderer'; - -// Custom markdown renderer for transforming markdown files for the docs. -const markdownRenderer = new DocsMarkdownRenderer(); - -// Setup our custom docs renderer by default. -marked.setOptions({renderer: markdownRenderer, highlight: highlightCodeBlock}); - -if (require.main === module) { - // The script expects the bazel-bin path as first argument. All remaining arguments will be - // considered as markdown input files that need to be transformed. - const [bazelBinPath, ...inputFiles] = process.argv.slice(2); - - // Walk through each input file and write transformed markdown output to the specified - // Bazel bin directory. - inputFiles.forEach(inputPath => { - const outputPath = join(bazelBinPath, `${inputPath}.html`); - const htmlOutput = markdownRenderer.finalizeOutput( - marked(readFileSync(inputPath, 'utf8')), - inputPath, - ); - - writeFileSync(outputPath, htmlOutput); - }); -} diff --git a/tools/region-parser/BUILD.bazel b/tools/region-parser/BUILD.bazel index 5af8f96805c5..099901cd88ee 100644 --- a/tools/region-parser/BUILD.bazel +++ b/tools/region-parser/BUILD.bazel @@ -4,6 +4,6 @@ package(default_visibility = ["//visibility:public"]) ts_project( name = "region-parser", - srcs = glob(["**/*.ts"]), + srcs = glob(["**/*.mts"]), tsconfig = "//tools:tsconfig", ) diff --git a/tools/region-parser/README.md b/tools/region-parser/README.md index 39eacc60244e..e8c68b3401aa 100644 --- a/tools/region-parser/README.md +++ b/tools/region-parser/README.md @@ -1,3 +1,3 @@ The implementation of `region-parser` is copied from the `angular/angular` repo. It has been adapted from JavaScript to Typescript along with a few minor changes. -This is part of an ongoing effort to share more components between [angular.io] and [material.angular.io]. +This is part of an ongoing effort to share more components between [angular.io] and [material.angular.dev]. diff --git a/tools/region-parser/region-matchers/block-c.ts b/tools/region-parser/region-matchers/block-c.mts similarity index 100% rename from tools/region-parser/region-matchers/block-c.ts rename to tools/region-parser/region-matchers/block-c.mts diff --git a/tools/region-parser/region-matchers/html.ts b/tools/region-parser/region-matchers/html.mts similarity index 100% rename from tools/region-parser/region-matchers/html.ts rename to tools/region-parser/region-matchers/html.mts diff --git a/tools/region-parser/region-matchers/inline-c.ts b/tools/region-parser/region-matchers/inline-c.mts similarity index 100% rename from tools/region-parser/region-matchers/inline-c.ts rename to tools/region-parser/region-matchers/inline-c.mts diff --git a/tools/region-parser/region-parser.ts b/tools/region-parser/region-parser.mts similarity index 96% rename from tools/region-parser/region-parser.ts rename to tools/region-parser/region-parser.mts index 4e3afd1d6021..b184911b2031 100644 --- a/tools/region-parser/region-parser.ts +++ b/tools/region-parser/region-parser.mts @@ -1,6 +1,6 @@ -import {blockC} from './region-matchers/block-c'; -import {html} from './region-matchers/html'; -import {inlineC} from './region-matchers/inline-c'; +import {blockC} from './region-matchers/block-c.mjs'; +import {html} from './region-matchers/html.mjs'; +import {inlineC} from './region-matchers/inline-c.mjs'; export type Region = {lines: string[]; open: boolean}; export type RegionMap = {[regionName: string]: Region}; diff --git a/tools/release-checks/npm-package-output/check-package.mts b/tools/release-checks/npm-package-output/check-package.mts index d8b8bf0381c6..22d5fb03c413 100644 --- a/tools/release-checks/npm-package-output/check-package.mts +++ b/tools/release-checks/npm-package-output/check-package.mts @@ -1,6 +1,6 @@ import {Log, bold, yellow} from '@angular/ng-dev'; import {existsSync} from 'fs'; -import glob from 'glob'; +import {sync as globSync} from 'glob'; import {basename, dirname, join} from 'path'; import { @@ -47,9 +47,9 @@ export function checkReleasePackage( failures.set(message, filePaths); }; - const jsFiles = glob.sync(releaseJsFilesGlob, {cwd: packagePath, absolute: true}); - const typeDefinitions = glob.sync(releaseTypeDefinitionsGlob, {cwd: packagePath, absolute: true}); - const packageJsonFiles = glob.sync(packageJsonFilesGlob, {cwd: packagePath, absolute: true}); + const jsFiles = globSync(releaseJsFilesGlob, {cwd: packagePath, absolute: true}); + const typeDefinitions = globSync(releaseTypeDefinitionsGlob, {cwd: packagePath, absolute: true}); + const packageJsonFiles = globSync(packageJsonFilesGlob, {cwd: packagePath, absolute: true}); // We want to walk through each bundle within the current package and run // release validations that ensure that the bundles are not invalid. diff --git a/tools/release-checks/npm-package-output/output-validations.mts b/tools/release-checks/npm-package-output/output-validations.mts index 0ebc068a1f86..09f987e3f03c 100644 --- a/tools/release-checks/npm-package-output/output-validations.mts +++ b/tools/release-checks/npm-package-output/output-validations.mts @@ -1,5 +1,5 @@ import {existsSync, readFileSync} from 'fs'; -import glob from 'glob'; +import {sync as globSync} from 'glob'; import {basename, dirname, isAbsolute, join} from 'path'; import semver from 'semver'; @@ -146,7 +146,7 @@ export function checkMaterialPackage(packagePath: string): string[] { const newThemingFilePath = join(packagePath, '_index.scss'); const failures: string[] = []; - if (glob.sync('*.css', {cwd: prebuiltThemesPath}).length === 0) { + if (globSync('*.css', {cwd: prebuiltThemesPath}).length === 0) { failures.push('No prebuilt themes could be found.'); } @@ -161,7 +161,7 @@ export function checkMaterialPackage(packagePath: string): string[] { * Checks whether the prebuilt CDK files are part of the release output. */ export function checkCdkPackage(packagePath: string): string[] { - const prebuiltFiles = glob.sync('*-prebuilt.css', {cwd: packagePath}).map(path => basename(path)); + const prebuiltFiles = globSync('*-prebuilt.css', {cwd: packagePath}).map(path => basename(path)); const newApiFilePath = join(packagePath, '_index.scss'); const failures = ['overlay', 'a11y', 'text-field'] .filter(name => !prebuiltFiles.includes(`${name}-prebuilt.css`)) diff --git a/tools/stylelint/no-prefixes/index.ts b/tools/stylelint/no-prefixes/index.ts index d746235561cd..ab166f8cab9b 100644 --- a/tools/stylelint/no-prefixes/index.ts +++ b/tools/stylelint/no-prefixes/index.ts @@ -1,5 +1,5 @@ import {createPlugin, Rule, utils} from 'stylelint'; -import minimatch from 'minimatch'; +import {minimatch} from 'minimatch'; import {NeedsPrefix} from './needs-prefix'; const parseSelector = require('stylelint/lib/utils/parseSelector'); diff --git a/tools/tsconfig.json b/tools/tsconfig.json index 905f67a9f954..810d8d3a6e3f 100644 --- a/tools/tsconfig.json +++ b/tools/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["./**/*.ts"], + "include": ["./**/*.mts", "./**/*.ts"], "compilerOptions": { "target": "es2020", "module": "Node16", @@ -12,5 +12,5 @@ "downlevelIteration": true, "types": ["node"] }, - "exclude": ["**/*.spec.ts", "adev-api-extraction/**"] + "exclude": ["**/*.spec.ts", "**/*.spec.mts", "adev-api-extraction/**"] } diff --git a/tools/tslint-rules/lightweightTokensRule.ts b/tools/tslint-rules/lightweightTokensRule.ts index dfad5089ed54..463ed9df8842 100644 --- a/tools/tslint-rules/lightweightTokensRule.ts +++ b/tools/tslint-rules/lightweightTokensRule.ts @@ -1,5 +1,5 @@ import ts from 'typescript'; -import minimatch from 'minimatch'; +import {minimatch} from 'minimatch'; import * as Lint from 'tslint'; /** Arguments this rule supports. */ diff --git a/tools/tslint-rules/noCrossEntryPointRelativeImportsRule.ts b/tools/tslint-rules/noCrossEntryPointRelativeImportsRule.ts index 46c0197262d7..93cc33a89e99 100644 --- a/tools/tslint-rules/noCrossEntryPointRelativeImportsRule.ts +++ b/tools/tslint-rules/noCrossEntryPointRelativeImportsRule.ts @@ -1,5 +1,5 @@ import ts from 'typescript'; -import minimatch from 'minimatch'; +import {minimatch} from 'minimatch'; import {existsSync} from 'fs'; import {dirname, join, normalize, resolve} from 'path'; diff --git a/tools/tslint-rules/noLifecycleInvocationRule.ts b/tools/tslint-rules/noLifecycleInvocationRule.ts index d0a7de6f5a80..904bdc960531 100644 --- a/tools/tslint-rules/noLifecycleInvocationRule.ts +++ b/tools/tslint-rules/noLifecycleInvocationRule.ts @@ -1,6 +1,6 @@ import * as Lint from 'tslint'; import ts from 'typescript'; -import minimatch from 'minimatch'; +import {minimatch} from 'minimatch'; const hooks = new Set([ 'ngOnChanges', diff --git a/tools/tslint-rules/noZoneDependenciesRule.ts b/tools/tslint-rules/noZoneDependenciesRule.ts index 8fd1b3743820..5129e06230be 100644 --- a/tools/tslint-rules/noZoneDependenciesRule.ts +++ b/tools/tslint-rules/noZoneDependenciesRule.ts @@ -1,4 +1,4 @@ -import minimatch from 'minimatch'; +import {minimatch} from 'minimatch'; import * as Lint from 'tslint'; import ts from 'typescript'; diff --git a/tools/tslint-rules/requireLicenseBannerRule.ts b/tools/tslint-rules/requireLicenseBannerRule.ts index 736c530a9e28..422aa582dd5b 100644 --- a/tools/tslint-rules/requireLicenseBannerRule.ts +++ b/tools/tslint-rules/requireLicenseBannerRule.ts @@ -1,5 +1,5 @@ import * as Lint from 'tslint'; -import minimatch from 'minimatch'; +import {minimatch} from 'minimatch'; import ts from 'typescript'; /** License banner that is placed at the top of every public TypeScript file. */ diff --git a/tools/tslint-rules/validateDecoratorsRule.ts b/tools/tslint-rules/validateDecoratorsRule.ts index 7d399cafe219..c1feac1cb5c3 100644 --- a/tools/tslint-rules/validateDecoratorsRule.ts +++ b/tools/tslint-rules/validateDecoratorsRule.ts @@ -1,4 +1,4 @@ -import minimatch from 'minimatch'; +import {minimatch} from 'minimatch'; import * as Lint from 'tslint'; import ts from 'typescript'; diff --git a/tsconfig.json b/tsconfig.json index fc54afa77aa8..84b08be1dcab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -53,6 +53,7 @@ "e2e/**/*.ts", "test/**/*.ts", "tools/**/*.ts", + "tools/**/*.mts", "integration/**/*.ts", "scripts/**/*.ts", "docs/**/*.ts" diff --git a/tslint.json b/tslint.json index a4b39f1a8481..b388ac9f6de9 100644 --- a/tslint.json +++ b/tslint.json @@ -40,6 +40,7 @@ {"name": ["Object", "assign"], "message": "Use the spread operator instead."}, {"name": ["*", "asObservable"], "message": "Cast to Observable type instead."}, {"name": ["*", "removeChild"], "message": "Use `remove` instead instead."}, + {"name": ["*", "compileComponents"], "message": "`compileComponents` is not necessary."}, { "name": ["isDevMode"], "message": "Use `typeof ngDevMode === 'undefined' || ngDevMode` instead" @@ -95,20 +96,26 @@ "argument": 0, "properties": { "!preserveWhitespaces": ".*", - "!styles": ".*", "!moduleId": ".*", + "!standalone": ".*" + } + }, + // Don't require `ViewEncapsulation.None` and allow inline styles in non-production code. + { + "argument": 0, + "properties": { + "!styles": ".*", "encapsulation": "\\.None$" }, - "excludeFiles": ["**/dev-app/**", "**/docs/**"] + "excludeFiles": ["**/dev-app/**", "**/docs/**", "**/*.spec.ts"] }, // Enforce OnPush & standalone even in the dev-app. { "argument": 0, "properties": { - "changeDetection": "\\.OnPush$", - "!standalone": ".*" + "changeDetection": "\\.OnPush$" }, - "excludeFiles": ["**/docs/**"] + "excludeFiles": ["**/docs/**", "**/*.spec.ts"] } ], "Directive": [ @@ -142,8 +149,7 @@ }, [ // Internal code that doesn't need to follow the same standards. - "**/+(e2e-app|components-examples|universal-app|integration)/**", - "**/*.spec.ts" + "**/+(e2e-app|components-examples|universal-app|integration)/**" ] ], "require-license-banner": [