From b78ca016b6f937798f6ef8516d27efdbada7f7b7 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 9 Aug 2022 10:45:23 +0200 Subject: [PATCH] feat(material/slider): switch implementation to use MDC Switches the slider module to use MDC by default. BREAKING CHANGE: * `mat-slider` has a new API that requires a `` element. --- .github/CODEOWNERS | 6 +- .ng-dev/commit-message.mts | 2 +- .../material/progress-bar/BUILD.bazel | 2 +- .../material/progress-bar/index.ts | 4 +- .../material/progress-spinner/BUILD.bazel | 2 +- .../material/progress-spinner/index.ts | 4 +- .../material/slider/BUILD.bazel | 8 +- .../material/slider/index.ts | 4 +- .../slider-harness-example.spec.ts | 16 +- src/dev-app/mdc-slider/BUILD.bazel | 2 +- src/dev-app/mdc-slider/mdc-slider-demo.ts | 2 +- src/dev-app/slider/BUILD.bazel | 2 +- src/dev-app/slider/slider-demo.ts | 4 +- src/e2e-app/BUILD.bazel | 2 +- .../mdc-slider/mdc-slider-e2e-module.ts | 2 +- src/material-experimental/_index.scss | 2 - src/material-experimental/config.bzl | 2 - .../mdc-core/theming/BUILD.bazel | 2 +- .../mdc-core/theming/_all-theme.scss | 3 +- .../mdc-slider/BUILD.bazel | 107 - .../mdc-slider/README.md | 70 - .../mdc-slider/_slider-theme.import.scss | 2 - .../mdc-slider/_slider-theme.scss | 124 - .../mdc-slider/slider.html | 22 - .../mdc-slider/slider.scss | 48 - .../mdc-slider/slider.spec.ts | 2044 ------------- .../mdc-slider/slider.ts | 1327 --------- .../testing/slider-harness-filters.ts | 26 - .../mdc-slider/testing/slider-harness.spec.ts | 203 -- .../mdc-slider/testing/slider-harness.ts | 85 - src/material/_index.scss | 4 +- src/material/_theming.scss | 2 +- src/material/config.bzl | 2 + .../core/density/private/_all-density.scss | 2 + src/material/core/theming/_all-theme.scss | 2 +- .../tests/test-css-variables-theme.scss | 2 - .../core/typography/_all-typography.scss | 2 +- .../legacy-core/theming/_all-theme.scss | 2 + .../typography/_all-typography.scss | 2 + src/material/legacy-slider/BUILD.bazel | 81 + src/material/legacy-slider/README.md | 1 + .../legacy-slider/_slider-legacy-index.scss | 3 + .../legacy-slider/_slider-theme.import.scss | 8 + src/material/legacy-slider/_slider-theme.scss | 204 ++ .../legacy-slider}/index.ts | 0 .../legacy-slider}/public-api.ts | 4 +- .../slider-module.ts | 8 +- src/material/legacy-slider/slider.html | 16 + src/material/legacy-slider/slider.md | 98 + src/material/legacy-slider/slider.scss | 495 +++ src/material/legacy-slider/slider.spec.ts | 1852 ++++++++++++ src/material/legacy-slider/slider.ts | 1022 +++++++ .../legacy-slider}/testing/BUILD.bazel | 18 +- .../legacy-slider}/testing/index.ts | 0 .../legacy-slider}/testing/public-api.ts | 1 - .../testing/slider-harness-filters.ts | 11 + .../testing/slider-harness.spec.ts | 191 ++ .../legacy-slider/testing/slider-harness.ts | 135 + src/material/slider/BUILD.bazel | 73 +- src/material/slider/README.md | 2 +- src/material/slider/_slider-legacy-index.scss | 2 - src/material/slider/_slider-theme.import.scss | 8 +- src/material/slider/_slider-theme.scss | 249 +- .../global-change-and-input-listener.ts | 0 .../mdc-slider => material/slider}/module.ts | 0 src/material/slider/public-api.ts | 4 +- .../slider}/slider-thumb.html | 0 .../slider}/slider-thumb.scss | 0 .../slider}/slider.e2e.spec.ts | 0 src/material/slider/slider.html | 32 +- src/material/slider/slider.scss | 509 +--- src/material/slider/slider.spec.ts | 2652 +++++++++-------- src/material/slider/slider.ts | 1877 +++++++----- src/material/slider/testing/BUILD.bazel | 21 +- src/material/slider/testing/public-api.ts | 1 + .../slider/testing/slider-harness-filters.ts | 17 +- .../slider/testing/slider-harness.spec.ts | 256 +- src/material/slider/testing/slider-harness.ts | 156 +- .../slider}/testing/slider-thumb-harness.ts | 0 .../kitchen-sink-mdc/kitchen-sink-mdc.ts | 2 +- .../kitchen-sink/kitchen-sink.ts | 4 +- .../material/legacy-slider-testing.md | 35 + .../material/legacy-slider.md | 126 + .../material/slider-testing.md | 41 +- tools/public_api_guard/material/slider.md | 196 +- 85 files changed, 7429 insertions(+), 7131 deletions(-) delete mode 100644 src/material-experimental/mdc-slider/BUILD.bazel delete mode 100644 src/material-experimental/mdc-slider/README.md delete mode 100644 src/material-experimental/mdc-slider/_slider-theme.import.scss delete mode 100644 src/material-experimental/mdc-slider/_slider-theme.scss delete mode 100644 src/material-experimental/mdc-slider/slider.html delete mode 100644 src/material-experimental/mdc-slider/slider.scss delete mode 100644 src/material-experimental/mdc-slider/slider.spec.ts delete mode 100644 src/material-experimental/mdc-slider/slider.ts delete mode 100644 src/material-experimental/mdc-slider/testing/slider-harness-filters.ts delete mode 100644 src/material-experimental/mdc-slider/testing/slider-harness.spec.ts delete mode 100644 src/material-experimental/mdc-slider/testing/slider-harness.ts create mode 100644 src/material/legacy-slider/BUILD.bazel create mode 100644 src/material/legacy-slider/README.md create mode 100644 src/material/legacy-slider/_slider-legacy-index.scss create mode 100644 src/material/legacy-slider/_slider-theme.import.scss create mode 100644 src/material/legacy-slider/_slider-theme.scss rename src/{material-experimental/mdc-slider => material/legacy-slider}/index.ts (100%) rename src/{material-experimental/mdc-slider => material/legacy-slider}/public-api.ts (64%) rename src/material/{slider => legacy-slider}/slider-module.ts (71%) create mode 100644 src/material/legacy-slider/slider.html create mode 100644 src/material/legacy-slider/slider.md create mode 100644 src/material/legacy-slider/slider.scss create mode 100644 src/material/legacy-slider/slider.spec.ts create mode 100644 src/material/legacy-slider/slider.ts rename src/{material-experimental/mdc-slider => material/legacy-slider}/testing/BUILD.bazel (66%) rename src/{material-experimental/mdc-slider => material/legacy-slider}/testing/index.ts (100%) rename src/{material-experimental/mdc-slider => material/legacy-slider}/testing/public-api.ts (87%) create mode 100644 src/material/legacy-slider/testing/slider-harness-filters.ts create mode 100644 src/material/legacy-slider/testing/slider-harness.spec.ts create mode 100644 src/material/legacy-slider/testing/slider-harness.ts delete mode 100644 src/material/slider/_slider-legacy-index.scss rename src/{material-experimental/mdc-slider => material/slider}/global-change-and-input-listener.ts (100%) rename src/{material-experimental/mdc-slider => material/slider}/module.ts (100%) rename src/{material-experimental/mdc-slider => material/slider}/slider-thumb.html (100%) rename src/{material-experimental/mdc-slider => material/slider}/slider-thumb.scss (100%) rename src/{material-experimental/mdc-slider => material/slider}/slider.e2e.spec.ts (100%) rename src/{material-experimental/mdc-slider => material/slider}/testing/slider-thumb-harness.ts (100%) create mode 100644 tools/public_api_guard/material/legacy-slider-testing.md create mode 100644 tools/public_api_guard/material/legacy-slider.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fb46bfaec67d..69320f84f84c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -29,7 +29,7 @@ /src/material/select/** @crisbeto /src/material/sidenav/** @mmalerba /src/material/legacy-slide-toggle/** @devversion -/src/material/slider/** @mmalerba +/src/material/legacy-slider/** @mmalerba /src/material/snack-bar/** @andrewseguin @crisbeto /src/material/sort/** @andrewseguin /src/material/stepper/** @mmalerba @@ -126,8 +126,8 @@ /src/material/progress-bar/** @andrewseguin /src/material/radio/** @mmalerba /src/material-experimental/mdc-snack-bar/** @andrewseguin -/src/material/slide-toggle/** @crisbeto -/src/material-experimental/mdc-slider/** @devversion +/src/material/slide-toggle/** @crisbeto +/src/material/slider/** @devversion /src/material-experimental/mdc-tabs/** @crisbeto /src/material-experimental/mdc-tooltip/** @crisbeto /src/material-experimental/mdc-table/** @andrewseguin diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index f106fafa0730..768fd9f60953 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -53,7 +53,6 @@ export const commitMessage: CommitMessageConfig = { 'material/progress-bar', 'material-experimental/mdc-progress-spinner', 'material/slide-toggle', - 'material-experimental/mdc-slider', 'material-experimental/mdc-snack-bar', 'material-experimental/mdc-table', 'material-experimental/mdc-tabs', @@ -101,6 +100,7 @@ export const commitMessage: CommitMessageConfig = { 'material/sidenav', 'material/legacy-slide-toggle', 'material/slider', + 'material/legacy-slider', 'material/snack-bar', 'material/sort', 'material/stepper', diff --git a/src/components-examples/material/progress-bar/BUILD.bazel b/src/components-examples/material/progress-bar/BUILD.bazel index eccecb427370..1bf2cac6a749 100644 --- a/src/components-examples/material/progress-bar/BUILD.bazel +++ b/src/components-examples/material/progress-bar/BUILD.bazel @@ -19,7 +19,7 @@ ng_module( "//src/material/legacy-progress-bar", "//src/material/legacy-progress-bar/testing", "//src/material/legacy-radio", - "//src/material/slider", + "//src/material/legacy-slider", "@npm//@angular/forms", "@npm//@angular/platform-browser", "@npm//@angular/platform-browser-dynamic", diff --git a/src/components-examples/material/progress-bar/index.ts b/src/components-examples/material/progress-bar/index.ts index 17283592cdad..31158b5895f9 100644 --- a/src/components-examples/material/progress-bar/index.ts +++ b/src/components-examples/material/progress-bar/index.ts @@ -4,7 +4,7 @@ import {FormsModule} from '@angular/forms'; import {MatLegacyCardModule} from '@angular/material/legacy-card'; import {MatLegacyProgressBarModule} from '@angular/material/legacy-progress-bar'; import {MatLegacyRadioModule} from '@angular/material/legacy-radio'; -import {MatSliderModule} from '@angular/material/slider'; +import {MatLegacySliderModule} from '@angular/material/legacy-slider'; import {ProgressBarBufferExample} from './progress-bar-buffer/progress-bar-buffer-example'; import {ProgressBarConfigurableExample} from './progress-bar-configurable/progress-bar-configurable-example'; import {ProgressBarDeterminateExample} from './progress-bar-determinate/progress-bar-determinate-example'; @@ -36,7 +36,7 @@ const EXAMPLES = [ MatLegacyCardModule, MatLegacyProgressBarModule, MatLegacyRadioModule, - MatSliderModule, + MatLegacySliderModule, FormsModule, ], declarations: EXAMPLES, diff --git a/src/components-examples/material/progress-spinner/BUILD.bazel b/src/components-examples/material/progress-spinner/BUILD.bazel index 0f8607e4f620..0f3532727d82 100644 --- a/src/components-examples/material/progress-spinner/BUILD.bazel +++ b/src/components-examples/material/progress-spinner/BUILD.bazel @@ -17,9 +17,9 @@ ng_module( "//src/cdk/testing/testbed", "//src/material/legacy-card", "//src/material/legacy-radio", + "//src/material/legacy-slider", "//src/material/progress-spinner", "//src/material/progress-spinner/testing", - "//src/material/slider", "@npm//@angular/forms", "@npm//@angular/platform-browser", "@npm//@angular/platform-browser-dynamic", diff --git a/src/components-examples/material/progress-spinner/index.ts b/src/components-examples/material/progress-spinner/index.ts index 36c5d780181d..904e92ac9a3c 100644 --- a/src/components-examples/material/progress-spinner/index.ts +++ b/src/components-examples/material/progress-spinner/index.ts @@ -4,7 +4,7 @@ import {FormsModule} from '@angular/forms'; import {MatLegacyCardModule} from '@angular/material/legacy-card'; import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; import {MatLegacyRadioModule} from '@angular/material/legacy-radio'; -import {MatSliderModule} from '@angular/material/slider'; +import {MatLegacySliderModule} from '@angular/material/legacy-slider'; import {ProgressSpinnerConfigurableExample} from './progress-spinner-configurable/progress-spinner-configurable-example'; import {ProgressSpinnerOverviewExample} from './progress-spinner-overview/progress-spinner-overview-example'; import {ProgressSpinnerHarnessExample} from './progress-spinner-harness/progress-spinner-harness-example'; @@ -27,7 +27,7 @@ const EXAMPLES = [ MatLegacyCardModule, MatProgressSpinnerModule, MatLegacyRadioModule, - MatSliderModule, + MatLegacySliderModule, FormsModule, ], declarations: EXAMPLES, diff --git a/src/components-examples/material/slider/BUILD.bazel b/src/components-examples/material/slider/BUILD.bazel index 5c617df356d2..1018d20b1739 100644 --- a/src/components-examples/material/slider/BUILD.bazel +++ b/src/components-examples/material/slider/BUILD.bazel @@ -18,8 +18,8 @@ ng_module( "//src/material/legacy-card", "//src/material/legacy-checkbox", "//src/material/legacy-input", - "//src/material/slider", - "//src/material/slider/testing", + "//src/material/legacy-slider", + "//src/material/legacy-slider/testing", "@npm//@angular/forms", "@npm//@angular/platform-browser", "@npm//@angular/platform-browser-dynamic", @@ -43,8 +43,8 @@ ng_test_library( ":slider", "//src/cdk/testing", "//src/cdk/testing/testbed", - "//src/material/slider", - "//src/material/slider/testing", + "//src/material/legacy-slider", + "//src/material/legacy-slider/testing", "@npm//@angular/platform-browser-dynamic", ], ) diff --git a/src/components-examples/material/slider/index.ts b/src/components-examples/material/slider/index.ts index 921dbbfe5df2..3f8d0783bb83 100644 --- a/src/components-examples/material/slider/index.ts +++ b/src/components-examples/material/slider/index.ts @@ -4,7 +4,7 @@ import {FormsModule} from '@angular/forms'; import {MatLegacyCardModule} from '@angular/material/legacy-card'; import {MatLegacyCheckboxModule} from '@angular/material/legacy-checkbox'; import {MatLegacyInputModule} from '@angular/material/legacy-input'; -import {MatSliderModule} from '@angular/material/slider'; +import {MatLegacySliderModule} from '@angular/material/legacy-slider'; import {SliderConfigurableExample} from './slider-configurable/slider-configurable-example'; import {SliderFormattingExample} from './slider-formatting/slider-formatting-example'; import {SliderOverviewExample} from './slider-overview/slider-overview-example'; @@ -31,7 +31,7 @@ const EXAMPLES = [ MatLegacyCardModule, MatLegacyCheckboxModule, MatLegacyInputModule, - MatSliderModule, + MatLegacySliderModule, ], declarations: EXAMPLES, exports: EXAMPLES, diff --git a/src/components-examples/material/slider/slider-harness/slider-harness-example.spec.ts b/src/components-examples/material/slider/slider-harness/slider-harness-example.spec.ts index 73ce8581256e..85a669a5d367 100644 --- a/src/components-examples/material/slider/slider-harness/slider-harness-example.spec.ts +++ b/src/components-examples/material/slider/slider-harness/slider-harness-example.spec.ts @@ -1,8 +1,8 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; -import {MatSliderHarness} from '@angular/material/slider/testing'; +import {MatLegacySliderHarness} from '@angular/material/legacy-slider/testing'; import {HarnessLoader} from '@angular/cdk/testing'; -import {MatSliderModule} from '@angular/material/slider'; +import {MatLegacySliderModule} from '@angular/material/legacy-slider'; import {SliderHarnessExample} from './slider-harness-example'; describe('SliderHarnessExample', () => { @@ -11,7 +11,7 @@ describe('SliderHarnessExample', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [MatSliderModule], + imports: [MatLegacySliderModule], declarations: [SliderHarnessExample], }).compileComponents(); fixture = TestBed.createComponent(SliderHarnessExample); @@ -20,27 +20,27 @@ describe('SliderHarnessExample', () => { }); it('should load all slider harnesses', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); expect(sliders.length).toBe(1); }); it('should get value of slider', async () => { - const slider = await loader.getHarness(MatSliderHarness); + const slider = await loader.getHarness(MatLegacySliderHarness); expect(await slider.getValue()).toBe(50); }); it('should get percentage of slider', async () => { - const slider = await loader.getHarness(MatSliderHarness); + const slider = await loader.getHarness(MatLegacySliderHarness); expect(await slider.getPercentage()).toBe(0.5); }); it('should get max value of slider', async () => { - const slider = await loader.getHarness(MatSliderHarness); + const slider = await loader.getHarness(MatLegacySliderHarness); expect(await slider.getMaxValue()).toBe(100); }); it('should be able to set value of slider', async () => { - const slider = await loader.getHarness(MatSliderHarness); + const slider = await loader.getHarness(MatLegacySliderHarness); expect(await slider.getValue()).toBe(50); await slider.setValue(33); diff --git a/src/dev-app/mdc-slider/BUILD.bazel b/src/dev-app/mdc-slider/BUILD.bazel index 1699600bafb6..9b43478b2e90 100644 --- a/src/dev-app/mdc-slider/BUILD.bazel +++ b/src/dev-app/mdc-slider/BUILD.bazel @@ -9,8 +9,8 @@ ng_module( "mdc-slider-demo.html", ], deps = [ - "//src/material-experimental/mdc-slider", "//src/material-experimental/mdc-tabs", + "//src/material/slider", "@npm//@angular/forms", ], ) diff --git a/src/dev-app/mdc-slider/mdc-slider-demo.ts b/src/dev-app/mdc-slider/mdc-slider-demo.ts index e77eb4e638e9..35dd32dfe76b 100644 --- a/src/dev-app/mdc-slider/mdc-slider-demo.ts +++ b/src/dev-app/mdc-slider/mdc-slider-demo.ts @@ -8,7 +8,7 @@ import {Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; -import {MatSliderModule} from '@angular/material-experimental/mdc-slider'; +import {MatSliderModule} from '@angular/material/slider'; import {MatTabsModule} from '@angular/material-experimental/mdc-tabs'; @Component({ diff --git a/src/dev-app/slider/BUILD.bazel b/src/dev-app/slider/BUILD.bazel index 5085da2dd02c..9b14e03a29a5 100644 --- a/src/dev-app/slider/BUILD.bazel +++ b/src/dev-app/slider/BUILD.bazel @@ -7,7 +7,7 @@ ng_module( srcs = glob(["**/*.ts"]), assets = ["slider-demo.html"], deps = [ - "//src/material/slider", + "//src/material/legacy-slider", "//src/material/tabs", "@npm//@angular/forms", ], diff --git a/src/dev-app/slider/slider-demo.ts b/src/dev-app/slider/slider-demo.ts index c09e4a241ffc..0a4e39cd84ee 100644 --- a/src/dev-app/slider/slider-demo.ts +++ b/src/dev-app/slider/slider-demo.ts @@ -8,14 +8,14 @@ import {Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; -import {MatSliderModule} from '@angular/material/slider'; +import {MatLegacySliderModule} from '@angular/material/legacy-slider'; import {MatTabsModule} from '@angular/material/tabs'; @Component({ selector: 'slider-demo', templateUrl: 'slider-demo.html', standalone: true, - imports: [FormsModule, MatSliderModule, MatTabsModule], + imports: [FormsModule, MatLegacySliderModule, MatTabsModule], }) export class SliderDemo { demo: number; diff --git a/src/e2e-app/BUILD.bazel b/src/e2e-app/BUILD.bazel index 260d5467cced..9cc63a580aca 100644 --- a/src/e2e-app/BUILD.bazel +++ b/src/e2e-app/BUILD.bazel @@ -45,7 +45,6 @@ ng_module( "//src/material-experimental/mdc-button", "//src/material-experimental/mdc-menu", "//src/material-experimental/mdc-progress-spinner", - "//src/material-experimental/mdc-slider", "//src/material-experimental/mdc-table", "//src/material-experimental/mdc-tabs", "//src/material/button", @@ -72,6 +71,7 @@ ng_module( "//src/material/select", "//src/material/sidenav", "//src/material/slide-toggle", + "//src/material/slider", "//src/material/tabs", "@npm//@angular/animations", "@npm//@angular/core", diff --git a/src/e2e-app/mdc-slider/mdc-slider-e2e-module.ts b/src/e2e-app/mdc-slider/mdc-slider-e2e-module.ts index c4b7678bada3..675ea9ddd447 100644 --- a/src/e2e-app/mdc-slider/mdc-slider-e2e-module.ts +++ b/src/e2e-app/mdc-slider/mdc-slider-e2e-module.ts @@ -7,7 +7,7 @@ */ import {NgModule} from '@angular/core'; -import {MatSliderModule} from '@angular/material-experimental/mdc-slider'; +import {MatSliderModule} from '@angular/material/slider'; import {MdcSliderE2e} from './mdc-slider-e2e'; @NgModule({ diff --git a/src/material-experimental/_index.scss b/src/material-experimental/_index.scss index 7040effa97c1..da42cb058c1a 100644 --- a/src/material-experimental/_index.scss +++ b/src/material-experimental/_index.scss @@ -32,8 +32,6 @@ @forward './mdc-progress-spinner/progress-spinner-theme' as mdc-progress-spinner-* show mdc-progress-spinner-color, mdc-progress-spinner-typography, mdc-progress-spinner-density, mdc-progress-spinner-theme; -@forward './mdc-slider/slider-theme' as mdc-slider-* show mdc-slider-color, - mdc-slider-typography, mdc-slider-density, mdc-slider-theme; @forward './mdc-snack-bar/snack-bar-theme' as mdc-snack-bar-* show mdc-snack-bar-color, mdc-snack-bar-typography, mdc-snack-bar-density, mdc-snack-bar-theme; @forward './mdc-table/table-theme' as mdc-table-* show mdc-table-color, mdc-table-typography, diff --git a/src/material-experimental/config.bzl b/src/material-experimental/config.bzl index 5a31246403f3..f05245b007e5 100644 --- a/src/material-experimental/config.bzl +++ b/src/material-experimental/config.bzl @@ -11,8 +11,6 @@ entryPoints = [ "mdc-paginator/testing", "mdc-progress-spinner", "mdc-progress-spinner/testing", - "mdc-slider", - "mdc-slider/testing", "mdc-snack-bar", "mdc-snack-bar/testing", "mdc-table", diff --git a/src/material-experimental/mdc-core/theming/BUILD.bazel b/src/material-experimental/mdc-core/theming/BUILD.bazel index 0ae7659f6ef0..e70c803d6378 100644 --- a/src/material-experimental/mdc-core/theming/BUILD.bazel +++ b/src/material-experimental/mdc-core/theming/BUILD.bazel @@ -26,7 +26,6 @@ sass_library( "//src/material-experimental/mdc-menu:mdc_menu_scss_lib", "//src/material-experimental/mdc-paginator:mdc_paginator_scss_lib", "//src/material-experimental/mdc-progress-spinner:mdc_progress_spinner_scss_lib", - "//src/material-experimental/mdc-slider:mdc_slider_scss_lib", "//src/material-experimental/mdc-snack-bar:mdc_snack_bar_scss_lib", "//src/material-experimental/mdc-table:mdc_table_scss_lib", "//src/material-experimental/mdc-tabs:mdc_tabs_scss_lib", @@ -39,6 +38,7 @@ sass_library( "//src/material/input:input_scss_lib", "//src/material/progress-bar:progress_bar_scss_lib", "//src/material/slide-toggle:slide_toggle_scss_lib", + "//src/material/slider:slider_scss_lib", "//src/material/tooltip:tooltip_scss_lib", ], ) diff --git a/src/material-experimental/mdc-core/theming/_all-theme.scss b/src/material-experimental/mdc-core/theming/_all-theme.scss index e06169d71c35..bab2cbe05764 100644 --- a/src/material-experimental/mdc-core/theming/_all-theme.scss +++ b/src/material-experimental/mdc-core/theming/_all-theme.scss @@ -6,7 +6,6 @@ @use '../../mdc-button/icon-button-theme'; @use '../../mdc-list/list-theme'; @use '../../mdc-menu/menu-theme'; -@use '../../mdc-slider/slider-theme'; @use '../../mdc-snack-bar/snack-bar-theme'; @use '../../mdc-tabs/tabs-theme'; @use '../../mdc-table/table-theme'; @@ -33,7 +32,7 @@ @include mat.radio-theme($theme-or-color-config); @include mat.select-theme($theme-or-color-config); @include mat.slide-toggle-theme($theme-or-color-config); - @include slider-theme.theme($theme-or-color-config); + @include mat.slider-theme($theme-or-color-config); @include snack-bar-theme.theme($theme-or-color-config); @include table-theme.theme($theme-or-color-config); @include mat.form-field-theme($theme-or-color-config); diff --git a/src/material-experimental/mdc-slider/BUILD.bazel b/src/material-experimental/mdc-slider/BUILD.bazel deleted file mode 100644 index 61d518b6ed13..000000000000 --- a/src/material-experimental/mdc-slider/BUILD.bazel +++ /dev/null @@ -1,107 +0,0 @@ -load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") -load( - "//tools:defaults.bzl", - "ng_e2e_test_library", - "ng_module", - "ng_test_library", - "ng_web_test_suite", - "sass_binary", - "sass_library", -) - -package(default_visibility = ["//visibility:public"]) - -ng_module( - name = "mdc-slider", - srcs = glob( - ["**/*.ts"], - exclude = ["**/*.spec.ts"], - ), - assets = [ - ":slider_scss", - ":slider_thumb_scss", - ] + glob(["**/*.html"]), - deps = [ - "//src/cdk/bidi", - "//src/cdk/coercion", - "//src/cdk/platform", - "//src/material/core", - "@npm//@angular/forms", - "@npm//@material/base", - "@npm//@material/slider", - ], -) - -sass_library( - name = "mdc_slider_scss_lib", - srcs = glob(["**/_*.scss"]), - deps = [ - "//:mdc_sass_lib", - "//src/material:sass_lib", - "//src/material/core:core_scss_lib", - ], -) - -sass_binary( - name = "slider_scss", - src = "slider.scss", - deps = [ - "//:mdc_sass_lib", - "//src/material:sass_lib", - "//src/material/core:core_scss_lib", - ], -) - -sass_binary( - name = "slider_thumb_scss", - src = "slider-thumb.scss", -) - -########### -# Testing -########### - -ng_test_library( - name = "slider_tests_lib", - srcs = glob( - ["**/*.spec.ts"], - exclude = ["**/*.e2e.spec.ts"], - ), - deps = [ - ":mdc-slider", - "//src/cdk/bidi", - "//src/cdk/keycodes", - "//src/cdk/platform", - "//src/cdk/testing/private", - "//src/material/core", - "@npm//@angular/forms", - "@npm//@angular/platform-browser", - "@npm//@material/slider", - "@npm//rxjs", - ], -) - -ng_web_test_suite( - name = "unit_tests", - deps = [ - ":slider_tests_lib", - ], -) - -ng_e2e_test_library( - name = "e2e_test_sources", - srcs = glob(["**/*.e2e.spec.ts"]), - deps = [ - ":mdc-slider", - "//src/cdk/testing/private/e2e", - "@npm//@material/slider", - ], -) - -e2e_test_suite( - name = "e2e_tests", - deps = [ - ":e2e_test_sources", - "//src/cdk/testing/private/e2e", - ], -) diff --git a/src/material-experimental/mdc-slider/README.md b/src/material-experimental/mdc-slider/README.md deleted file mode 100644 index b0697b2fab57..000000000000 --- a/src/material-experimental/mdc-slider/README.md +++ /dev/null @@ -1,70 +0,0 @@ -This is a prototype of an alternate version of `MatSlider` built on top of -[MDC Web](https://github.com/material-components/material-components-web). This component is experimental and should not be used in production. - -## How to use -Assuming your application is already up and running using Angular Material, you can add this component by following these steps: - -1. Install `@angular/material-experimental` and MDC Web: - - ```bash - npm i material-components-web @angular/material-experimental - ``` - -2. In your `angular.json`, make sure `node_modules/` is listed as a Sass include path. This is - needed for the Sass compiler to be able to find the MDC Web Sass files. - - ```json - ... - "styles": [ - "src/styles.scss" - ], - "stylePreprocessorOptions": { - "includePaths": [ - "node_modules/" - ] - }, - ... - ``` - -3. Import the experimental `MatSliderModule` and add it to the module that declares your component: - - ```ts - import {MatSliderModule} from '@angular/material-experimental/mdc-slider'; - - @NgModule({ - declarations: [MyComponent], - imports: [MatSliderModule], - }) - export class MyModule {} - ``` - -4. Use the slider in your component's template: - - ```html - - - ``` - -5. Add the theme mixins to your Sass: - - ```scss - @use '@angular/material' as mat; - @use '@angular/material-experimental' as mat-experimental; - - $candy-app-primary: mat.define-palette(mat.$indigo-palette); - $candy-app-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400); - $candy-app-theme: mat.define-light-theme(( - color: ( - primary: $candy-app-primary, - accent: $candy-app-accent, - ) - )); - - - @include mat-experimental.mdc-slider-theme($candy-app-theme); - ``` - -## API differences - -The API of the slider matches the one from `@angular/material/slider`. Simply replace imports to -`@angular/material/slider` with imports to `@angular/material-experimental/mdc-slider`. diff --git a/src/material-experimental/mdc-slider/_slider-theme.import.scss b/src/material-experimental/mdc-slider/_slider-theme.import.scss deleted file mode 100644 index dc7a93eca3fc..000000000000 --- a/src/material-experimental/mdc-slider/_slider-theme.import.scss +++ /dev/null @@ -1,2 +0,0 @@ -@forward 'slider-theme' as mat-mdc-slider-*; - diff --git a/src/material-experimental/mdc-slider/_slider-theme.scss b/src/material-experimental/mdc-slider/_slider-theme.scss deleted file mode 100644 index 50854b900f59..000000000000 --- a/src/material-experimental/mdc-slider/_slider-theme.scss +++ /dev/null @@ -1,124 +0,0 @@ -@use 'sass:map'; - -@use '@angular/material' as mat; -@use '@material/slider/slider' as mdc-slider; -@use '@material/slider/slider-theme'; -@use '@material/theme/variables' as theme-variables; - - -@mixin color($config-or-theme) { - $config: mat.get-color-config($config-or-theme); - @include mat.private-using-mdc-theme($config) { - @include mdc-slider.without-ripple($query: mat.$private-mdc-theme-styles-query); - - .mat-mdc-slider { - &.mat-primary, &.mat-accent, &.mat-warn { - $is-dark: map.get($config, is-dark); - $indicator-color: if($is-dark, white, black); - $indicator-text-color: if($is-dark, black, white); - $indicator-opacity: if($is-dark, 0.9, 0.6); - - @include slider-theme.value-indicator-color( - $color: $indicator-color, - $opacity: $indicator-opacity, - $query: mat.$private-mdc-theme-styles-query - ); - @include slider-theme.value-indicator-text-color( - $color: $indicator-text-color, - $query: mat.$private-mdc-theme-styles-query - ); - } - - &.mat-primary { - @include _custom-slider-color(primary, on-primary); - } - - &.mat-accent { - @include _custom-slider-color(secondary, on-secondary); - } - - &.mat-warn { - @include _custom-slider-color(error, on-error); - } - } - } -} - -@mixin typography($config-or-theme) { - $config: mat.private-typography-to-2018-config( - mat.get-typography-config($config-or-theme)); - @include mat.private-using-mdc-typography($config) { - @include mdc-slider.without-ripple($query: mat.$private-mdc-typography-styles-query); - } -} - -@mixin density($config-or-theme) {} - -@mixin theme($theme-or-color-config) { - $theme: mat.private-legacy-get-theme($theme-or-color-config); - @include mat.private-check-duplicate-theme-styles($theme, 'mat-mdc-slider') { - $color: mat.get-color-config($theme); - $density: mat.get-density-config($theme); - $typography: mat.get-typography-config($theme); - - @if $color != null { - @include color($color); - } - @if $density != null { - @include density($density); - } - @if $typography != null { - @include typography($typography); - } - } -} - -@mixin _custom-slider-color($color, $on-color) { - @include slider-theme.thumb-color( - $color-or-map: ( - default: $color, - disabled: on-surface, - ), - $query: mat.$private-mdc-theme-styles-query - ); - @include slider-theme.track-active-color( - $color-or-map: ( - default: $color, - disabled: on-surface, - ), - $query: mat.$private-mdc-theme-styles-query - ); - @include slider-theme.track-inactive-color( - $color-or-map: ( - default: $color, - disabled: on-surface, - ), - $query: mat.$private-mdc-theme-styles-query - ); - @include slider-theme.tick-mark-active-color( - $color-or-map: ( - default: $on-color, - disabled: surface, - ), - $query: mat.$private-mdc-theme-styles-query - ); - @include slider-theme.tick-mark-inactive-color( - $color-or-map: ( - default: $color, - disabled: on-surface, - ), - $query: mat.$private-mdc-theme-styles-query - ); - $ripple-color: map.get(theme-variables.$property-values, $color); - @include mat.ripple-color(( - foreground: ( - base: $ripple-color - ), - )); - .mat-mdc-slider-hover-ripple { - background-color: rgba($ripple-color, 0.05); - } - .mat-mdc-slider-focus-ripple, .mat-mdc-slider-active-ripple { - background-color: rgba($ripple-color, 0.2); - } -} diff --git a/src/material-experimental/mdc-slider/slider.html b/src/material-experimental/mdc-slider/slider.html deleted file mode 100644 index caa46f348407..000000000000 --- a/src/material-experimental/mdc-slider/slider.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - -
-
-
-
-
-
-
-
-
- - - - diff --git a/src/material-experimental/mdc-slider/slider.scss b/src/material-experimental/mdc-slider/slider.scss deleted file mode 100644 index 3f1c0eb4ff95..000000000000 --- a/src/material-experimental/mdc-slider/slider.scss +++ /dev/null @@ -1,48 +0,0 @@ -@use '@angular/material' as mat; -@use '@material/slider/slider' as mdc-slider; - -@include mat.private-disable-mdc-fallback-declarations { - @include mdc-slider.without-ripple($query: mat.$private-mdc-base-styles-query); -} - -$mat-slider-min-size: 128px !default; -$mat-slider-horizontal-margin: 8px !default; - -// Overwrites the mdc-slider default styles to match the visual appearance of the -// Angular Material standard slider. This involves making the slider an inline-block -// element, aligning it in the vertical middle of a line, specifying a default minimum -// width and adding horizontal margin. -.mat-mdc-slider { - display: inline-block; - box-sizing: border-box; - outline: none; - vertical-align: middle; - margin: { - left: $mat-slider-horizontal-margin; - right: $mat-slider-horizontal-margin; - } - - // Unset the default "width" property from the MDC slider class. We don't want - // the slider to automatically expand horizontally for backwards compatibility. - width: auto; - min-width: $mat-slider-min-size - (2 * $mat-slider-horizontal-margin); - - &._mat-animation-noopable { - &.mdc-slider--discrete .mdc-slider__thumb, - &.mdc-slider--discrete .mdc-slider__track--active_fill, - .mdc-slider__value-indicator { - transition: none; - } - } - - // Slider components have to set `border-radius: 50%` in order to support density scaling - // which will clip a square focus indicator so we have to turn it into a circle. - .mat-mdc-focus-indicator::before { - border-radius: 50%; - } -} - -// In the MDC slider the focus indicator is inside the thumb. -.mdc-slider__thumb--focused .mat-mdc-focus-indicator::before { - content: ''; -} diff --git a/src/material-experimental/mdc-slider/slider.spec.ts b/src/material-experimental/mdc-slider/slider.spec.ts deleted file mode 100644 index 0ca3cfc1c05a..000000000000 --- a/src/material-experimental/mdc-slider/slider.spec.ts +++ /dev/null @@ -1,2044 +0,0 @@ -/** - * @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.io/license - */ - -import {BidiModule, Directionality} from '@angular/cdk/bidi'; -import {Platform} from '@angular/cdk/platform'; -import { - dispatchFakeEvent, - dispatchMouseEvent, - dispatchPointerEvent, - dispatchTouchEvent, -} from '../../cdk/testing/private'; -import {Component, Provider, QueryList, Type, ViewChild, ViewChildren} from '@angular/core'; -import {ComponentFixture, fakeAsync, flush, TestBed, waitForAsync} from '@angular/core/testing'; -import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; -import {By} from '@angular/platform-browser'; -import {Thumb} from '@material/slider'; -import {of} from 'rxjs'; -import {MatSliderModule} from './module'; -import {MatSlider, MatSliderThumb, MatSliderVisualThumb} from './slider'; - -interface Point { - x: number; - y: number; -} - -describe('MDC-based MatSlider', () => { - let platform: Platform; - - function createComponent(component: Type, providers: Provider[] = []): ComponentFixture { - TestBed.configureTestingModule({ - imports: [FormsModule, MatSliderModule, ReactiveFormsModule, BidiModule], - declarations: [component], - providers: [...providers], - }).compileComponents(); - - platform = TestBed.inject(Platform); - // Mock #setPointerCapture as it throws errors on pointerdown without a real pointerId. - spyOn(Element.prototype, 'setPointerCapture'); - - return TestBed.createComponent(component); - } - - describe('standard slider', () => { - let fixture: ComponentFixture; - let sliderInstance: MatSlider; - let inputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - fixture = createComponent(StandardSlider); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - inputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should set the default values', () => { - expect(inputInstance.value).toBe(0); - expect(sliderInstance.min).toBe(0); - expect(sliderInstance.max).toBe(100); - expect(inputInstance._hostElement.getAttribute('aria-valuetext')).toBe('0'); - }); - - it('should update the value on mousedown', () => { - setValueByClick(sliderInstance, 19, platform.IOS); - expect(inputInstance.value).toBe(19); - }); - - it('should update the value on a slide', () => { - slideToValue(sliderInstance, 77, Thumb.END, platform.IOS); - expect(inputInstance.value).toBe(77); - }); - - it('should set the value as min when sliding before the track', () => { - slideToValue(sliderInstance, -1, Thumb.END, platform.IOS); - expect(inputInstance.value).toBe(0); - }); - - it('should set the value as max when sliding past the track', () => { - slideToValue(sliderInstance, 101, Thumb.END, platform.IOS); - expect(inputInstance.value).toBe(100); - }); - - it('should focus the slider input when clicking on the slider', () => { - expect(document.activeElement).not.toBe(inputInstance._hostElement); - setValueByClick(sliderInstance, 0, platform.IOS); - expect(document.activeElement).toBe(inputInstance._hostElement); - }); - - it('should not break on when the page layout changes', () => { - sliderInstance._elementRef.nativeElement.style.marginLeft = '100px'; - setValueByClick(sliderInstance, 10, platform.IOS); - expect(inputInstance.value).toBe(10); - sliderInstance._elementRef.nativeElement.style.marginLeft = 'initial'; - }); - - it('should not throw if destroyed before initialization is complete', () => { - fixture.destroy(); - fixture = TestBed.createComponent(StandardSlider); - expect(() => fixture.destroy()).not.toThrow(); - }); - }); - - describe('standard range slider', () => { - let sliderInstance: MatSlider; - let startInputInstance: MatSliderThumb; - let sliderElement: HTMLElement; - let endInputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - const fixture = createComponent(StandardRangeSlider); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - startInputInstance = sliderInstance._getInput(Thumb.START); - sliderElement = sliderDebugElement.nativeElement; - endInputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should set the default values', () => { - expect(startInputInstance.value).toBe(0); - expect(endInputInstance.value).toBe(100); - expect(sliderInstance.min).toBe(0); - expect(sliderInstance.max).toBe(100); - expect(startInputInstance._hostElement.getAttribute('aria-valuetext')).toBe('0'); - expect(endInputInstance._hostElement.getAttribute('aria-valuetext')).toBe('100'); - }); - - it('should update the start value on a slide', () => { - slideToValue(sliderInstance, 19, Thumb.START, platform.IOS); - expect(startInputInstance.value).toBe(19); - }); - - it('should update the end value on a slide', () => { - slideToValue(sliderInstance, 27, Thumb.END, platform.IOS); - expect(endInputInstance.value).toBe(27); - }); - - it('should update the start value on mousedown behind the start thumb', () => { - sliderInstance._setValue(19, Thumb.START); - setValueByClick(sliderInstance, 12, platform.IOS); - expect(startInputInstance.value).toBe(12); - }); - - it('should update the end value on mousedown in front of the end thumb', () => { - sliderInstance._setValue(27, Thumb.END); - setValueByClick(sliderInstance, 55, platform.IOS); - expect(endInputInstance.value).toBe(55); - }); - - it('should set the start value as min when sliding before the track', () => { - slideToValue(sliderInstance, -1, Thumb.START, platform.IOS); - expect(startInputInstance.value).toBe(0); - }); - - it('should set the end value as max when sliding past the track', () => { - slideToValue(sliderInstance, 101, Thumb.START, platform.IOS); - expect(startInputInstance.value).toBe(100); - }); - - it('should not let the start thumb slide past the end thumb', () => { - sliderInstance._setValue(50, Thumb.END); - slideToValue(sliderInstance, 75, Thumb.START, platform.IOS); - expect(startInputInstance.value).toBe(50); - }); - - it('should not let the end thumb slide before the start thumb', () => { - sliderInstance._setValue(50, Thumb.START); - slideToValue(sliderInstance, 25, Thumb.END, platform.IOS); - expect(startInputInstance.value).toBe(50); - }); - - it('should have a strong focus indicator in each of the thumbs', () => { - const indicators = sliderElement.querySelectorAll( - '.mat-mdc-slider-visual-thumb .mat-mdc-focus-indicator', - ); - expect(indicators.length).toBe(2); - }); - }); - - describe('disabled slider', () => { - let sliderInstance: MatSlider; - let inputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - const fixture = createComponent(DisabledSlider); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - inputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should be disabled', () => { - expect(sliderInstance.disabled).toBeTrue(); - }); - - it('should have the disabled class on the root element', () => { - expect(sliderInstance._elementRef.nativeElement.classList).toContain('mdc-slider--disabled'); - }); - - it('should set the disabled attribute on the input element', () => { - expect(inputInstance._hostElement.disabled).toBeTrue(); - }); - - it('should not update the value on mousedown', () => { - setValueByClick(sliderInstance, 19, platform.IOS); - expect(inputInstance.value).toBe(0); - }); - - it('should not update the value on a slide', () => { - slideToValue(sliderInstance, 77, Thumb.END, platform.IOS); - expect(inputInstance.value).toBe(0); - }); - }); - - describe('disabled range slider', () => { - let sliderInstance: MatSlider; - let startInputInstance: MatSliderThumb; - let endInputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - const fixture = createComponent(DisabledRangeSlider); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - startInputInstance = sliderInstance._getInput(Thumb.START); - endInputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should be disabled', () => { - expect(sliderInstance.disabled).toBeTrue(); - }); - - it('should have the disabled class on the root element', () => { - expect(sliderInstance._elementRef.nativeElement.classList).toContain('mdc-slider--disabled'); - }); - - it('should set the disabled attribute on the input elements', () => { - expect(startInputInstance._hostElement.disabled).toBeTrue(); - expect(endInputInstance._hostElement.disabled).toBeTrue(); - }); - - it('should not update the start value on a slide', () => { - slideToValue(sliderInstance, 19, Thumb.START, platform.IOS); - expect(startInputInstance.value).toBe(0); - }); - - it('should not update the end value on a slide', () => { - slideToValue(sliderInstance, 27, Thumb.END, platform.IOS); - expect(endInputInstance.value).toBe(100); - }); - - it('should not update the start value on mousedown behind the start thumb', () => { - sliderInstance._setValue(19, Thumb.START); - setValueByClick(sliderInstance, 12, platform.IOS); - expect(startInputInstance.value).toBe(19); - }); - - it('should update the end value on mousedown in front of the end thumb', () => { - sliderInstance._setValue(27, Thumb.END); - setValueByClick(sliderInstance, 55, platform.IOS); - expect(endInputInstance.value).toBe(27); - }); - }); - - describe('ripple states', () => { - let inputInstance: MatSliderThumb; - let thumbInstance: MatSliderVisualThumb; - let thumbElement: HTMLElement; - let thumbX: number; - let thumbY: number; - - beforeEach(waitForAsync(() => { - const fixture = createComponent(StandardSlider); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - const sliderInstance = sliderDebugElement.componentInstance; - inputInstance = sliderInstance._getInput(Thumb.END); - thumbInstance = sliderInstance._getThumb(Thumb.END); - thumbElement = thumbInstance._getHostElement(); - const thumbDimensions = thumbElement.getBoundingClientRect(); - thumbX = thumbDimensions.left - thumbDimensions.width / 2; - thumbY = thumbDimensions.top - thumbDimensions.height / 2; - })); - - function isRippleVisible(selector: string) { - flushRippleTransitions(); - return thumbElement.querySelector(`.mat-mdc-slider-${selector}-ripple`) !== null; - } - - function flushRippleTransitions() { - thumbElement.querySelectorAll('.mat-ripple-element').forEach(el => { - dispatchFakeEvent(el, 'transitionend'); - }); - } - - function blur() { - inputInstance._hostElement.blur(); - } - - function mouseenter() { - dispatchMouseEvent(thumbElement, 'mouseenter', thumbX, thumbY); - } - - function mouseleave() { - dispatchMouseEvent(thumbElement, 'mouseleave', thumbX, thumbY); - } - - function pointerdown() { - dispatchPointerOrTouchEvent( - thumbElement, - PointerEventType.POINTER_DOWN, - thumbX, - thumbY, - platform.IOS, - ); - } - - function pointerup() { - dispatchPointerOrTouchEvent( - thumbElement, - PointerEventType.POINTER_UP, - thumbX, - thumbY, - platform.IOS, - ); - } - - it('should show the hover ripple on mouseenter', fakeAsync(() => { - expect(isRippleVisible('hover')).toBeFalse(); - mouseenter(); - expect(isRippleVisible('hover')).toBeTrue(); - })); - - it('should hide the hover ripple on mouseleave', fakeAsync(() => { - mouseenter(); - mouseleave(); - expect(isRippleVisible('hover')).toBeFalse(); - })); - - it('should show the focus ripple on pointerdown', fakeAsync(() => { - expect(isRippleVisible('focus')).toBeFalse(); - pointerdown(); - expect(isRippleVisible('focus')).toBeTrue(); - })); - - it('should continue to show the focus ripple on pointerup', fakeAsync(() => { - pointerdown(); - pointerup(); - expect(isRippleVisible('focus')).toBeTrue(); - })); - - it('should hide the focus ripple on blur', fakeAsync(() => { - pointerdown(); - pointerup(); - blur(); - expect(isRippleVisible('focus')).toBeFalse(); - })); - - it('should show the active ripple on pointerdown', fakeAsync(() => { - expect(isRippleVisible('active')).toBeFalse(); - pointerdown(); - expect(isRippleVisible('active')).toBeTrue(); - })); - - it('should hide the active ripple on pointerup', fakeAsync(() => { - pointerdown(); - pointerup(); - expect(isRippleVisible('active')).toBeFalse(); - })); - - // Edge cases. - - it('should not show the hover ripple if the thumb is already focused', fakeAsync(() => { - pointerdown(); - mouseenter(); - expect(isRippleVisible('hover')).toBeFalse(); - })); - - it('should hide the hover ripple if the thumb is focused', fakeAsync(() => { - mouseenter(); - pointerdown(); - expect(isRippleVisible('hover')).toBeFalse(); - })); - - it('should not hide the focus ripple if the thumb is pressed', fakeAsync(() => { - pointerdown(); - blur(); - expect(isRippleVisible('focus')).toBeTrue(); - })); - - it('should not hide the hover ripple on blur if the thumb is hovered', fakeAsync(() => { - mouseenter(); - pointerdown(); - pointerup(); - blur(); - expect(isRippleVisible('hover')).toBeTrue(); - })); - - it('should hide the focus ripple on drag end if the thumb already lost focus', fakeAsync(() => { - pointerdown(); - blur(); - pointerup(); - expect(isRippleVisible('focus')).toBeFalse(); - })); - }); - - describe('slider with set min and max', () => { - let fixture: ComponentFixture; - let sliderInstance: MatSlider; - let inputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - fixture = createComponent(SliderWithMinAndMax); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - inputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should set the default values from the attributes', () => { - expect(inputInstance.value).toBe(25); - expect(sliderInstance.min).toBe(25); - expect(sliderInstance.max).toBe(75); - }); - - it('should set the correct value on mousedown', () => { - setValueByClick(sliderInstance, 33, platform.IOS); - expect(inputInstance.value).toBe(33); - }); - - it('should set the correct value on slide', () => { - slideToValue(sliderInstance, 55, Thumb.END, platform.IOS); - expect(inputInstance.value).toBe(55); - }); - - it( - 'should be able to set the min and max values when they are more precise ' + 'than the step', - () => { - sliderInstance.step = 10; - slideToValue(sliderInstance, 25, Thumb.END, platform.IOS); - expect(inputInstance.value).toBe(25); - slideToValue(sliderInstance, 75, Thumb.END, platform.IOS); - expect(inputInstance.value).toBe(75); - }, - ); - }); - - describe('range slider with set min and max', () => { - let fixture: ComponentFixture; - let sliderInstance: MatSlider; - let startInputInstance: MatSliderThumb; - let endInputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - fixture = createComponent(RangeSliderWithMinAndMax); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - startInputInstance = sliderInstance._getInput(Thumb.START); - endInputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should set the default values from the attributes', () => { - expect(startInputInstance.value).toBe(25); - expect(endInputInstance.value).toBe(75); - expect(sliderInstance.min).toBe(25); - expect(sliderInstance.max).toBe(75); - }); - - it('should set the correct start value on mousedown behind the start thumb', () => { - sliderInstance._setValue(50, Thumb.START); - setValueByClick(sliderInstance, 33, platform.IOS); - expect(startInputInstance.value).toBe(33); - }); - - it('should set the correct end value on mousedown behind the end thumb', () => { - sliderInstance._setValue(50, Thumb.END); - setValueByClick(sliderInstance, 66, platform.IOS); - expect(endInputInstance.value).toBe(66); - }); - - it('should set the correct start value on slide', () => { - slideToValue(sliderInstance, 40, Thumb.START, platform.IOS); - expect(startInputInstance.value).toBe(40); - }); - - it('should set the correct end value on slide', () => { - slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); - expect(endInputInstance.value).toBe(60); - }); - - it( - 'should be able to set the min and max values when they are more precise ' + 'than the step', - () => { - sliderInstance.step = 10; - fixture.detectChanges(); - slideToValue(sliderInstance, 25, Thumb.START, platform.IOS); - expect(startInputInstance.value).toBe(25); - slideToValue(sliderInstance, 75, Thumb.END, platform.IOS); - expect(endInputInstance.value).toBe(75); - }, - ); - }); - - describe('slider with set value', () => { - let sliderInstance: MatSlider; - let inputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - const fixture = createComponent(SliderWithValue); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - inputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should set the default value from the attribute', () => { - expect(inputInstance.value).toBe(50); - }); - - it('should set the correct value on mousedown', () => { - setValueByClick(sliderInstance, 19, platform.IOS); - expect(inputInstance.value).toBe(19); - }); - - it('should set the correct value on slide', () => { - slideToValue(sliderInstance, 77, Thumb.END, platform.IOS); - expect(inputInstance.value).toBe(77); - }); - }); - - describe('range slider with set value', () => { - let sliderInstance: MatSlider; - let startInputInstance: MatSliderThumb; - let endInputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - const fixture = createComponent(RangeSliderWithValue); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - startInputInstance = sliderInstance._getInput(Thumb.START); - endInputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should set the default value from the attribute', () => { - expect(startInputInstance.value).toBe(25); - expect(endInputInstance.value).toBe(75); - }); - - it('should set the correct start value on mousedown behind the start thumb', () => { - setValueByClick(sliderInstance, 19, platform.IOS); - expect(startInputInstance.value).toBe(19); - }); - - it('should set the correct start value on mousedown in front of the end thumb', () => { - setValueByClick(sliderInstance, 77, platform.IOS); - expect(endInputInstance.value).toBe(77); - }); - - it('should set the correct start value on slide', () => { - slideToValue(sliderInstance, 73, Thumb.START, platform.IOS); - expect(startInputInstance.value).toBe(73); - }); - - it('should set the correct end value on slide', () => { - slideToValue(sliderInstance, 99, Thumb.END, platform.IOS); - expect(endInputInstance.value).toBe(99); - }); - }); - - describe('slider with set step', () => { - let fixture: ComponentFixture; - let sliderInstance: MatSlider; - let inputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - fixture = createComponent(SliderWithStep); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - inputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should set the correct step value on mousedown', () => { - expect(inputInstance.value).toBe(0); - setValueByClick(sliderInstance, 13, platform.IOS); - expect(inputInstance.value).toBe(25); - }); - - it('should set the correct step value on slide', () => { - slideToValue(sliderInstance, 12, Thumb.END, platform.IOS); - expect(inputInstance.value).toBe(0); - }); - - it('should not add decimals to the value if it is a whole number', () => { - sliderInstance.step = 0.1; - slideToValue(sliderInstance, 100, Thumb.END, platform.IOS); - expect(inputInstance.value).toBe(100); - }); - - it('should truncate long decimal values when using a decimal step', () => { - sliderInstance.step = 0.5; - slideToValue(sliderInstance, 55.555, Thumb.END, platform.IOS); - expect(inputInstance.value).toBe(55.5); - }); - }); - - describe('range slider with set step', () => { - let fixture: ComponentFixture; - let sliderInstance: MatSlider; - let startInputInstance: MatSliderThumb; - let endInputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - fixture = createComponent(RangeSliderWithStep); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - startInputInstance = sliderInstance._getInput(Thumb.START); - endInputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should set the correct step value on mousedown behind the start thumb', () => { - sliderInstance._setValue(50, Thumb.START); - setValueByClick(sliderInstance, 13, platform.IOS); - expect(startInputInstance.value).toBe(25); - }); - - it('should set the correct step value on mousedown in front of the end thumb', () => { - sliderInstance._setValue(50, Thumb.END); - setValueByClick(sliderInstance, 63, platform.IOS); - expect(endInputInstance.value).toBe(75); - }); - - it('should set the correct start thumb step value on slide', () => { - slideToValue(sliderInstance, 26, Thumb.START, platform.IOS); - expect(startInputInstance.value).toBe(25); - }); - - it('should set the correct end thumb step value on slide', () => { - slideToValue(sliderInstance, 45, Thumb.END, platform.IOS); - expect(endInputInstance.value).toBe(50); - }); - - it('should not add decimals to the end value if it is a whole number', () => { - sliderInstance.step = 0.1; - slideToValue(sliderInstance, 100, Thumb.END, platform.IOS); - expect(endInputInstance.value).toBe(100); - }); - - it('should not add decimals to the start value if it is a whole number', () => { - sliderInstance.step = 0.1; - slideToValue(sliderInstance, 100, Thumb.END, platform.IOS); - expect(endInputInstance.value).toBe(100); - }); - - it('should truncate long decimal start values when using a decimal step', () => { - sliderInstance.step = 0.1; - slideToValue(sliderInstance, 33.7, Thumb.START, platform.IOS); - expect(startInputInstance.value).toBe(33.7); - }); - - it('should truncate long decimal end values when using a decimal step', () => { - sliderInstance.step = 0.1; - slideToValue(sliderInstance, 33.7, Thumb.END, platform.IOS); - expect(endInputInstance.value).toBe(33.7); - - // NOTE(wagnermaciel): Different browsers treat the clientX dispatched by us differently. - // Below is an example of a case that should work but because Firefox rounds the clientX - // down, the clientX that gets dispatched (1695.998...) is not the same clientX that the MDC - // Foundation receives (1695). This means the test will pass on chromium but fail on Firefox. - // - // slideToValue(sliderInstance, 66.66, Thumb.END, platform.IOS); - // expect(endInputInstance.value).toBe(66.7); - }); - }); - - describe('slider with custom thumb label formatting', () => { - let fixture: ComponentFixture; - let sliderInstance: MatSlider; - let inputInstance: MatSliderThumb; - let valueIndicatorTextElement: Element; - - beforeEach(() => { - fixture = createComponent(DiscreteSliderWithDisplayWith); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - const sliderNativeElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.componentInstance; - valueIndicatorTextElement = sliderNativeElement.querySelector( - '.mdc-slider__value-indicator-text', - )!; - inputInstance = sliderInstance._getInput(Thumb.END); - }); - - it('should set the aria-valuetext attribute with the given `displayWith` function', () => { - expect(inputInstance._hostElement.getAttribute('aria-valuetext')).toBe('$1'); - sliderInstance._setValue(10000, Thumb.END); - expect(inputInstance._hostElement.getAttribute('aria-valuetext')).toBe('$10k'); - }); - - it('should invoke the passed-in `displayWith` function with the value', () => { - spyOn(sliderInstance, 'displayWith').and.callThrough(); - sliderInstance._setValue(1337, Thumb.END); - expect(sliderInstance.displayWith).toHaveBeenCalledWith(1337); - }); - - it('should format the thumb label based on the passed-in `displayWith` function', () => { - sliderInstance._setValue(200000, Thumb.END); - fixture.detectChanges(); - expect(valueIndicatorTextElement.textContent).toBe('$200k'); - }); - }); - - describe('range slider with custom thumb label formatting', () => { - let fixture: ComponentFixture; - let sliderInstance: MatSlider; - let startValueIndicatorTextElement: Element; - let endValueIndicatorTextElement: Element; - let startInputInstance: MatSliderThumb; - let endInputInstance: MatSliderThumb; - - beforeEach(() => { - fixture = createComponent(DiscreteRangeSliderWithDisplayWith); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderInstance = sliderDebugElement.componentInstance; - startInputInstance = sliderInstance._getInput(Thumb.START); - endInputInstance = sliderInstance._getInput(Thumb.END); - - const startThumbElement = sliderInstance._getThumbElement(Thumb.START); - const endThumbElement = sliderInstance._getThumbElement(Thumb.END); - startValueIndicatorTextElement = startThumbElement.querySelector( - '.mdc-slider__value-indicator-text', - )!; - endValueIndicatorTextElement = endThumbElement.querySelector( - '.mdc-slider__value-indicator-text', - )!; - }); - - it('should set the aria-valuetext attribute with the given `displayWith` function', () => { - expect(startInputInstance._hostElement.getAttribute('aria-valuetext')).toBe('$1'); - expect(endInputInstance._hostElement.getAttribute('aria-valuetext')).toBe('$1000k'); - sliderInstance._setValue(250000, Thumb.START); - sliderInstance._setValue(810000, Thumb.END); - expect(startInputInstance._hostElement.getAttribute('aria-valuetext')).toBe('$250k'); - expect(endInputInstance._hostElement.getAttribute('aria-valuetext')).toBe('$810k'); - }); - - it('should invoke the passed-in `displayWith` function with the start value', () => { - spyOn(sliderInstance, 'displayWith').and.callThrough(); - sliderInstance._setValue(1337, Thumb.START); - expect(sliderInstance.displayWith).toHaveBeenCalledWith(1337); - }); - - it('should invoke the passed-in `displayWith` function with the end value', () => { - spyOn(sliderInstance, 'displayWith').and.callThrough(); - sliderInstance._setValue(5996, Thumb.END); - expect(sliderInstance.displayWith).toHaveBeenCalledWith(5996); - }); - - it('should format the start thumb label based on the passed-in `displayWith` function', () => { - sliderInstance._setValue(200000, Thumb.START); - fixture.detectChanges(); - expect(startValueIndicatorTextElement.textContent).toBe('$200k'); - }); - - it('should format the end thumb label based on the passed-in `displayWith` function', () => { - sliderInstance._setValue(700000, Thumb.END); - fixture.detectChanges(); - expect(endValueIndicatorTextElement.textContent).toBe('$700k'); - }); - }); - - describe('slider with value property binding', () => { - let fixture: ComponentFixture; - let testComponent: SliderWithOneWayBinding; - let inputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - fixture = createComponent(SliderWithOneWayBinding); - fixture.detectChanges(); - testComponent = fixture.debugElement.componentInstance; - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - const sliderInstance = sliderDebugElement.componentInstance; - inputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should update when bound value changes', () => { - testComponent.value = 75; - fixture.detectChanges(); - expect(inputInstance.value).toBe(75); - }); - }); - - describe('range slider with value property binding', () => { - let fixture: ComponentFixture; - let testComponent: RangeSliderWithOneWayBinding; - let startInputInstance: MatSliderThumb; - let endInputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - fixture = createComponent(RangeSliderWithOneWayBinding); - fixture.detectChanges(); - testComponent = fixture.debugElement.componentInstance; - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - const sliderInstance = sliderDebugElement.componentInstance; - startInputInstance = sliderInstance._getInput(Thumb.START); - endInputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should update when bound start value changes', () => { - testComponent.startValue = 30; - fixture.detectChanges(); - expect(startInputInstance.value).toBe(30); - }); - - it('should update when bound end value changes', () => { - testComponent.endValue = 70; - fixture.detectChanges(); - expect(endInputInstance.value).toBe(70); - }); - }); - - describe('slider with change handler', () => { - let sliderInstance: MatSlider; - let inputInstance: MatSliderThumb; - let sliderElement: HTMLElement; - let fixture: ComponentFixture; - let testComponent: SliderWithChangeHandler; - - beforeEach(waitForAsync(() => { - fixture = createComponent(SliderWithChangeHandler); - fixture.detectChanges(); - testComponent = fixture.debugElement.componentInstance; - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.componentInstance; - inputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should emit change on mouseup', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); - setValueByClick(sliderInstance, 20, platform.IOS); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - }); - - it('should emit change on slide', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); - slideToValue(sliderInstance, 40, Thumb.END, platform.IOS); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - }); - - it('should not emit multiple changes for the same value', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); - - setValueByClick(sliderInstance, 60, platform.IOS); - slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); - setValueByClick(sliderInstance, 60, platform.IOS); - slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); - - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - }); - - it( - 'should dispatch events when changing back to previously emitted value after ' + - 'programmatically setting value', - () => { - const dispatchSliderEvent = (type: PointerEventType, value: number) => { - const {x, y} = getCoordsForValue(sliderInstance, value); - dispatchPointerOrTouchEvent(sliderElement, type, x, y, platform.IOS); - }; - - expect(testComponent.onChange).not.toHaveBeenCalled(); - expect(testComponent.onInput).not.toHaveBeenCalled(); - - dispatchSliderEvent(PointerEventType.POINTER_DOWN, 20); - fixture.detectChanges(); - - expect(testComponent.onChange).not.toHaveBeenCalled(); - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - - dispatchSliderEvent(PointerEventType.POINTER_UP, 20); - fixture.detectChanges(); - - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - - inputInstance.value = 0; - fixture.detectChanges(); - - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - - dispatchSliderEvent(PointerEventType.POINTER_DOWN, 20); - fixture.detectChanges(); - dispatchSliderEvent(PointerEventType.POINTER_UP, 20); - - expect(testComponent.onChange).toHaveBeenCalledTimes(2); - expect(testComponent.onInput).toHaveBeenCalledTimes(2); - }, - ); - }); - - describe('range slider with change handlers', () => { - let sliderInstance: MatSlider; - let startInputInstance: MatSliderThumb; - let endInputInstance: MatSliderThumb; - let sliderElement: HTMLElement; - let fixture: ComponentFixture; - let testComponent: RangeSliderWithChangeHandler; - - beforeEach(waitForAsync(() => { - fixture = createComponent(RangeSliderWithChangeHandler); - fixture.detectChanges(); - testComponent = fixture.debugElement.componentInstance; - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.componentInstance; - startInputInstance = sliderInstance._getInput(Thumb.START); - endInputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should emit change on mouseup on the start thumb', () => { - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - setValueByClick(sliderInstance, 20, platform.IOS); - expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - }); - - it('should emit change on mouseup on the end thumb', () => { - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - setValueByClick(sliderInstance, 80, platform.IOS); - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); - }); - - it('should emit change on start thumb slide', () => { - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - slideToValue(sliderInstance, 40, Thumb.START, platform.IOS); - expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - }); - - it('should emit change on end thumb slide', () => { - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); - }); - - it('should not emit multiple changes for the same start thumb value', () => { - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - - setValueByClick(sliderInstance, 30, platform.IOS); - slideToValue(sliderInstance, 30, Thumb.START, platform.IOS); - setValueByClick(sliderInstance, 30, platform.IOS); - slideToValue(sliderInstance, 30, Thumb.START, platform.IOS); - - expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - }); - - it('should not emit multiple changes for the same end thumb value', () => { - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - - setValueByClick(sliderInstance, 60, platform.IOS); - slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); - setValueByClick(sliderInstance, 60, platform.IOS); - slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); - - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); - }); - - it( - 'should dispatch events when changing back to previously emitted value after ' + - 'programmatically setting the start value', - () => { - const dispatchSliderEvent = (type: PointerEventType, value: number) => { - const {x, y} = getCoordsForValue(sliderInstance, value); - dispatchPointerOrTouchEvent(sliderElement, type, x, y, platform.IOS); - }; - - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - - dispatchSliderEvent(PointerEventType.POINTER_DOWN, 20); - fixture.detectChanges(); - - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(1); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - - dispatchSliderEvent(PointerEventType.POINTER_UP, 20); - fixture.detectChanges(); - - expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); - expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(1); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - - startInputInstance.value = 0; - fixture.detectChanges(); - - expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); - expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(1); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - - dispatchSliderEvent(PointerEventType.POINTER_DOWN, 20); - fixture.detectChanges(); - dispatchSliderEvent(PointerEventType.POINTER_UP, 20); - - expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(2); - expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(2); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - }, - ); - - it( - 'should dispatch events when changing back to previously emitted value after ' + - 'programmatically setting the end value', - () => { - const dispatchSliderEvent = (type: PointerEventType, value: number) => { - const {x, y} = getCoordsForValue(sliderInstance, value); - dispatchPointerOrTouchEvent(sliderElement, type, x, y, platform.IOS); - }; - - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - - dispatchSliderEvent(PointerEventType.POINTER_DOWN, 80); - fixture.detectChanges(); - - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(1); - - dispatchSliderEvent(PointerEventType.POINTER_UP, 80); - fixture.detectChanges(); - - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); - expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(1); - - endInputInstance.value = 100; - fixture.detectChanges(); - - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); - expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(1); - - dispatchSliderEvent(PointerEventType.POINTER_DOWN, 80); - fixture.detectChanges(); - dispatchSliderEvent(PointerEventType.POINTER_UP, 80); - - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(2); - expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(2); - }, - ); - }); - - describe('slider with input event', () => { - let sliderInstance: MatSlider; - let sliderElement: HTMLElement; - let testComponent: SliderWithChangeHandler; - - beforeEach(waitForAsync(() => { - const fixture = createComponent(SliderWithChangeHandler); - fixture.detectChanges(); - - testComponent = fixture.debugElement.componentInstance; - - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - sliderElement = sliderInstance._elementRef.nativeElement; - })); - - it('should emit an input event while sliding', () => { - const dispatchSliderEvent = (type: PointerEventType, value: number) => { - const {x, y} = getCoordsForValue(sliderInstance, value); - dispatchPointerOrTouchEvent(sliderElement, type, x, y, platform.IOS); - }; - - expect(testComponent.onChange).not.toHaveBeenCalled(); - expect(testComponent.onInput).not.toHaveBeenCalled(); - - // pointer down on current value (should not trigger input event) - dispatchSliderEvent(PointerEventType.POINTER_DOWN, 0); - - // value changes (should trigger input) - dispatchSliderEvent(PointerEventType.POINTER_MOVE, 10); - dispatchSliderEvent(PointerEventType.POINTER_MOVE, 25); - - // a new value has been committed (should trigger change event) - dispatchSliderEvent(PointerEventType.POINTER_UP, 25); - - expect(testComponent.onInput).toHaveBeenCalledTimes(2); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - }); - - it('should emit an input event when clicking', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); - expect(testComponent.onInput).not.toHaveBeenCalled(); - setValueByClick(sliderInstance, 75, platform.IOS); - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - }); - }); - - describe('range slider with input event', () => { - let sliderInstance: MatSlider; - let sliderElement: HTMLElement; - let testComponent: RangeSliderWithChangeHandler; - - beforeEach(waitForAsync(() => { - const fixture = createComponent(RangeSliderWithChangeHandler); - fixture.detectChanges(); - - testComponent = fixture.debugElement.componentInstance; - - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - sliderElement = sliderInstance._elementRef.nativeElement; - })); - - it('should emit an input event while sliding the start thumb', () => { - const dispatchSliderEvent = (type: PointerEventType, value: number) => { - const {x, y} = getCoordsForValue(sliderInstance, value); - dispatchPointerOrTouchEvent(sliderElement, type, x, y, platform.IOS); - }; - - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - - // pointer down on current start thumb value (should not trigger input event) - dispatchSliderEvent(PointerEventType.POINTER_DOWN, 0); - - // value changes (should trigger input) - dispatchSliderEvent(PointerEventType.POINTER_MOVE, 10); - dispatchSliderEvent(PointerEventType.POINTER_MOVE, 25); - - // a new value has been committed (should trigger change event) - dispatchSliderEvent(PointerEventType.POINTER_UP, 25); - - expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); - expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(2); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - }); - - it('should emit an input event while sliding the end thumb', () => { - const dispatchSliderEvent = (type: PointerEventType, value: number) => { - const {x, y} = getCoordsForValue(sliderInstance, value); - dispatchPointerOrTouchEvent(sliderElement, type, x, y, platform.IOS); - }; - - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - - // pointer down on current end thumb value (should not trigger input event) - dispatchSliderEvent(PointerEventType.POINTER_DOWN, 100); - - // value changes (should trigger input) - dispatchSliderEvent(PointerEventType.POINTER_MOVE, 90); - dispatchSliderEvent(PointerEventType.POINTER_MOVE, 55); - - // a new value has been committed (should trigger change event) - dispatchSliderEvent(PointerEventType.POINTER_UP, 55); - - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); - expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(2); - }); - - it('should emit an input event on the start thumb when clicking near it', () => { - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - - setValueByClick(sliderInstance, 30, platform.IOS); - - expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); - expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(1); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - }); - - it('should emit an input event on the end thumb when clicking near it', () => { - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - - setValueByClick(sliderInstance, 55, platform.IOS); - - expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); - expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); - expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(1); - }); - }); - - describe('slider with direction', () => { - let sliderInstance: MatSlider; - let inputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - const fixture = createComponent(StandardSlider, [ - { - provide: Directionality, - useValue: {value: 'rtl', change: of()}, - }, - ]); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - inputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('works in RTL languages', () => { - setValueByClick(sliderInstance, 30, platform.IOS); - expect(inputInstance.value).toBe(70); - }); - }); - - describe('range slider with direction', () => { - let sliderInstance: MatSlider; - let startInputInstance: MatSliderThumb; - let endInputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - const fixture = createComponent(StandardRangeSlider, [ - { - provide: Directionality, - useValue: {value: 'rtl', change: of()}, - }, - ]); - fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - startInputInstance = sliderInstance._getInput(Thumb.START); - endInputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('works in RTL languages', () => { - setValueByClick(sliderInstance, 90, platform.IOS); - expect(startInputInstance.value).toBe(10); - - setValueByClick(sliderInstance, 10, platform.IOS); - expect(endInputInstance.value).toBe(90); - }); - }); - - describe('slider with ngModel', () => { - let fixture: ComponentFixture; - let testComponent: SliderWithNgModel; - let inputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - fixture = createComponent(SliderWithNgModel); - fixture.detectChanges(); - testComponent = fixture.debugElement.componentInstance; - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - const sliderInstance = sliderDebugElement.componentInstance; - inputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should update the model on mouseup', () => { - expect(testComponent.val).toBe(0); - setValueByClick(testComponent.slider, 76, platform.IOS); - fixture.detectChanges(); - expect(testComponent.val).toBe(76); - }); - - it('should update the model on slide', () => { - expect(testComponent.val).toBe(0); - slideToValue(testComponent.slider, 19, Thumb.END, platform.IOS); - fixture.detectChanges(); - expect(testComponent.val).toBe(19); - }); - - it('should be able to reset a slider by setting the model back to undefined', fakeAsync(() => { - expect(inputInstance.value).toBe(0); - testComponent.val = 5; - fixture.detectChanges(); - flush(); - expect(inputInstance.value).toBe(5); - - testComponent.val = undefined; - fixture.detectChanges(); - flush(); - expect(inputInstance.value).toBe(0); - })); - }); - - describe('slider with ngModel', () => { - let fixture: ComponentFixture; - let testComponent: RangeSliderWithNgModel; - - let startInputInstance: MatSliderThumb; - let endInputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - fixture = createComponent(RangeSliderWithNgModel); - fixture.detectChanges(); - testComponent = fixture.debugElement.componentInstance; - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - const sliderInstance = sliderDebugElement.componentInstance; - startInputInstance = sliderInstance._getInput(Thumb.START); - endInputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should update the start thumb model on mouseup', () => { - expect(testComponent.startVal).toBe(0); - setValueByClick(testComponent.slider, 25, platform.IOS); - fixture.detectChanges(); - expect(testComponent.startVal).toBe(25); - }); - - it('should update the end thumb model on mouseup', () => { - expect(testComponent.endVal).toBe(100); - setValueByClick(testComponent.slider, 75, platform.IOS); - fixture.detectChanges(); - expect(testComponent.endVal).toBe(75); - }); - - it('should update the start thumb model on slide', () => { - expect(testComponent.startVal).toBe(0); - slideToValue(testComponent.slider, 19, Thumb.START, platform.IOS); - fixture.detectChanges(); - expect(testComponent.startVal).toBe(19); - }); - - it('should update the end thumb model on slide', () => { - expect(testComponent.endVal).toBe(100); - slideToValue(testComponent.slider, 19, Thumb.END, platform.IOS); - fixture.detectChanges(); - expect(testComponent.endVal).toBe(19); - }); - - it('should be able to reset a slider by setting the start thumb model back to undefined', fakeAsync(() => { - expect(startInputInstance.value).toBe(0); - testComponent.startVal = 5; - fixture.detectChanges(); - flush(); - expect(startInputInstance.value).toBe(5); - - testComponent.startVal = undefined; - fixture.detectChanges(); - flush(); - expect(startInputInstance.value).toBe(0); - })); - - it('should be able to reset a slider by setting the end thumb model back to undefined', fakeAsync(() => { - expect(endInputInstance.value).toBe(100); - testComponent.endVal = 5; - fixture.detectChanges(); - flush(); - expect(endInputInstance.value).toBe(5); - - testComponent.endVal = undefined; - fixture.detectChanges(); - flush(); - expect(endInputInstance.value).toBe(0); - })); - }); - - describe('slider as a custom form control', () => { - let fixture: ComponentFixture; - let testComponent: SliderWithFormControl; - let sliderInstance: MatSlider; - let inputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - fixture = createComponent(SliderWithFormControl); - fixture.detectChanges(); - testComponent = fixture.debugElement.componentInstance; - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - inputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should not update the control when the value is updated', () => { - expect(testComponent.control.value).toBe(0); - inputInstance.value = 11; - fixture.detectChanges(); - expect(testComponent.control.value).toBe(0); - }); - - it('should update the control on mouseup', () => { - expect(testComponent.control.value).toBe(0); - setValueByClick(sliderInstance, 76, platform.IOS); - expect(testComponent.control.value).toBe(76); - }); - - it('should update the control on slide', () => { - expect(testComponent.control.value).toBe(0); - slideToValue(sliderInstance, 19, Thumb.END, platform.IOS); - expect(testComponent.control.value).toBe(19); - }); - - it('should update the value when the control is set', () => { - expect(inputInstance.value).toBe(0); - testComponent.control.setValue(7); - expect(inputInstance.value).toBe(7); - }); - - it('should update the disabled state when control is disabled', () => { - expect(sliderInstance.disabled).toBe(false); - testComponent.control.disable(); - expect(sliderInstance.disabled).toBe(true); - }); - - it('should update the disabled state when the control is enabled', () => { - sliderInstance.disabled = true; - testComponent.control.enable(); - expect(sliderInstance.disabled).toBe(false); - }); - - it('should have the correct control state initially and after interaction', () => { - let sliderControl = testComponent.control; - - // The control should start off valid, pristine, and untouched. - expect(sliderControl.valid).toBe(true); - expect(sliderControl.pristine).toBe(true); - expect(sliderControl.touched).toBe(false); - - // After changing the value, the control should become dirty (not pristine), - // but remain untouched. - setValueByClick(sliderInstance, 50, platform.IOS); - - expect(sliderControl.valid).toBe(true); - expect(sliderControl.pristine).toBe(false); - expect(sliderControl.touched).toBe(false); - - // If the control has been visited due to interaction, the control should remain - // dirty and now also be touched. - inputInstance.blur(); - fixture.detectChanges(); - - expect(sliderControl.valid).toBe(true); - expect(sliderControl.pristine).toBe(false); - expect(sliderControl.touched).toBe(true); - }); - }); - - describe('slider as a custom form control', () => { - let fixture: ComponentFixture; - let testComponent: RangeSliderWithFormControl; - let sliderInstance: MatSlider; - let startInputInstance: MatSliderThumb; - let endInputInstance: MatSliderThumb; - - beforeEach(waitForAsync(() => { - fixture = createComponent(RangeSliderWithFormControl); - fixture.detectChanges(); - testComponent = fixture.debugElement.componentInstance; - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderInstance = sliderDebugElement.componentInstance; - startInputInstance = sliderInstance._getInput(Thumb.START); - endInputInstance = sliderInstance._getInput(Thumb.END); - })); - - it('should not update the start input control when the value is updated', () => { - expect(testComponent.startInputControl.value).toBe(0); - startInputInstance.value = 11; - fixture.detectChanges(); - expect(testComponent.startInputControl.value).toBe(0); - }); - - it('should not update the end input control when the value is updated', () => { - expect(testComponent.endInputControl.value).toBe(100); - endInputInstance.value = 11; - fixture.detectChanges(); - expect(testComponent.endInputControl.value).toBe(100); - }); - - it('should update the start input control on mouseup', () => { - expect(testComponent.startInputControl.value).toBe(0); - setValueByClick(sliderInstance, 20, platform.IOS); - expect(testComponent.startInputControl.value).toBe(20); - }); - - it('should update the end input control on mouseup', () => { - expect(testComponent.endInputControl.value).toBe(100); - setValueByClick(sliderInstance, 80, platform.IOS); - expect(testComponent.endInputControl.value).toBe(80); - }); - - it('should update the start input control on slide', () => { - expect(testComponent.startInputControl.value).toBe(0); - slideToValue(sliderInstance, 20, Thumb.START, platform.IOS); - expect(testComponent.startInputControl.value).toBe(20); - }); - - it('should update the end input control on slide', () => { - expect(testComponent.endInputControl.value).toBe(100); - slideToValue(sliderInstance, 80, Thumb.END, platform.IOS); - expect(testComponent.endInputControl.value).toBe(80); - }); - - it('should update the start input value when the start input control is set', () => { - expect(startInputInstance.value).toBe(0); - testComponent.startInputControl.setValue(10); - expect(startInputInstance.value).toBe(10); - }); - - it('should update the end input value when the end input control is set', () => { - expect(endInputInstance.value).toBe(100); - testComponent.endInputControl.setValue(90); - expect(endInputInstance.value).toBe(90); - }); - - it('should update the disabled state if the start input control is disabled', () => { - expect(sliderInstance.disabled).toBe(false); - testComponent.startInputControl.disable(); - expect(sliderInstance.disabled).toBe(true); - }); - - it('should update the disabled state if the end input control is disabled', () => { - expect(sliderInstance.disabled).toBe(false); - testComponent.endInputControl.disable(); - expect(sliderInstance.disabled).toBe(true); - }); - - it('should update the disabled state when both input controls are enabled', () => { - sliderInstance.disabled = true; - testComponent.startInputControl.enable(); - expect(sliderInstance.disabled).toBe(true); - testComponent.endInputControl.enable(); - expect(sliderInstance.disabled).toBe(false); - }); - - it('should have the correct start input control state initially and after interaction', () => { - let sliderControl = testComponent.startInputControl; - - // The control should start off valid, pristine, and untouched. - expect(sliderControl.valid).toBe(true); - expect(sliderControl.pristine).toBe(true); - expect(sliderControl.touched).toBe(false); - - // After changing the value, the control should become dirty (not pristine), - // but remain untouched. - setValueByClick(sliderInstance, 25, platform.IOS); - - expect(sliderControl.valid).toBe(true); - expect(sliderControl.pristine).toBe(false); - expect(sliderControl.touched).toBe(false); - - // If the control has been visited due to interaction, the control should remain - // dirty and now also be touched. - startInputInstance.blur(); - fixture.detectChanges(); - - expect(sliderControl.valid).toBe(true); - expect(sliderControl.pristine).toBe(false); - expect(sliderControl.touched).toBe(true); - }); - - it('should have the correct start input control state initially and after interaction', () => { - let sliderControl = testComponent.endInputControl; - - // The control should start off valid, pristine, and untouched. - expect(sliderControl.valid).toBe(true); - expect(sliderControl.pristine).toBe(true); - expect(sliderControl.touched).toBe(false); - - // After changing the value, the control should become dirty (not pristine), - // but remain untouched. - setValueByClick(sliderInstance, 75, platform.IOS); - - expect(sliderControl.valid).toBe(true); - expect(sliderControl.pristine).toBe(false); - expect(sliderControl.touched).toBe(false); - - // If the control has been visited due to interaction, the control should remain - // dirty and now also be touched. - endInputInstance.blur(); - fixture.detectChanges(); - - expect(sliderControl.valid).toBe(true); - expect(sliderControl.pristine).toBe(false); - expect(sliderControl.touched).toBe(true); - }); - }); - - describe('slider with a two-way binding', () => { - let fixture: ComponentFixture; - let testComponent: SliderWithTwoWayBinding; - - beforeEach(() => { - fixture = createComponent(SliderWithTwoWayBinding); - fixture.detectChanges(); - testComponent = fixture.componentInstance; - }); - - it('should sync the value binding in both directions', () => { - expect(testComponent.value).toBe(0); - expect(testComponent.sliderInput.value).toBe(0); - - slideToValue(testComponent.slider, 10, Thumb.END, platform.IOS); - expect(testComponent.value).toBe(10); - expect(testComponent.sliderInput.value).toBe(10); - - testComponent.value = 20; - fixture.detectChanges(); - expect(testComponent.value).toBe(20); - expect(testComponent.sliderInput.value).toBe(20); - }); - }); - - describe('range slider with a two-way binding', () => { - let fixture: ComponentFixture; - let testComponent: RangeSliderWithTwoWayBinding; - - beforeEach(waitForAsync(() => { - fixture = createComponent(RangeSliderWithTwoWayBinding); - fixture.detectChanges(); - testComponent = fixture.componentInstance; - })); - - it('should sync the start value binding in both directions', () => { - expect(testComponent.startValue).toBe(0); - expect(testComponent.sliderInputs.get(0)!.value).toBe(0); - - slideToValue(testComponent.slider, 10, Thumb.START, platform.IOS); - - expect(testComponent.startValue).toBe(10); - expect(testComponent.sliderInputs.get(0)!.value).toBe(10); - - testComponent.startValue = 20; - fixture.detectChanges(); - expect(testComponent.startValue).toBe(20); - expect(testComponent.sliderInputs.get(0)!.value).toBe(20); - }); - - it('should sync the end value binding in both directions', () => { - expect(testComponent.endValue).toBe(100); - expect(testComponent.sliderInputs.get(1)!.value).toBe(100); - - slideToValue(testComponent.slider, 90, Thumb.END, platform.IOS); - expect(testComponent.endValue).toBe(90); - expect(testComponent.sliderInputs.get(1)!.value).toBe(90); - - testComponent.endValue = 80; - fixture.detectChanges(); - expect(testComponent.endValue).toBe(80); - expect(testComponent.sliderInputs.get(1)!.value).toBe(80); - }); - }); -}); - -const SLIDER_STYLES = ['.mat-mdc-slider { width: 300px; }']; - -@Component({ - template: ` - - - - `, - styles: SLIDER_STYLES, -}) -class StandardSlider {} - -@Component({ - template: ` - - - - - `, - styles: SLIDER_STYLES, -}) -class StandardRangeSlider {} - -@Component({ - template: ` - - - - `, - styles: SLIDER_STYLES, -}) -class DisabledSlider {} - -@Component({ - template: ` - - - - - `, - styles: SLIDER_STYLES, -}) -class DisabledRangeSlider {} - -@Component({ - template: ` - - - - `, - styles: SLIDER_STYLES, -}) -class SliderWithMinAndMax {} - -@Component({ - template: ` - - - - - `, - styles: SLIDER_STYLES, -}) -class RangeSliderWithMinAndMax {} - -@Component({ - template: ` - - - - `, - styles: SLIDER_STYLES, -}) -class SliderWithValue {} - -@Component({ - template: ` - - - - - `, - styles: SLIDER_STYLES, -}) -class RangeSliderWithValue {} - -@Component({ - template: ` - - - - `, - styles: SLIDER_STYLES, -}) -class SliderWithStep {} - -@Component({ - template: ` - - - - - `, - styles: SLIDER_STYLES, -}) -class RangeSliderWithStep {} - -@Component({ - template: ` - - - - `, - styles: SLIDER_STYLES, -}) -class DiscreteSliderWithDisplayWith { - displayWith(v: number) { - if (v >= 1000) { - return `$${v / 1000}k`; - } - return `$${v}`; - } -} - -@Component({ - template: ` - - - - - `, - styles: SLIDER_STYLES, -}) -class DiscreteRangeSliderWithDisplayWith { - displayWith(v: number) { - if (v >= 1000) { - return `$${v / 1000}k`; - } - return `$${v}`; - } -} - -@Component({ - template: ` - - - - `, - styles: SLIDER_STYLES, -}) -class SliderWithOneWayBinding { - value = 50; -} - -@Component({ - template: ` - - - - - `, - styles: SLIDER_STYLES, -}) -class RangeSliderWithOneWayBinding { - startValue = 25; - endValue = 75; -} - -@Component({ - template: ` - - - - `, - styles: SLIDER_STYLES, -}) -class SliderWithChangeHandler { - onChange = jasmine.createSpy('onChange'); - onInput = jasmine.createSpy('onChange'); - @ViewChild(MatSlider) slider: MatSlider; -} - -@Component({ - template: ` - - - - - `, - styles: SLIDER_STYLES, -}) -class RangeSliderWithChangeHandler { - onStartThumbChange = jasmine.createSpy('onStartThumbChange'); - onStartThumbInput = jasmine.createSpy('onStartThumbInput'); - onEndThumbChange = jasmine.createSpy('onEndThumbChange'); - onEndThumbInput = jasmine.createSpy('onEndThumbInput'); - @ViewChild(MatSlider) slider: MatSlider; -} - -@Component({ - template: ` - - - - `, - styles: SLIDER_STYLES, -}) -class SliderWithNgModel { - @ViewChild(MatSlider) slider: MatSlider; - val: number | undefined = 0; -} - -@Component({ - template: ` - - - - - `, - styles: SLIDER_STYLES, -}) -class RangeSliderWithNgModel { - @ViewChild(MatSlider) slider: MatSlider; - startVal: number | undefined = 0; - endVal: number | undefined = 100; -} - -@Component({ - template: ` - - - `, - styles: SLIDER_STYLES, -}) -class SliderWithFormControl { - control = new FormControl(0); -} - -@Component({ - template: ` - - - - `, - styles: SLIDER_STYLES, -}) -class RangeSliderWithFormControl { - startInputControl = new FormControl(0); - endInputControl = new FormControl(100); -} - -@Component({ - template: ` - - - - `, - styles: SLIDER_STYLES, -}) -class SliderWithTwoWayBinding { - @ViewChild(MatSlider) slider: MatSlider; - @ViewChild(MatSliderThumb) sliderInput: MatSliderThumb; - value = 0; -} - -@Component({ - template: ` - - - - - `, - styles: SLIDER_STYLES, -}) -class RangeSliderWithTwoWayBinding { - @ViewChild(MatSlider) slider: MatSlider; - @ViewChildren(MatSliderThumb) sliderInputs: QueryList; - startValue = 0; - endValue = 100; -} - -/** The pointer event types used by the MDC Slider. */ -const enum PointerEventType { - POINTER_DOWN = 'pointerdown', - POINTER_UP = 'pointerup', - POINTER_MOVE = 'pointermove', -} - -/** The touch event types used by the MDC Slider. */ -const enum TouchEventType { - TOUCH_START = 'touchstart', - TOUCH_END = 'touchend', - TOUCH_MOVE = 'touchmove', -} - -/** Clicks on the MatSlider at the coordinates corresponding to the given value. */ -function setValueByClick(slider: MatSlider, value: number, isIOS: boolean) { - const sliderElement = slider._elementRef.nativeElement; - const {x, y} = getCoordsForValue(slider, value); - - dispatchPointerEvent(sliderElement, 'mouseenter', x, y); - dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_DOWN, x, y, isIOS); - dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_UP, x, y, isIOS); -} - -/** Slides the MatSlider's thumb to the given value. */ -function slideToValue(slider: MatSlider, value: number, thumbPosition: Thumb, isIOS: boolean) { - const sliderElement = slider._elementRef.nativeElement; - const {x: startX, y: startY} = getCoordsForValue(slider, slider._getInput(thumbPosition).value); - const {x: endX, y: endY} = getCoordsForValue(slider, value); - - dispatchPointerEvent(sliderElement, 'mouseenter', startX, startY); - dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_DOWN, startX, startY, isIOS); - dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_MOVE, endX, endY, isIOS); - dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_UP, endX, endY, isIOS); -} - -/** Returns the x and y coordinates for the given slider value. */ -function getCoordsForValue(slider: MatSlider, value: number): Point { - const {min, max} = slider; - const percent = (value - min) / (max - min); - - const {top, left, width, height} = slider._elementRef.nativeElement.getBoundingClientRect(); - const x = left + width * percent; - const y = top + height / 2; - - return {x, y}; -} - -/** Dispatch a pointerdown or pointerup event if supported, otherwise dispatch the touch event. */ -function dispatchPointerOrTouchEvent( - node: Node, - type: PointerEventType, - x: number, - y: number, - isIOS: boolean, -) { - if (isIOS) { - dispatchTouchEvent(node, pointerEventTypeToTouchEventType(type), x, y, x, y); - } else { - dispatchPointerEvent(node, type, x, y); - } -} - -/** Returns the touch event equivalent of the given pointer event. */ -function pointerEventTypeToTouchEventType(pointerEventType: PointerEventType) { - switch (pointerEventType) { - case PointerEventType.POINTER_DOWN: - return TouchEventType.TOUCH_START; - case PointerEventType.POINTER_UP: - return TouchEventType.TOUCH_END; - case PointerEventType.POINTER_MOVE: - return TouchEventType.TOUCH_MOVE; - } -} diff --git a/src/material-experimental/mdc-slider/slider.ts b/src/material-experimental/mdc-slider/slider.ts deleted file mode 100644 index 517f917cc80b..000000000000 --- a/src/material-experimental/mdc-slider/slider.ts +++ /dev/null @@ -1,1327 +0,0 @@ -/** - * @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.io/license - */ - -import {Directionality} from '@angular/cdk/bidi'; -import { - BooleanInput, - coerceBooleanProperty, - coerceNumberProperty, - NumberInput, -} from '@angular/cdk/coercion'; -import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; -import {DOCUMENT} from '@angular/common'; -import { - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ContentChildren, - Directive, - ElementRef, - EventEmitter, - forwardRef, - Inject, - Input, - NgZone, - OnDestroy, - OnInit, - Optional, - Output, - QueryList, - ViewChild, - ViewChildren, - ViewEncapsulation, -} from '@angular/core'; -import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; -import { - CanDisableRipple, - MatRipple, - MAT_RIPPLE_GLOBAL_OPTIONS, - mixinColor, - mixinDisableRipple, - RippleAnimationConfig, - RippleGlobalOptions, - RippleRef, - RippleState, -} from '@angular/material/core'; -import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; -import {SpecificEventListener, EventType} from '@material/base'; -import {MDCSliderAdapter, MDCSliderFoundation, Thumb, TickMark} from '@material/slider'; -import {Subscription} from 'rxjs'; -import {GlobalChangeAndInputListener} from './global-change-and-input-listener'; - -/** Options used to bind passive event listeners. */ -const passiveEventListenerOptions = normalizePassiveListenerOptions({passive: true}); - -/** Represents a drag event emitted by the MatSlider component. */ -export interface MatSliderDragEvent { - /** The MatSliderThumb that was interacted with. */ - source: MatSliderThumb; - - /** The MatSlider that was interacted with. */ - parent: MatSlider; - - /** The current value of the slider. */ - value: number; -} - -/** - * The visual slider thumb. - * - * Handles the slider thumb ripple states (hover, focus, and active), - * and displaying the value tooltip on discrete sliders. - * @docs-private - */ -@Component({ - selector: 'mat-slider-visual-thumb', - templateUrl: './slider-thumb.html', - styleUrls: ['slider-thumb.css'], - host: { - 'class': 'mdc-slider__thumb mat-mdc-slider-visual-thumb', - - // NOTE: This class is used internally. - // TODO(wagnermaciel): Remove this once it is handled by the mdc foundation (cl/388828896). - '[class.mdc-slider__thumb--short-value]': '_isShortValue()', - }, - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, -}) -export class MatSliderVisualThumb implements AfterViewInit, OnDestroy { - /** Whether the slider displays a numeric value label upon pressing the thumb. */ - @Input() discrete: boolean; - - /** Indicates which slider thumb this input corresponds to. */ - @Input() thumbPosition: Thumb; - - /** The display value of the slider thumb. */ - @Input() valueIndicatorText: string; - - /** Whether ripples on the slider thumb should be disabled. */ - @Input() disableRipple: boolean = false; - - /** The MatRipple for this slider thumb. */ - @ViewChild(MatRipple) private readonly _ripple: MatRipple; - - /** The slider thumb knob. */ - @ViewChild('knob') _knob: ElementRef; - - /** The slider thumb value indicator container. */ - @ViewChild('valueIndicatorContainer') - _valueIndicatorContainer: ElementRef; - - /** The slider input corresponding to this slider thumb. */ - private _sliderInput: MatSliderThumb; - - /** The RippleRef for the slider thumbs hover state. */ - private _hoverRippleRef: RippleRef | undefined; - - /** The RippleRef for the slider thumbs focus state. */ - private _focusRippleRef: RippleRef | undefined; - - /** The RippleRef for the slider thumbs active state. */ - private _activeRippleRef: RippleRef | undefined; - - /** Whether the slider thumb is currently being pressed. */ - readonly _isActive = false; - - /** Whether the slider thumb is currently being hovered. */ - private _isHovered: boolean = false; - - constructor( - private readonly _ngZone: NgZone, - @Inject(forwardRef(() => MatSlider)) private readonly _slider: MatSlider, - private readonly _elementRef: ElementRef, - ) {} - - ngAfterViewInit() { - this._ripple.radius = 24; - this._sliderInput = this._slider._getInput(this.thumbPosition); - - // Note that we don't unsubscribe from these, because they're complete on destroy. - this._sliderInput.dragStart.subscribe(event => this._onDragStart(event)); - this._sliderInput.dragEnd.subscribe(event => this._onDragEnd(event)); - - this._sliderInput._focus.subscribe(() => this._onFocus()); - this._sliderInput._blur.subscribe(() => this._onBlur()); - - // These two listeners don't update any data bindings so we bind them - // outside of the NgZone to prevent Angular from needlessly running change detection. - this._ngZone.runOutsideAngular(() => { - this._elementRef.nativeElement.addEventListener('mouseenter', this._onMouseEnter); - this._elementRef.nativeElement.addEventListener('mouseleave', this._onMouseLeave); - }); - } - - ngOnDestroy() { - this._elementRef.nativeElement.removeEventListener('mouseenter', this._onMouseEnter); - this._elementRef.nativeElement.removeEventListener('mouseleave', this._onMouseLeave); - } - - /** Used to append a class to indicate when the value indicator text is short. */ - _isShortValue(): boolean { - return this.valueIndicatorText?.length <= 2; - } - - private _onMouseEnter = (): void => { - this._isHovered = true; - // We don't want to show the hover ripple on top of the focus ripple. - // This can happen if the user tabs to a thumb and then the user moves their cursor over it. - if (!this._isShowingRipple(this._focusRippleRef)) { - this._showHoverRipple(); - } - }; - - private _onMouseLeave = (): void => { - this._isHovered = false; - this._hoverRippleRef?.fadeOut(); - }; - - private _onFocus(): void { - // We don't want to show the hover ripple on top of the focus ripple. - // Happen when the users cursor is over a thumb and then the user tabs to it. - this._hoverRippleRef?.fadeOut(); - this._showFocusRipple(); - } - - private _onBlur(): void { - // Happens when the user tabs away while still dragging a thumb. - if (!this._isActive) { - this._focusRippleRef?.fadeOut(); - } - // Happens when the user tabs away from a thumb but their cursor is still over it. - if (this._isHovered) { - this._showHoverRipple(); - } - } - - private _onDragStart(event: MatSliderDragEvent): void { - if (event.source._thumbPosition === this.thumbPosition) { - (this as {_isActive: boolean})._isActive = true; - this._showActiveRipple(); - } - } - - private _onDragEnd(event: MatSliderDragEvent): void { - if (event.source._thumbPosition === this.thumbPosition) { - (this as {_isActive: boolean})._isActive = false; - this._activeRippleRef?.fadeOut(); - // Happens when the user starts dragging a thumb, tabs away, and then stops dragging. - if (!this._sliderInput._isFocused()) { - this._focusRippleRef?.fadeOut(); - } - } - } - - /** Handles displaying the hover ripple. */ - private _showHoverRipple(): void { - if (!this._isShowingRipple(this._hoverRippleRef)) { - this._hoverRippleRef = this._showRipple({enterDuration: 0, exitDuration: 0}); - this._hoverRippleRef?.element.classList.add('mat-mdc-slider-hover-ripple'); - } - } - - /** Handles displaying the focus ripple. */ - private _showFocusRipple(): void { - // Show the focus ripple event if noop animations are enabled. - if (!this._isShowingRipple(this._focusRippleRef)) { - this._focusRippleRef = this._showRipple({enterDuration: 0, exitDuration: 0}); - this._focusRippleRef?.element.classList.add('mat-mdc-slider-focus-ripple'); - } - } - - /** Handles displaying the active ripple. */ - private _showActiveRipple(): void { - if (!this._isShowingRipple(this._activeRippleRef)) { - this._activeRippleRef = this._showRipple({enterDuration: 225, exitDuration: 400}); - this._activeRippleRef?.element.classList.add('mat-mdc-slider-active-ripple'); - } - } - - /** Whether the given rippleRef is currently fading in or visible. */ - private _isShowingRipple(rippleRef?: RippleRef): boolean { - return rippleRef?.state === RippleState.FADING_IN || rippleRef?.state === RippleState.VISIBLE; - } - - /** Manually launches the slider thumb ripple using the specified ripple animation config. */ - private _showRipple(animation: RippleAnimationConfig): RippleRef | undefined { - if (this.disableRipple) { - return; - } - return this._ripple.launch({ - animation: this._slider._noopAnimations ? {enterDuration: 0, exitDuration: 0} : animation, - centered: true, - persistent: true, - }); - } - - /** Gets the hosts native HTML element. */ - _getHostElement(): HTMLElement { - return this._elementRef.nativeElement; - } - - /** Gets the native HTML element of the slider thumb knob. */ - _getKnob(): HTMLElement { - return this._knob.nativeElement; - } - - /** - * Gets the native HTML element of the slider thumb value indicator - * container. - */ - _getValueIndicatorContainer(): HTMLElement { - return this._valueIndicatorContainer.nativeElement; - } -} - -/** - * Directive that adds slider-specific behaviors to an input element inside ``. - * Up to two may be placed inside of a ``. - * - * If one is used, the selector `matSliderThumb` must be used, and the outcome will be a normal - * slider. If two are used, the selectors `matSliderStartThumb` and `matSliderEndThumb` must be - * used, and the outcome will be a range slider with two slider thumbs. - */ -@Directive({ - selector: 'input[matSliderThumb], input[matSliderStartThumb], input[matSliderEndThumb]', - exportAs: 'matSliderThumb', - host: { - 'class': 'mdc-slider__input', - 'type': 'range', - '(blur)': '_onBlur()', - '(focus)': '_focus.emit()', - }, - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: MatSliderThumb, - multi: true, - }, - ], -}) -export class MatSliderThumb implements AfterViewInit, ControlValueAccessor, OnInit, OnDestroy { - // ** IMPORTANT NOTE ** - // - // The way `value` is implemented for MatSliderThumb doesn't follow typical Angular conventions. - // Normally we would define a private variable `_value` as the source of truth for the value of - // the slider thumb input. The source of truth for the value of the slider inputs has already - // been decided for us by MDC to be the value attribute on the slider thumb inputs. This is - // because the MDC foundation and adapter expect that the value attribute is the source of truth - // for the slider inputs. - // - // Also, note that the value attribute is completely disconnected from the value property. - - /** The current value of this slider input. */ - @Input() - get value(): number { - return coerceNumberProperty(this._hostElement.getAttribute('value')); - } - set value(v: NumberInput) { - const value = coerceNumberProperty(v); - - // If the foundation has already been initialized, we need to - // relay any value updates to it so that it can update the UI. - if (this._slider._initialized) { - this._slider._setValue(value, this._thumbPosition); - } else { - // Setup for the MDC foundation. - this._hostElement.setAttribute('value', `${value}`); - } - } - - /** - * Emits when the raw value of the slider changes. This is here primarily - * to facilitate the two-way binding for the `value` input. - * @docs-private - */ - @Output() readonly valueChange: EventEmitter = new EventEmitter(); - - /** Event emitted when the slider thumb starts being dragged. */ - @Output() readonly dragStart: EventEmitter = - new EventEmitter(); - - /** Event emitted when the slider thumb stops being dragged. */ - @Output() readonly dragEnd: EventEmitter = - new EventEmitter(); - - /** Event emitted every time the MatSliderThumb is blurred. */ - @Output() readonly _blur: EventEmitter = new EventEmitter(); - - /** Event emitted every time the MatSliderThumb is focused. */ - @Output() readonly _focus: EventEmitter = new EventEmitter(); - - /** - * Used to determine the disabled state of the MatSlider (ControlValueAccessor). - * For ranged sliders, the disabled state of the MatSlider depends on the combined state of the - * start and end inputs. See MatSlider._updateDisabled. - */ - _disabled: boolean = false; - - /** - * A callback function that is called when the - * control's value changes in the UI (ControlValueAccessor). - */ - _onChange: (value: any) => void = () => {}; - - /** - * A callback function that is called by the forms API on - * initialization to update the form model on blur (ControlValueAccessor). - */ - private _onTouched: () => void = () => {}; - - /** Indicates which slider thumb this input corresponds to. */ - _thumbPosition: Thumb = this._elementRef.nativeElement.hasAttribute('matSliderStartThumb') - ? Thumb.START - : Thumb.END; - - /** The injected document if available or fallback to the global document reference. */ - private _document: Document; - - /** The host native HTML input element. */ - _hostElement: HTMLInputElement; - - constructor( - @Inject(DOCUMENT) document: any, - @Inject(forwardRef(() => MatSlider)) private readonly _slider: MatSlider, - private readonly _elementRef: ElementRef, - ) { - this._document = document; - this._hostElement = _elementRef.nativeElement; - } - - ngOnInit() { - // By calling this in ngOnInit() we guarantee that the sibling sliders initial value by - // has already been set by the time we reach ngAfterViewInit(). - this._initializeInputValueAttribute(); - this._initializeAriaValueText(); - } - - ngAfterViewInit() { - this._initializeInputState(); - this._initializeInputValueProperty(); - - // Setup for the MDC foundation. - if (this._slider.disabled) { - this._hostElement.disabled = true; - } - } - - ngOnDestroy() { - this.dragStart.complete(); - this.dragEnd.complete(); - this._focus.complete(); - this._blur.complete(); - this.valueChange.complete(); - } - - _onBlur(): void { - this._onTouched(); - this._blur.emit(); - } - - _emitFakeEvent(type: 'change' | 'input') { - const event = new Event(type) as any; - event._matIsHandled = true; - this._hostElement.dispatchEvent(event); - } - - /** - * Sets the model value. Implemented as part of ControlValueAccessor. - * @param value - */ - writeValue(value: any): void { - this.value = value; - } - - /** - * Registers a callback to be triggered when the value has changed. - * Implemented as part of ControlValueAccessor. - * @param fn Callback to be registered. - */ - registerOnChange(fn: any): void { - this._onChange = fn; - } - - /** - * Registers a callback to be triggered when the component is touched. - * Implemented as part of ControlValueAccessor. - * @param fn Callback to be registered. - */ - registerOnTouched(fn: any): void { - this._onTouched = fn; - } - - /** - * Sets whether the component should be disabled. - * Implemented as part of ControlValueAccessor. - * @param isDisabled - */ - setDisabledState(isDisabled: boolean): void { - this._disabled = isDisabled; - this._slider._updateDisabled(); - } - - focus(): void { - this._hostElement.focus(); - } - - blur(): void { - this._hostElement.blur(); - } - - /** Returns true if this slider input currently has focus. */ - _isFocused(): boolean { - return this._document.activeElement === this._hostElement; - } - - /** - * Sets the min, max, and step properties on the slider thumb input. - * - * Must be called AFTER the sibling slider thumb input is guaranteed to have had its value - * attribute value set. For a range slider, the min and max of the slider thumb input depends on - * the value of its sibling slider thumb inputs value. - * - * Must be called BEFORE the value property is set. In the case where the min and max have not - * yet been set and we are setting the input value property to a value outside of the native - * inputs default min or max. The value property would not be set to our desired value, but - * instead be capped at either the default min or max. - * - */ - _initializeInputState(): void { - const min = this._hostElement.hasAttribute('matSliderEndThumb') - ? this._slider._getInput(Thumb.START).value - : this._slider.min; - const max = this._hostElement.hasAttribute('matSliderStartThumb') - ? this._slider._getInput(Thumb.END).value - : this._slider.max; - this._hostElement.min = `${min}`; - this._hostElement.max = `${max}`; - this._hostElement.step = `${this._slider.step}`; - } - - /** - * Sets the value property on the slider thumb input. - * - * Must be called AFTER the min and max have been set. In the case where the min and max have not - * yet been set and we are setting the input value property to a value outside of the native - * inputs default min or max. The value property would not be set to our desired value, but - * instead be capped at either the default min or max. - */ - private _initializeInputValueProperty(): void { - this._hostElement.value = `${this.value}`; - } - - /** - * Ensures the value attribute is initialized. - * - * Must be called BEFORE the min and max are set. For a range slider, the min and max of the - * slider thumb input depends on the value of its sibling slider thumb inputs value. - */ - private _initializeInputValueAttribute(): void { - // Only set the default value if an initial value has not already been provided. - if (!this._hostElement.hasAttribute('value')) { - this.value = this._hostElement.hasAttribute('matSliderEndThumb') - ? this._slider.max - : this._slider.min; - } - } - - /** - * Initializes the aria-valuetext attribute. - * - * Must be called AFTER the value attribute is set. This is because the slider's parent - * `displayWith` function is used to set the `aria-valuetext` attribute. - */ - private _initializeAriaValueText(): void { - this._hostElement.setAttribute('aria-valuetext', this._slider.displayWith(this.value)); - } -} - -// Boilerplate for applying mixins to MatSlider. -const _MatSliderMixinBase = mixinColor( - mixinDisableRipple( - class { - constructor(public _elementRef: ElementRef) {} - }, - ), - 'primary', -); - -/** - * Allows users to select from a range of values by moving the slider thumb. It is similar in - * behavior to the native `` element. - */ -@Component({ - selector: 'mat-slider', - templateUrl: 'slider.html', - styleUrls: ['slider.css'], - host: { - 'class': 'mat-mdc-slider mdc-slider', - '[class.mdc-slider--range]': '_isRange()', - '[class.mdc-slider--disabled]': 'disabled', - '[class.mdc-slider--discrete]': 'discrete', - '[class.mdc-slider--tick-marks]': 'showTickMarks', - '[class._mat-animation-noopable]': '_noopAnimations', - }, - exportAs: 'matSlider', - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, - inputs: ['color', 'disableRipple'], -}) -export class MatSlider - extends _MatSliderMixinBase - implements AfterViewInit, CanDisableRipple, OnDestroy -{ - /** The slider thumb(s). */ - @ViewChildren(MatSliderVisualThumb) _thumbs: QueryList; - - /** The active section of the slider track. */ - @ViewChild('trackActive') _trackActive: ElementRef; - - /** The sliders hidden range input(s). */ - @ContentChildren(MatSliderThumb, {descendants: false}) - _inputs: QueryList; - - /** Whether the slider is disabled. */ - @Input() - get disabled(): boolean { - return this._disabled; - } - set disabled(v: BooleanInput) { - this._setDisabled(coerceBooleanProperty(v)); - this._updateInputsDisabledState(); - } - private _disabled: boolean = false; - - /** Whether the slider displays a numeric value label upon pressing the thumb. */ - @Input() - get discrete(): boolean { - return this._discrete; - } - set discrete(v: BooleanInput) { - this._discrete = coerceBooleanProperty(v); - } - private _discrete: boolean = false; - - /** Whether the slider displays tick marks along the slider track. */ - @Input() - get showTickMarks(): boolean { - return this._showTickMarks; - } - set showTickMarks(v: BooleanInput) { - this._showTickMarks = coerceBooleanProperty(v); - } - private _showTickMarks: boolean = false; - - /** The minimum value that the slider can have. */ - @Input() - get min(): number { - return this._min; - } - set min(v: NumberInput) { - this._min = coerceNumberProperty(v, this._min); - this._reinitialize(); - } - private _min: number = 0; - - /** The maximum value that the slider can have. */ - @Input() - get max(): number { - return this._max; - } - set max(v: NumberInput) { - this._max = coerceNumberProperty(v, this._max); - this._reinitialize(); - } - private _max: number = 100; - - /** The values at which the thumb will snap. */ - @Input() - get step(): number { - return this._step; - } - set step(v: NumberInput) { - this._step = coerceNumberProperty(v, this._step); - this._reinitialize(); - } - private _step: number = 1; - - /** - * Function that will be used to format the value before it is displayed - * in the thumb label. Can be used to format very large number in order - * for them to fit into the slider thumb. - */ - @Input() displayWith: (value: number) => string = (value: number) => `${value}`; - - /** Instance of the MDC slider foundation for this slider. */ - private _foundation = new MDCSliderFoundation(new SliderAdapter(this)); - - /** Whether the foundation has been initialized. */ - _initialized: boolean = false; - - /** The injected document if available or fallback to the global document reference. */ - _document: Document; - - /** - * The defaultView of the injected document if - * available or fallback to global window reference. - */ - _window: Window; - - /** Used to keep track of & render the active & inactive tick marks on the slider track. */ - _tickMarks: TickMark[]; - - /** The display value of the start thumb. */ - _startValueIndicatorText: string; - - /** The display value of the end thumb. */ - _endValueIndicatorText: string; - - /** Whether animations have been disabled. */ - _noopAnimations: boolean; - - /** - * Whether the browser supports pointer events. - * - * We exclude iOS to mirror the MDC Foundation. The MDC Foundation cannot use pointer events on - * iOS because of this open bug - https://bugs.webkit.org/show_bug.cgi?id=220196. - */ - private _SUPPORTS_POINTER_EVENTS = - typeof PointerEvent !== 'undefined' && !!PointerEvent && !this._platform.IOS; - - /** Subscription to changes to the directionality (LTR / RTL) context for the application. */ - private _dirChangeSubscription: Subscription; - - /** Observer used to monitor size changes in the slider. */ - private _resizeObserver: ResizeObserver | null; - - /** Timeout used to debounce resize listeners. */ - private _resizeTimer: number; - - /** Cached dimensions of the host element. */ - private _cachedHostRect: DOMRect | null; - - constructor( - readonly _ngZone: NgZone, - readonly _cdr: ChangeDetectorRef, - elementRef: ElementRef, - private readonly _platform: Platform, - readonly _globalChangeAndInputListener: GlobalChangeAndInputListener<'input' | 'change'>, - @Inject(DOCUMENT) document: any, - @Optional() private _dir: Directionality, - @Optional() - @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) - readonly _globalRippleOptions?: RippleGlobalOptions, - @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string, - ) { - super(elementRef); - this._document = document; - this._window = this._document.defaultView || window; - this._noopAnimations = animationMode === 'NoopAnimations'; - this._dirChangeSubscription = this._dir.change.subscribe(() => this._onDirChange()); - this._attachUISyncEventListener(); - } - - ngAfterViewInit() { - if (typeof ngDevMode === 'undefined' || ngDevMode) { - _validateThumbs(this._isRange(), this._getThumb(Thumb.START), this._getThumb(Thumb.END)); - _validateInputs( - this._isRange(), - this._getInputElement(Thumb.START), - this._getInputElement(Thumb.END), - ); - } - if (this._platform.isBrowser) { - this._foundation.init(); - this._foundation.layout(); - this._initialized = true; - this._observeHostResize(); - } - // The MDC foundation requires access to the view and content children of the MatSlider. In - // order to access the view and content children of MatSlider we need to wait until change - // detection runs and materializes them. That is why we call init() and layout() in - // ngAfterViewInit(). - // - // The MDC foundation then uses the information it gathers from the DOM to compute an initial - // value for the tickMarks array. It then tries to update the component data, but because it is - // updating the component data AFTER change detection already ran, we will get a changed after - // checked error. Because of this, we need to force change detection to update the UI with the - // new state. - this._cdr.detectChanges(); - } - - ngOnDestroy() { - if (this._platform.isBrowser) { - this._foundation.destroy(); - } - this._dirChangeSubscription.unsubscribe(); - this._resizeObserver?.disconnect(); - this._resizeObserver = null; - clearTimeout(this._resizeTimer); - this._removeUISyncEventListener(); - } - - /** Returns true if the language direction for this slider element is right to left. */ - _isRTL() { - return this._dir && this._dir.value === 'rtl'; - } - - /** - * Attaches an event listener that keeps sync the slider UI and the foundation in sync. - * - * Because the MDC Foundation stores the value of the bounding client rect when layout is called, - * we need to keep calling layout to avoid the position of the slider getting out of sync with - * what the foundation has stored. If we don't do this, the foundation will not be able to - * correctly calculate the slider value on click/slide. - */ - _attachUISyncEventListener(): void { - // Implementation detail: It may seem weird that we are using "mouseenter" instead of - // "mousedown" as the default for when a browser does not support pointer events. While we - // would prefer to use "mousedown" as the default, for some reason it does not work (the - // callback is never triggered). - if (this._SUPPORTS_POINTER_EVENTS) { - this._elementRef.nativeElement.addEventListener('pointerdown', this._layout); - } else { - this._elementRef.nativeElement.addEventListener('mouseenter', this._layout); - this._elementRef.nativeElement.addEventListener( - 'touchstart', - this._layout, - passiveEventListenerOptions, - ); - } - } - - /** Removes the event listener that keeps sync the slider UI and the foundation in sync. */ - _removeUISyncEventListener(): void { - if (this._SUPPORTS_POINTER_EVENTS) { - this._elementRef.nativeElement.removeEventListener('pointerdown', this._layout); - } else { - this._elementRef.nativeElement.removeEventListener('mouseenter', this._layout); - this._elementRef.nativeElement.removeEventListener( - 'touchstart', - this._layout, - passiveEventListenerOptions, - ); - } - } - - /** Wrapper function for calling layout (needed for adding & removing an event listener). */ - private _layout = () => this._foundation.layout(); - - /** - * Reinitializes the slider foundation and input state(s). - * - * The MDC Foundation does not support changing some slider attributes after it has been - * initialized (e.g. min, max, and step). To continue supporting this feature, we need to - * destroy the foundation and re-initialize everything whenever we make these changes. - */ - private _reinitialize(): void { - if (this._initialized) { - this._foundation.destroy(); - if (this._isRange()) { - this._getInput(Thumb.START)._initializeInputState(); - } - this._getInput(Thumb.END)._initializeInputState(); - this._foundation.init(); - this._foundation.layout(); - } - } - - /** Handles updating the slider foundation after a dir change. */ - private _onDirChange(): void { - this._ngZone.runOutsideAngular(() => { - // We need to call layout() a few milliseconds after the dir change callback - // because we need to wait until the bounding client rect of the slider has updated. - setTimeout(() => this._foundation.layout(), 10); - }); - } - - /** Sets the value of a slider thumb. */ - _setValue(value: number, thumbPosition: Thumb): void { - thumbPosition === Thumb.START - ? this._foundation.setValueStart(value) - : this._foundation.setValue(value); - } - - /** Sets the disabled state of the MatSlider. */ - private _setDisabled(value: boolean) { - this._disabled = value; - - // If we want to disable the slider after the foundation has been initialized, - // we need to inform the foundation by calling `setDisabled`. Also, we can't call - // this before initializing the foundation because it will throw errors. - if (this._initialized) { - this._foundation.setDisabled(value); - } - } - - /** Sets the disabled state of the individual slider thumb(s) (ControlValueAccessor). */ - private _updateInputsDisabledState() { - if (this._initialized) { - this._getInput(Thumb.END)._disabled = true; - if (this._isRange()) { - this._getInput(Thumb.START)._disabled = true; - } - } - } - - /** Whether this is a ranged slider. */ - _isRange(): boolean { - return this._inputs.length === 2; - } - - /** Sets the disabled state based on the disabled state of the inputs (ControlValueAccessor). */ - _updateDisabled(): void { - const disabled = this._inputs?.some(input => input._disabled) || false; - this._setDisabled(disabled); - } - - /** Gets the slider thumb input of the given thumb position. */ - _getInput(thumbPosition: Thumb): MatSliderThumb { - return thumbPosition === Thumb.END ? this._inputs?.last! : this._inputs?.first!; - } - - /** Gets the slider thumb HTML input element of the given thumb position. */ - _getInputElement(thumbPosition: Thumb): HTMLInputElement { - return this._getInput(thumbPosition)?._hostElement; - } - - _getThumb(thumbPosition: Thumb): MatSliderVisualThumb { - return thumbPosition === Thumb.END ? this._thumbs?.last! : this._thumbs?.first!; - } - - /** Gets the slider thumb HTML element of the given thumb position. */ - _getThumbElement(thumbPosition: Thumb): HTMLElement { - return this._getThumb(thumbPosition)?._getHostElement(); - } - - /** Gets the slider knob HTML element of the given thumb position. */ - _getKnobElement(thumbPosition: Thumb): HTMLElement { - return this._getThumb(thumbPosition)?._getKnob(); - } - - /** - * Gets the slider value indicator container HTML element of the given thumb - * position. - */ - _getValueIndicatorContainerElement(thumbPosition: Thumb): HTMLElement { - return this._getThumb(thumbPosition)._getValueIndicatorContainer(); - } - - /** - * Sets the value indicator text of the given thumb position using the given value. - * - * Uses the `displayWith` function if one has been provided. Otherwise, it just uses the - * numeric value as a string. - */ - _setValueIndicatorText(value: number, thumbPosition: Thumb) { - thumbPosition === Thumb.START - ? (this._startValueIndicatorText = this.displayWith(value)) - : (this._endValueIndicatorText = this.displayWith(value)); - this._cdr.markForCheck(); - } - - /** Gets the value indicator text for the given thumb position. */ - _getValueIndicatorText(thumbPosition: Thumb): string { - return thumbPosition === Thumb.START - ? this._startValueIndicatorText - : this._endValueIndicatorText; - } - - /** Determines the class name for a HTML element. */ - _getTickMarkClass(tickMark: TickMark): string { - return tickMark === TickMark.ACTIVE - ? 'mdc-slider__tick-mark--active' - : 'mdc-slider__tick-mark--inactive'; - } - - /** Whether the slider thumb ripples should be disabled. */ - _isRippleDisabled(): boolean { - return this.disabled || this.disableRipple || !!this._globalRippleOptions?.disabled; - } - - /** Gets the dimensions of the host element. */ - _getHostDimensions() { - return this._cachedHostRect || this._elementRef.nativeElement.getBoundingClientRect(); - } - - /** Starts observing and updating the slider if the host changes its size. */ - private _observeHostResize() { - if (typeof ResizeObserver === 'undefined' || !ResizeObserver) { - return; - } - - // MDC only updates the slider when the window is resized which - // doesn't capture changes of the container itself. We use a resize - // observer to ensure that the layout is correct (see #24590 and #25286). - this._ngZone.runOutsideAngular(() => { - this._resizeObserver = new ResizeObserver(entries => { - // Triggering a layout while the user is dragging can throw off the alignment. - if (this._isActive()) { - return; - } - - clearTimeout(this._resizeTimer); - this._resizeTimer = setTimeout(() => { - // The `layout` call is going to call `getBoundingClientRect` to update the dimensions - // of the host. Since the `ResizeObserver` already calculated them, we can save some - // work by returning them instead of having to check the DOM again. - if (!this._isActive()) { - this._cachedHostRect = entries[0]?.contentRect; - this._layout(); - this._cachedHostRect = null; - } - }, 50); - }); - this._resizeObserver.observe(this._elementRef.nativeElement); - }); - } - - /** Whether any of the thumbs are currently active. */ - private _isActive(): boolean { - return this._getThumb(Thumb.START)._isActive || this._getThumb(Thumb.END)._isActive; - } -} - -/** The MDCSliderAdapter implementation. */ -class SliderAdapter implements MDCSliderAdapter { - /** The global event listener subscription used to handle events on the slider inputs. */ - private _globalEventSubscriptions = new Subscription(); - - /** The MDC Foundations handler function for start input change events. */ - private _startInputChangeEventHandler: SpecificEventListener; - - /** The MDC Foundations handler function for end input change events. */ - private _endInputChangeEventHandler: SpecificEventListener; - - constructor(private readonly _delegate: MatSlider) { - this._globalEventSubscriptions.add(this._subscribeToSliderInputEvents('change')); - this._globalEventSubscriptions.add(this._subscribeToSliderInputEvents('input')); - } - - /** - * Handles "change" and "input" events on the slider inputs. - * - * Exposes a callback to allow the MDC Foundations "change" event handler to be called for "real" - * events. - * - * ** IMPORTANT NOTE ** - * - * We block all "real" change and input events and emit fake events from #emitChangeEvent and - * #emitInputEvent, instead. We do this because interacting with the MDC slider won't trigger all - * of the correct change and input events, but it will call #emitChangeEvent and #emitInputEvent - * at the correct times. This allows users to listen for these events directly on the slider - * input as they would with a native range input. - */ - private _subscribeToSliderInputEvents(type: 'change' | 'input') { - return this._delegate._globalChangeAndInputListener.listen(type, (event: Event) => { - const thumbPosition = this._getInputThumbPosition(event.target); - - // Do nothing if the event isn't from a thumb input. - if (thumbPosition === null) { - return; - } - - // Do nothing if the event is "fake". - if ((event as any)._matIsHandled) { - return; - } - - // Prevent "real" events from reaching end users. - event.stopImmediatePropagation(); - - // Relay "real" change events to the MDC Foundation. - if (type === 'change') { - this._callChangeEventHandler(event, thumbPosition); - } - }); - } - - /** Calls the MDC Foundations change event handler for the specified thumb position. */ - private _callChangeEventHandler(event: Event, thumbPosition: Thumb) { - if (thumbPosition === Thumb.START) { - this._startInputChangeEventHandler(event); - } else { - this._endInputChangeEventHandler(event); - } - } - - /** Save the event handler so it can be used in our global change event listener subscription. */ - private _saveChangeEventHandler(thumbPosition: Thumb, handler: SpecificEventListener) { - if (thumbPosition === Thumb.START) { - this._startInputChangeEventHandler = handler; - } else { - this._endInputChangeEventHandler = handler; - } - } - - /** - * Returns the thumb position of the given event target. - * Returns null if the given event target does not correspond to a slider thumb input. - */ - private _getInputThumbPosition(target: EventTarget | null): Thumb | null { - if (target === this._delegate._getInputElement(Thumb.END)) { - return Thumb.END; - } - if (this._delegate._isRange() && target === this._delegate._getInputElement(Thumb.START)) { - return Thumb.START; - } - return null; - } - - // We manually assign functions instead of using prototype methods because - // MDC clobbers the values otherwise. - // See https://github.com/material-components/material-components-web/pull/6256 - - hasClass = (className: string): boolean => { - return this._delegate._elementRef.nativeElement.classList.contains(className); - }; - addClass = (className: string): void => { - this._delegate._elementRef.nativeElement.classList.add(className); - }; - removeClass = (className: string): void => { - this._delegate._elementRef.nativeElement.classList.remove(className); - }; - getAttribute = (attribute: string): string | null => { - return this._delegate._elementRef.nativeElement.getAttribute(attribute); - }; - addThumbClass = (className: string, thumbPosition: Thumb): void => { - this._delegate._getThumbElement(thumbPosition).classList.add(className); - }; - removeThumbClass = (className: string, thumbPosition: Thumb): void => { - this._delegate._getThumbElement(thumbPosition).classList.remove(className); - }; - getInputValue = (thumbPosition: Thumb): string => { - return this._delegate._getInputElement(thumbPosition).value; - }; - setInputValue = (value: string, thumbPosition: Thumb): void => { - this._delegate._getInputElement(thumbPosition).value = value; - }; - getInputAttribute = (attribute: string, thumbPosition: Thumb): string | null => { - return this._delegate._getInputElement(thumbPosition).getAttribute(attribute); - }; - setInputAttribute = (attribute: string, value: string, thumbPosition: Thumb): void => { - const input = this._delegate._getInputElement(thumbPosition); - - // TODO(wagnermaciel): remove this check once this component is - // added to the internal allowlist for calling setAttribute. - - // Explicitly check the attribute we are setting to prevent xss. - switch (attribute) { - case 'aria-valuetext': - input.setAttribute('aria-valuetext', value); - break; - case 'disabled': - input.setAttribute('disabled', value); - break; - case 'min': - input.setAttribute('min', value); - break; - case 'max': - input.setAttribute('max', value); - break; - case 'value': - input.setAttribute('value', value); - break; - case 'step': - input.setAttribute('step', value); - break; - default: - throw Error(`Tried to set invalid attribute ${attribute} on the mdc-slider.`); - } - }; - removeInputAttribute = (attribute: string, thumbPosition: Thumb): void => { - this._delegate._getInputElement(thumbPosition).removeAttribute(attribute); - }; - focusInput = (thumbPosition: Thumb): void => { - this._delegate._getInputElement(thumbPosition).focus(); - }; - isInputFocused = (thumbPosition: Thumb): boolean => { - return this._delegate._getInput(thumbPosition)._isFocused(); - }; - getThumbKnobWidth = (thumbPosition: Thumb): number => { - return this._delegate._getKnobElement(thumbPosition).getBoundingClientRect().width; - }; - getThumbBoundingClientRect = (thumbPosition: Thumb): DOMRect => { - return this._delegate._getThumbElement(thumbPosition).getBoundingClientRect(); - }; - getBoundingClientRect = (): DOMRect => { - return this._delegate._getHostDimensions(); - }; - getValueIndicatorContainerWidth = (thumbPosition: Thumb): number => { - return this._delegate._getValueIndicatorContainerElement(thumbPosition).getBoundingClientRect() - .width; - }; - isRTL = (): boolean => { - return this._delegate._isRTL(); - }; - setThumbStyleProperty = (propertyName: string, value: string, thumbPosition: Thumb): void => { - this._delegate._getThumbElement(thumbPosition).style.setProperty(propertyName, value); - }; - removeThumbStyleProperty = (propertyName: string, thumbPosition: Thumb): void => { - this._delegate._getThumbElement(thumbPosition).style.removeProperty(propertyName); - }; - setTrackActiveStyleProperty = (propertyName: string, value: string): void => { - this._delegate._trackActive.nativeElement.style.setProperty(propertyName, value); - }; - removeTrackActiveStyleProperty = (propertyName: string): void => { - this._delegate._trackActive.nativeElement.style.removeProperty(propertyName); - }; - setValueIndicatorText = (value: number, thumbPosition: Thumb): void => { - this._delegate._setValueIndicatorText(value, thumbPosition); - }; - getValueToAriaValueTextFn = (): ((value: number) => string) | null => { - return this._delegate.displayWith; - }; - updateTickMarks = (tickMarks: TickMark[]): void => { - this._delegate._tickMarks = tickMarks; - this._delegate._cdr.markForCheck(); - }; - setPointerCapture = (pointerId: number): void => { - this._delegate._elementRef.nativeElement.setPointerCapture(pointerId); - }; - emitChangeEvent = (value: number, thumbPosition: Thumb): void => { - // We block all real slider input change events and emit fake change events from here, instead. - // We do this because the mdc implementation of the slider does not trigger real change events - // on pointer up (only on left or right arrow key down). - // - // By stopping real change events from reaching users, and dispatching fake change events - // (which we allow to reach the user) the slider inputs change events are triggered at the - // appropriate times. This allows users to listen for change events directly on the slider - // input as they would with a native range input. - const input = this._delegate._getInput(thumbPosition); - input._emitFakeEvent('change'); - input._onChange(value); - input.valueChange.emit(value); - }; - emitInputEvent = (value: number, thumbPosition: Thumb): void => { - this._delegate._getInput(thumbPosition)._emitFakeEvent('input'); - }; - emitDragStartEvent = (value: number, thumbPosition: Thumb): void => { - const input = this._delegate._getInput(thumbPosition); - input.dragStart.emit({source: input, parent: this._delegate, value}); - }; - emitDragEndEvent = (value: number, thumbPosition: Thumb): void => { - const input = this._delegate._getInput(thumbPosition); - input.dragEnd.emit({source: input, parent: this._delegate, value}); - }; - registerEventHandler = ( - evtType: K, - handler: SpecificEventListener, - ): void => { - this._delegate._elementRef.nativeElement.addEventListener(evtType, handler); - }; - deregisterEventHandler = ( - evtType: K, - handler: SpecificEventListener, - ): void => { - this._delegate._elementRef.nativeElement.removeEventListener(evtType, handler); - }; - registerThumbEventHandler = ( - thumbPosition: Thumb, - evtType: K, - handler: SpecificEventListener, - ): void => { - this._delegate._getThumbElement(thumbPosition).addEventListener(evtType, handler); - }; - deregisterThumbEventHandler = ( - thumbPosition: Thumb, - evtType: K, - handler: SpecificEventListener, - ): void => { - this._delegate._getThumbElement(thumbPosition)?.removeEventListener(evtType, handler); - }; - registerInputEventHandler = ( - thumbPosition: Thumb, - evtType: K, - handler: SpecificEventListener, - ): void => { - if (evtType === 'change') { - this._saveChangeEventHandler(thumbPosition, handler as SpecificEventListener); - } else { - this._delegate._getInputElement(thumbPosition)?.addEventListener(evtType, handler); - } - }; - deregisterInputEventHandler = ( - thumbPosition: Thumb, - evtType: K, - handler: SpecificEventListener, - ): void => { - if (evtType === 'change') { - this._globalEventSubscriptions.unsubscribe(); - } else { - this._delegate._getInputElement(thumbPosition)?.removeEventListener(evtType, handler); - } - }; - registerBodyEventHandler = ( - evtType: K, - handler: SpecificEventListener, - ): void => { - this._delegate._document.body.addEventListener(evtType, handler); - }; - deregisterBodyEventHandler = ( - evtType: K, - handler: SpecificEventListener, - ): void => { - this._delegate._document.body.removeEventListener(evtType, handler); - }; - registerWindowEventHandler = ( - evtType: K, - handler: SpecificEventListener, - ): void => { - this._delegate._window.addEventListener(evtType, handler); - }; - deregisterWindowEventHandler = ( - evtType: K, - handler: SpecificEventListener, - ): void => { - this._delegate._window.removeEventListener(evtType, handler); - }; -} - -/** Ensures that there is not an invalid configuration for the slider thumb inputs. */ -function _validateInputs( - isRange: boolean, - startInputElement: HTMLInputElement, - endInputElement: HTMLInputElement, -): void { - const startValid = !isRange || startInputElement.hasAttribute('matSliderStartThumb'); - const endValid = endInputElement.hasAttribute(isRange ? 'matSliderEndThumb' : 'matSliderThumb'); - - if (!startValid || !endValid) { - _throwInvalidInputConfigurationError(); - } -} - -/** Validates that the slider has the correct set of thumbs. */ -function _validateThumbs( - isRange: boolean, - start: MatSliderVisualThumb | undefined, - end: MatSliderVisualThumb | undefined, -): void { - if (!end && (!isRange || !start)) { - _throwInvalidInputConfigurationError(); - } -} - -function _throwInvalidInputConfigurationError(): void { - throw Error(`Invalid slider thumb input configuration! - - Valid configurations are as follows: - - - - - - or - - - - - - `); -} diff --git a/src/material-experimental/mdc-slider/testing/slider-harness-filters.ts b/src/material-experimental/mdc-slider/testing/slider-harness-filters.ts deleted file mode 100644 index 27f07a3dea05..000000000000 --- a/src/material-experimental/mdc-slider/testing/slider-harness-filters.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @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.io/license - */ -import {BaseHarnessFilters} from '@angular/cdk/testing'; - -/** Possible positions of a slider thumb. */ -export const enum ThumbPosition { - START, - END, -} - -/** A set of criteria that can be used to filter a list of `MatSliderHarness` instances. */ -export interface SliderHarnessFilters extends BaseHarnessFilters { - /** Filters out only range/non-range sliders. */ - isRange?: boolean; -} - -/** A set of criteria that can be used to filter a list of `MatSliderThumbHarness` instances. */ -export interface SliderThumbHarnessFilters extends BaseHarnessFilters { - /** Filters out slider thumbs with a particular position. */ - position?: ThumbPosition; -} diff --git a/src/material-experimental/mdc-slider/testing/slider-harness.spec.ts b/src/material-experimental/mdc-slider/testing/slider-harness.spec.ts deleted file mode 100644 index eae28344f6db..000000000000 --- a/src/material-experimental/mdc-slider/testing/slider-harness.spec.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * @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.io/license - */ - -import {Component} from '@angular/core'; -import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {HarnessLoader, parallel} from '@angular/cdk/testing'; -import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; -import {MatSliderModule} from '@angular/material-experimental/mdc-slider'; -import {MatSliderHarness} from './slider-harness'; -import {MatSliderThumbHarness} from './slider-thumb-harness'; -import {ThumbPosition} from './slider-harness-filters'; - -describe('MDC-based MatSliderHarness', () => { - let fixture: ComponentFixture; - let loader: HarnessLoader; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSliderModule], - declarations: [SliderHarnessTest], - }).compileComponents(); - - fixture = TestBed.createComponent(SliderHarnessTest); - fixture.detectChanges(); - loader = TestbedHarnessEnvironment.loader(fixture); - }); - - it('should load all slider harnesses', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(sliders.length).toBe(2); - }); - - it('should get whether is a range slider', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await parallel(() => sliders.map(slider => slider.isRange()))).toEqual([false, true]); - }); - - it('should get whether a slider is disabled', async () => { - const slider = await loader.getHarness(MatSliderHarness); - expect(await slider.isDisabled()).toBe(false); - fixture.componentInstance.singleSliderDisabled = true; - expect(await slider.isDisabled()).toBe(true); - }); - - it('should get the min/max values of a single-thumb slider', async () => { - const slider = await loader.getHarness(MatSliderHarness); - const [min, max] = await parallel(() => [slider.getMinValue(), slider.getMaxValue()]); - expect(min).toBe(0); - expect(max).toBe(100); - }); - - it('should get the min/max values of a range slider', async () => { - const slider = await loader.getHarness(MatSliderHarness.with({isRange: true})); - const [min, max] = await parallel(() => [slider.getMinValue(), slider.getMaxValue()]); - expect(min).toBe(fixture.componentInstance.rangeSliderMin); - expect(max).toBe(fixture.componentInstance.rangeSliderMax); - }); - - it('should get the thumbs within a slider', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await sliders[0].getEndThumb()).toBeTruthy(); - expect(await sliders[1].getStartThumb()).toBeTruthy(); - expect(await sliders[1].getEndThumb()).toBeTruthy(); - }); - - it('should throw when trying to get the start thumb from a single point slider', async () => { - const slider = await loader.getHarness(MatSliderHarness.with({isRange: false})); - await expectAsync(slider.getStartThumb()).toBeRejectedWithError( - '`getStartThumb` is only applicable for range sliders. ' + - 'Did you mean to use `getEndThumb`?', - ); - }); - - it('should get the step of a slider', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect( - await parallel(() => { - return sliders.map(slider => slider.getStep()); - }), - ).toEqual([1, fixture.componentInstance.rangeSliderStep]); - }); - - it('should get the position of a slider thumb in a range slider', async () => { - const slider = await loader.getHarness(MatSliderHarness.with({selector: '#range'})); - const [start, end] = await parallel(() => [slider.getStartThumb(), slider.getEndThumb()]); - expect(await start.getPosition()).toBe(ThumbPosition.START); - expect(await end.getPosition()).toBe(ThumbPosition.END); - }); - - it('should get the position of a slider thumb in a non-range slider', async () => { - const thumb = await loader.getHarness(MatSliderThumbHarness.with({ancestor: '#single'})); - expect(await thumb.getPosition()).toBe(ThumbPosition.END); - }); - - it('should get and set the value of a slider thumb', async () => { - const slider = await loader.getHarness(MatSliderHarness); - const thumb = await slider.getEndThumb(); - expect(await thumb.getValue()).toBe(0); - await thumb.setValue(73); - expect(await thumb.getValue()).toBe(73); - }); - - it('should dispatch input and change events when setting the value', async () => { - const slider = await loader.getHarness(MatSliderHarness); - const thumb = await slider.getEndThumb(); - const changeSpy = spyOn(fixture.componentInstance, 'changeListener'); - const inputSpy = spyOn(fixture.componentInstance, 'inputListener'); - await thumb.setValue(73); - expect(changeSpy).toHaveBeenCalledTimes(1); - expect(inputSpy).toHaveBeenCalledTimes(1); - expect(await thumb.getValue()).toBe(73); - }); - - it('should get the value of a thumb as a percentage', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await (await sliders[0].getEndThumb()).getPercentage()).toBe(0); - expect(await (await sliders[1].getStartThumb()).getPercentage()).toBe(0.4); - expect(await (await sliders[1].getEndThumb()).getPercentage()).toBe(0.5); - }); - - it('should get the display value of a slider thumb', async () => { - const slider = await loader.getHarness(MatSliderHarness); - const thumb = await slider.getEndThumb(); - fixture.componentInstance.displayFn = value => `#${value}`; - await thumb.setValue(73); - expect(await thumb.getDisplayValue()).toBe('#73'); - }); - - it('should get the min/max values of a slider thumb', async () => { - const instance = fixture.componentInstance; - const slider = await loader.getHarness(MatSliderHarness.with({selector: '#range'})); - const [start, end] = await parallel(() => [slider.getStartThumb(), slider.getEndThumb()]); - - expect(await start.getMinValue()).toBe(instance.rangeSliderMin); - expect(await start.getMaxValue()).toBe(instance.rangeSliderEndValue); - expect(await end.getMinValue()).toBe(instance.rangeSliderStartValue); - expect(await end.getMaxValue()).toBe(instance.rangeSliderMax); - }); - - it('should get the disabled state of a slider thumb', async () => { - const slider = await loader.getHarness(MatSliderHarness); - const thumb = await slider.getEndThumb(); - - expect(await thumb.isDisabled()).toBe(false); - fixture.componentInstance.singleSliderDisabled = true; - expect(await thumb.isDisabled()).toBe(true); - }); - - it('should get the name of a slider thumb', async () => { - const slider = await loader.getHarness(MatSliderHarness); - expect(await (await slider.getEndThumb()).getName()).toBe('price'); - }); - - it('should get the id of a slider thumb', async () => { - const slider = await loader.getHarness(MatSliderHarness); - expect(await (await slider.getEndThumb()).getId()).toBe('price-input'); - }); - - it('should be able to focus and blur a slider thumb', async () => { - const slider = await loader.getHarness(MatSliderHarness); - const thumb = await slider.getEndThumb(); - - expect(await thumb.isFocused()).toBe(false); - await thumb.focus(); - expect(await thumb.isFocused()).toBe(true); - await thumb.blur(); - expect(await thumb.isFocused()).toBe(false); - }); -}); - -@Component({ - template: ` - - - - - - - - - `, -}) -class SliderHarnessTest { - singleSliderDisabled = false; - rangeSliderMin = 100; - rangeSliderMax = 500; - rangeSliderStep = 50; - rangeSliderStartValue = 200; - rangeSliderEndValue = 350; - displayFn = (value: number) => value + ''; - inputListener() {} - changeListener() {} -} diff --git a/src/material-experimental/mdc-slider/testing/slider-harness.ts b/src/material-experimental/mdc-slider/testing/slider-harness.ts deleted file mode 100644 index f4bfdef83de4..000000000000 --- a/src/material-experimental/mdc-slider/testing/slider-harness.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * @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.io/license - */ - -import { - ComponentHarness, - ComponentHarnessConstructor, - HarnessPredicate, -} from '@angular/cdk/testing'; -import {coerceNumberProperty} from '@angular/cdk/coercion'; -import {SliderHarnessFilters, ThumbPosition} from './slider-harness-filters'; -import {MatSliderThumbHarness} from './slider-thumb-harness'; - -/** Harness for interacting with a MDC mat-slider in tests. */ -export class MatSliderHarness extends ComponentHarness { - static hostSelector = '.mat-mdc-slider'; - - /** - * Gets a `HarnessPredicate` that can be used to search for a slider 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: SliderHarnessFilters = {}, - ): HarnessPredicate { - return new HarnessPredicate(this, options).addOption( - 'isRange', - options.isRange, - async (harness, value) => { - return (await harness.isRange()) === value; - }, - ); - } - - /** Gets the start thumb of the slider (only applicable for range sliders). */ - async getStartThumb(): Promise { - if (!(await this.isRange())) { - throw Error( - '`getStartThumb` is only applicable for range sliders. ' + - 'Did you mean to use `getEndThumb`?', - ); - } - return this.locatorFor(MatSliderThumbHarness.with({position: ThumbPosition.START}))(); - } - - /** Gets the thumb (for single point sliders), or the end thumb (for range sliders). */ - async getEndThumb(): Promise { - return this.locatorFor(MatSliderThumbHarness.with({position: ThumbPosition.END}))(); - } - - /** Gets whether the slider is a range slider. */ - async isRange(): Promise { - return await (await this.host()).hasClass('mdc-slider--range'); - } - - /** Gets whether the slider is disabled. */ - async isDisabled(): Promise { - return await (await this.host()).hasClass('mdc-slider--disabled'); - } - - /** Gets the value step increments of the slider. */ - async getStep(): Promise { - // The same step value is forwarded to both thumbs. - const startHost = await (await this.getEndThumb()).host(); - return coerceNumberProperty(await startHost.getProperty('step')); - } - - /** Gets the maximum value of the slider. */ - async getMaxValue(): Promise { - return (await this.getEndThumb()).getMaxValue(); - } - - /** Gets the minimum value of the slider. */ - async getMinValue(): Promise { - const startThumb = (await this.isRange()) - ? await this.getStartThumb() - : await this.getEndThumb(); - return startThumb.getMinValue(); - } -} diff --git a/src/material/_index.scss b/src/material/_index.scss index 03d9f9a7a389..4681e82bc2f6 100644 --- a/src/material/_index.scss +++ b/src/material/_index.scss @@ -139,7 +139,9 @@ @forward './legacy-slide-toggle/slide-toggle-theme' as legacy-slide-toggle-* show legacy-slide-toggle-theme, legacy-slide-toggle-color, legacy-slide-toggle-typography; @forward './slide-toggle/slide-toggle-theme' as slide-toggle-* show -slide-toggle-theme, slide-toggle-color, slide-toggle-typography, slide-toggle-density; + slide-toggle-theme, slide-toggle-color, slide-toggle-typography, slide-toggle-density; +@forward './legacy-slider/slider-theme' as legacy-slider-* show legacy-slider-theme, + legacy-slider-color, legacy-slider-typography; @forward './slider/slider-theme' as slider-* show slider-theme, slider-color, slider-typography; @forward './snack-bar/snack-bar-theme' as snack-bar-* show snack-bar-theme, snack-bar-color, snack-bar-typography; diff --git a/src/material/_theming.scss b/src/material/_theming.scss index 5ea56d0b3f1b..57691de120fa 100644 --- a/src/material/_theming.scss +++ b/src/material/_theming.scss @@ -32,7 +32,7 @@ @forward './legacy-select/select-legacy-index'; @forward './sidenav/sidenav-legacy-index'; @forward './legacy-slide-toggle/slide-toggle-legacy-index'; -@forward './slider/slider-legacy-index'; +@forward './legacy-slider/slider-legacy-index'; @forward './snack-bar/snack-bar-legacy-index'; @forward './sort/sort-legacy-index'; @forward './stepper/stepper-legacy-index'; diff --git a/src/material/config.bzl b/src/material/config.bzl index ac5cfc59de77..6d97f37ff115 100644 --- a/src/material/config.bzl +++ b/src/material/config.bzl @@ -78,6 +78,8 @@ entryPoints = [ "legacy-slide-toggle/testing", "slider", "slider/testing", + "legacy-slider", + "legacy-slider/testing", "snack-bar", "snack-bar/testing", "sort", diff --git a/src/material/core/density/private/_all-density.scss b/src/material/core/density/private/_all-density.scss index cdc4fe57ff81..0356e4432136 100644 --- a/src/material/core/density/private/_all-density.scss +++ b/src/material/core/density/private/_all-density.scss @@ -19,6 +19,7 @@ @use '../../../chips/chips-theme'; @use '../../../slide-toggle/slide-toggle-theme'; @use '../../../radio/radio-theme'; +@use '../../../slider/slider-theme'; @mixin private-all-unmigrated-component-densities($config) { @include expansion-theme.density($config); @@ -59,6 +60,7 @@ @include chips-theme.density($config); @include slide-toggle-theme.density($config); @include radio-theme.density($config); + @include slider-theme.density($config); @include private-all-unmigrated-component-densities($config); } diff --git a/src/material/core/theming/_all-theme.scss b/src/material/core/theming/_all-theme.scss index 281854ef7da8..856b675088ee 100644 --- a/src/material/core/theming/_all-theme.scss +++ b/src/material/core/theming/_all-theme.scss @@ -53,7 +53,6 @@ @include paginator-theme.theme($theme-or-color-config); @include progress-spinner-theme.theme($theme-or-color-config); @include sidenav-theme.theme($theme-or-color-config); - @include slider-theme.theme($theme-or-color-config); @include stepper-theme.theme($theme-or-color-config); @include sort-theme.theme($theme-or-color-config); @include tabs-theme.theme($theme-or-color-config); @@ -78,6 +77,7 @@ @include chips-theme.theme($theme-or-color-config); @include slide-toggle-theme.theme($theme-or-color-config); @include radio-theme.theme($theme-or-color-config); + @include slider-theme.theme($theme-or-color-config); @include private-all-unmigrated-component-themes($theme-or-color-config); } } diff --git a/src/material/core/theming/tests/test-css-variables-theme.scss b/src/material/core/theming/tests/test-css-variables-theme.scss index 91e536d82bd5..f448ecad2509 100644 --- a/src/material/core/theming/tests/test-css-variables-theme.scss +++ b/src/material/core/theming/tests/test-css-variables-theme.scss @@ -18,7 +18,6 @@ @use '../../../paginator/paginator-theme'; @use '../../../progress-spinner/progress-spinner-theme'; @use '../../../sidenav/sidenav-theme'; -@use '../../../slider/slider-theme'; @use '../../../stepper/stepper-theme'; @use '../../../sort/sort-theme'; @use '../../../tabs/tabs-theme'; @@ -69,7 +68,6 @@ @include paginator-theme.theme($css-var-theme); @include progress-spinner-theme.theme($css-var-theme); @include sidenav-theme.theme($css-var-theme); - @include slider-theme.theme($css-var-theme); @include stepper-theme.theme($css-var-theme); @include sort-theme.theme($css-var-theme); @include tabs-theme.theme($css-var-theme); diff --git a/src/material/core/typography/_all-typography.scss b/src/material/core/typography/_all-typography.scss index e9830011b4d0..43ba49fbc6be 100644 --- a/src/material/core/typography/_all-typography.scss +++ b/src/material/core/typography/_all-typography.scss @@ -54,7 +54,6 @@ @include paginator-theme.typography($config); @include progress-spinner-theme.typography($config); @include sidenav-theme.typography($config); - @include slider-theme.typography($config); @include stepper-theme.typography($config); @include sort-theme.typography($config); @include tabs-theme.typography($config); @@ -94,6 +93,7 @@ @include chips-theme.typography($config); @include slide-toggle-theme.typography($config); @include radio-theme.typography($config); + @include slider-theme.typography($config); } // @deprecated Use `all-component-typographies`. diff --git a/src/material/legacy-core/theming/_all-theme.scss b/src/material/legacy-core/theming/_all-theme.scss index 0b447ed01ed8..9ccfe6103e7c 100644 --- a/src/material/legacy-core/theming/_all-theme.scss +++ b/src/material/legacy-core/theming/_all-theme.scss @@ -13,6 +13,7 @@ @use '../../legacy-chips/chips-theme'; @use '../../legacy-slide-toggle/slide-toggle-theme'; @use '../../legacy-radio/radio-theme'; +@use '../../legacy-slider/slider-theme'; // Create a theme. @mixin all-legacy-component-themes($theme-or-color-config) { @@ -31,6 +32,7 @@ @include chips-theme.theme($theme-or-color-config); @include slide-toggle-theme.theme($theme-or-color-config); @include radio-theme.theme($theme-or-color-config); + @include slider-theme.theme($theme-or-color-config); @include all-theme.private-all-unmigrated-component-themes($theme-or-color-config); } } diff --git a/src/material/legacy-core/typography/_all-typography.scss b/src/material/legacy-core/typography/_all-typography.scss index a8e2f367e123..99f492de1eeb 100644 --- a/src/material/legacy-core/typography/_all-typography.scss +++ b/src/material/legacy-core/typography/_all-typography.scss @@ -15,6 +15,7 @@ @use '../../legacy-chips/chips-theme'; @use '../../legacy-slide-toggle/slide-toggle-theme'; @use '../../legacy-radio/radio-theme'; +@use '../../legacy-slider/slider-theme'; // Includes all of the typographic styles. @mixin all-legacy-component-typographies($config-or-theme: null) { @@ -47,6 +48,7 @@ @include chips-theme.typography($config); @include slide-toggle-theme.typography($config); @include radio-theme.typography($config); + @include slider-theme.typography($config); } // @deprecated Use `all-legacy-component-typographies`. diff --git a/src/material/legacy-slider/BUILD.bazel b/src/material/legacy-slider/BUILD.bazel new file mode 100644 index 000000000000..9a5f9eb0710b --- /dev/null +++ b/src/material/legacy-slider/BUILD.bazel @@ -0,0 +1,81 @@ +load( + "//tools:defaults.bzl", + "markdown_to_html", + "ng_module", + "ng_test_library", + "ng_web_test_suite", + "sass_binary", + "sass_library", +) + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "legacy-slider", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + assets = [":slider.css"] + glob(["**/*.html"]), + deps = [ + "//src/cdk/a11y", + "//src/cdk/bidi", + "//src/cdk/coercion", + "//src/cdk/keycodes", + "//src/cdk/platform", + "//src/material/core", + "@npm//@angular/animations", + "@npm//@angular/common", + "@npm//@angular/core", + "@npm//@angular/forms", + "@npm//@angular/platform-browser", + "@npm//rxjs", + ], +) + +sass_library( + name = "legacy_slider_scss_lib", + srcs = glob(["**/_*.scss"]), + deps = ["//src/material/core:core_scss_lib"], +) + +sass_binary( + name = "slider_scss", + src = "slider.scss", + deps = [ + "//src/cdk:sass_lib", + "//src/material/core:core_scss_lib", + ], +) + +ng_test_library( + name = "unit_test_sources", + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":legacy-slider", + "//src/cdk/bidi", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + "//src/material/testing", + "@npm//@angular/forms", + "@npm//@angular/platform-browser", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) + +markdown_to_html( + name = "overview", + srcs = [":slider.md"], +) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +) diff --git a/src/material/legacy-slider/README.md b/src/material/legacy-slider/README.md new file mode 100644 index 000000000000..0242d8a8153c --- /dev/null +++ b/src/material/legacy-slider/README.md @@ -0,0 +1 @@ +Please see the official documentation at https://material.angular.io/components/component/slider \ No newline at end of file diff --git a/src/material/legacy-slider/_slider-legacy-index.scss b/src/material/legacy-slider/_slider-legacy-index.scss new file mode 100644 index 000000000000..b59cf8e92b1a --- /dev/null +++ b/src/material/legacy-slider/_slider-legacy-index.scss @@ -0,0 +1,3 @@ +@forward 'slider-theme' hide color, theme, typography; +@forward 'slider-theme' as mat-legacy-slider-* hide mat-legacy-slider-density, + mat-legacy-slider-inner-content-theme; diff --git a/src/material/legacy-slider/_slider-theme.import.scss b/src/material/legacy-slider/_slider-theme.import.scss new file mode 100644 index 000000000000..33b2b790d0cb --- /dev/null +++ b/src/material/legacy-slider/_slider-theme.import.scss @@ -0,0 +1,8 @@ +@forward '../core/theming/theming.import'; +@forward '../core/typography/typography-utils.import'; +@forward 'slider-theme' hide color, theme, typography; +@forward 'slider-theme' as mat-slider-* hide mat-slider-density, mat-slider-inner-content-theme; + +@import '../core/theming/palette'; +@import '../core/theming/theming'; +@import '../core/typography/typography-utils'; diff --git a/src/material/legacy-slider/_slider-theme.scss b/src/material/legacy-slider/_slider-theme.scss new file mode 100644 index 000000000000..b6911d7006ab --- /dev/null +++ b/src/material/legacy-slider/_slider-theme.scss @@ -0,0 +1,204 @@ +@use 'sass:map'; +@use 'sass:meta'; +@use '../core/theming/theming'; +@use '../core/typography/typography'; +@use '../core/typography/typography-utils'; + +@mixin _inner-content-theme($palette) { + .mat-slider-track-fill, + .mat-slider-thumb, + .mat-slider-thumb-label { + background-color: theming.get-color-from-palette($palette); + } + + .mat-slider-thumb-label-text { + color: theming.get-color-from-palette($palette, default-contrast); + } + + .mat-slider-focus-ring { + $opacity: 0.2; + $color: theming.get-color-from-palette($palette, default, $opacity); + background-color: $color; + + // `mat-color` uses `rgba` for the opacity which won't work with + // CSS variables so we need to use `opacity` as a fallback. + @if (meta.type-of($color) != color) { + opacity: $opacity; + } + } +} + +@mixin color($config-or-theme) { + $config: theming.get-color-config($config-or-theme); + $primary: map.get($config, primary); + $accent: map.get($config, accent); + $warn: map.get($config, warn); + $background: map.get($config, background); + $foreground: map.get($config, foreground); + + $mat-slider-off-color: theming.get-color-from-palette($foreground, slider-off); + $mat-slider-off-focused-color: theming.get-color-from-palette($foreground, slider-off-active); + $mat-slider-disabled-color: theming.get-color-from-palette($foreground, slider-off); + $mat-slider-labeled-min-value-thumb-color: + theming.get-color-from-palette($foreground, slider-min); + $mat-slider-labeled-min-value-thumb-label-color: + theming.get-color-from-palette($foreground, slider-off); + $mat-slider-tick-opacity: 0.7; + $mat-slider-tick-color: + theming.get-color-from-palette($foreground, base, $mat-slider-tick-opacity); + $mat-slider-tick-size: 2px; + + .mat-slider-track-background { + background-color: $mat-slider-off-color; + } + + .mat-slider { + &.mat-primary { + @include _inner-content-theme($primary); + } + + &.mat-accent { + @include _inner-content-theme($accent); + } + + &.mat-warn { + @include _inner-content-theme($warn); + } + } + + .mat-slider:hover, + .mat-slider.cdk-focused { + .mat-slider-track-background { + background-color: $mat-slider-off-focused-color; + } + } + + .mat-slider.mat-slider-disabled { + .mat-slider-track-background, + .mat-slider-track-fill, + .mat-slider-thumb { + background-color: $mat-slider-disabled-color; + } + + &:hover { + .mat-slider-track-background { + background-color: $mat-slider-disabled-color; + } + } + } + + .mat-slider.mat-slider-min-value { + .mat-slider-focus-ring { + $opacity: 0.12; + $color: theming.get-color-from-palette($foreground, base, $opacity); + background-color: $color; + + // `mat-color` uses `rgba` for the opacity which won't work with + // CSS variables so we need to use `opacity` as a fallback. + @if (meta.type-of($color) != color) { + opacity: $opacity; + } + } + + &.mat-slider-thumb-label-showing { + .mat-slider-thumb, + .mat-slider-thumb-label { + background-color: $mat-slider-labeled-min-value-thumb-color; + } + + &.cdk-focused { + .mat-slider-thumb, + .mat-slider-thumb-label { + background-color: $mat-slider-labeled-min-value-thumb-label-color; + } + } + } + + &:not(.mat-slider-thumb-label-showing) { + .mat-slider-thumb { + border-color: $mat-slider-off-color; + background-color: transparent; + } + + &:hover, + &.cdk-focused { + .mat-slider-thumb { + border-color: $mat-slider-off-focused-color; + } + + &.mat-slider-disabled .mat-slider-thumb { + border-color: $mat-slider-disabled-color; + } + } + } + } + + .mat-slider-has-ticks .mat-slider-wrapper::after { + border-color: $mat-slider-tick-color; + + // `mat-color` uses `rgba` for the opacity which won't work with + // CSS variables so we need to use `opacity` as a fallback. + @if (meta.type-of($mat-slider-tick-color) != color) { + opacity: $mat-slider-tick-opacity; + } + } + + .mat-slider-horizontal .mat-slider-ticks { + background-image: repeating-linear-gradient(to right, $mat-slider-tick-color, + $mat-slider-tick-color $mat-slider-tick-size, transparent 0, transparent); + // Firefox doesn't draw the gradient correctly with 'to right' + // (see https://bugzilla.mozilla.org/show_bug.cgi?id=1314319). + background-image: -moz-repeating-linear-gradient(0.0001deg, $mat-slider-tick-color, + $mat-slider-tick-color $mat-slider-tick-size, transparent 0, transparent); + + // `mat-color` uses `rgba` for the opacity which won't work with + // CSS variables so we need to use `opacity` as a fallback. + @if (meta.type-of($mat-slider-tick-color) != color) { + opacity: $mat-slider-tick-opacity; + } + } + + .mat-slider-vertical .mat-slider-ticks { + background-image: repeating-linear-gradient(to bottom, $mat-slider-tick-color, + $mat-slider-tick-color $mat-slider-tick-size, transparent 0, transparent); + + // `mat-color` uses `rgba` for the opacity which won't work with + // CSS variables so we need to use `opacity` as a fallback. + @if (meta.type-of($mat-slider-tick-color) != color) { + opacity: $mat-slider-tick-opacity; + } + } +} + +@mixin typography($config-or-theme) { + $config: typography.private-typography-to-2014-config( + theming.get-typography-config($config-or-theme)); + .mat-slider-thumb-label-text { + font: { + family: typography-utils.font-family($config); + size: typography-utils.font-size($config, caption); + weight: typography-utils.font-weight($config, body-2); + } + } +} + +@mixin _density($config-or-theme) {} + +@mixin theme($theme-or-color-config) { + $theme: theming.private-legacy-get-theme($theme-or-color-config); + @include theming.private-check-duplicate-theme-styles($theme, 'mat-legacy-slider') { + $color: theming.get-color-config($theme); + $density: theming.get-density-config($theme); + $typography: theming.get-typography-config($theme); + + @if $color != null { + @include color($color); + } + @if $density != null { + @include _density($density); + } + @if $typography != null { + @include typography($typography); + } + } +} diff --git a/src/material-experimental/mdc-slider/index.ts b/src/material/legacy-slider/index.ts similarity index 100% rename from src/material-experimental/mdc-slider/index.ts rename to src/material/legacy-slider/index.ts diff --git a/src/material-experimental/mdc-slider/public-api.ts b/src/material/legacy-slider/public-api.ts similarity index 64% rename from src/material-experimental/mdc-slider/public-api.ts rename to src/material/legacy-slider/public-api.ts index 294dbb8a1be7..a203c15f18ac 100644 --- a/src/material-experimental/mdc-slider/public-api.ts +++ b/src/material/legacy-slider/public-api.ts @@ -6,5 +6,5 @@ * found in the LICENSE file at https://angular.io/license */ -export {MatSlider, MatSliderThumb, MatSliderDragEvent} from './slider'; -export {MatSliderModule} from './module'; +export * from './slider-module'; +export * from './slider'; diff --git a/src/material/slider/slider-module.ts b/src/material/legacy-slider/slider-module.ts similarity index 71% rename from src/material/slider/slider-module.ts rename to src/material/legacy-slider/slider-module.ts index 04ddd8156dbc..bf71d7d254c7 100644 --- a/src/material/slider/slider-module.ts +++ b/src/material/legacy-slider/slider-module.ts @@ -9,11 +9,11 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {MatCommonModule} from '@angular/material/core'; -import {MatSlider} from './slider'; +import {MatLegacySlider} from './slider'; @NgModule({ imports: [CommonModule, MatCommonModule], - exports: [MatSlider, MatCommonModule], - declarations: [MatSlider], + exports: [MatLegacySlider, MatCommonModule], + declarations: [MatLegacySlider], }) -export class MatSliderModule {} +export class MatLegacySliderModule {} diff --git a/src/material/legacy-slider/slider.html b/src/material/legacy-slider/slider.html new file mode 100644 index 000000000000..d607be8ade39 --- /dev/null +++ b/src/material/legacy-slider/slider.html @@ -0,0 +1,16 @@ +
+
+
+
+
+
+
+
+
+
+
+
+ {{displayValue}} +
+
+
diff --git a/src/material/legacy-slider/slider.md b/src/material/legacy-slider/slider.md new file mode 100644 index 000000000000..73be0d40e408 --- /dev/null +++ b/src/material/legacy-slider/slider.md @@ -0,0 +1,98 @@ +`` allows for the selection of a value from a range via mouse, touch, or keyboard, +similar to ``. + + + +### Selecting a value + +By default the minimum value of the slider is `0`, the maximum value is `100`, and the thumb moves +in increments of `1`. These values can be changed by setting the `min`, `max`, and `step` attributes +respectively. The initial value is set to the minimum value unless otherwise specified. + +```html + +``` + +### Orientation + +By default sliders are horizontal with the minimum value on the left and the maximum value on the +right. The `vertical` attribute can be added to a slider to make it vertical with the minimum value +on bottom and the maximum value on top. + +```html + +``` + +An `invert` attribute is also available which can be specified to flip the axis that the thumb moves +along. An inverted horizontal slider will have the minimum value on the right and the maximum value +on the left, while an inverted vertical slider will have the minimum value on top and the maximum +value on bottom. + +```html + +``` + +### Thumb label +By default, the exact selected value of a slider is not visible to the user. However, this value can +be added to the thumb by adding the `thumbLabel` attribute. + +The [Material Design spec](https://material.io/design/components/sliders.html#discrete-slider) recommends using the +`thumbLabel` attribute (along with `tickInterval="1"`) only for sliders that are used to display a +discrete value (such as a 1-5 rating). + +```html + +``` + +### Formatting the thumb label +By default, the value in the slider's thumb label will be the same as the model value, however this +may end up being too large to fit into the label. If you want to control the value that is being +displayed, you can do so using the `displayWith` input. + + + +### Tick marks +By default, sliders do not show tick marks along the thumb track. This can be enabled using the +`tickInterval` attribute. The value of `tickInterval` should be a number representing the number +of steps between ticks. For example a `tickInterval` of `3` with a `step` of `4` will draw +tick marks at every `3` steps, which is the same as every `12` values. + +```html + +``` + +The `tickInterval` can also be set to `auto` which will automatically choose the number of steps +such that there is at least `30px` of space between ticks. + +```html + +``` + +The slider will always show a tick at the beginning and end of the track. If the remaining space +doesn't add up perfectly the last interval will be shortened or lengthened so that the tick can be +shown at the end of the track. + +The [Material Design spec](https://material.io/design/components/sliders.html#discrete-slider) recommends using the +`tickInterval` attribute (set to `1` along with the `thumbLabel` attribute) only for sliders that +are used to display a discrete value (such as a 1-5 rating). + + +### Keyboard interaction +The slider has the following keyboard bindings: + +| Key | Action | +|-------------|------------------------------------------------------------------------------------| +| Right arrow | Increment the slider value by one step (decrements in RTL). | +| Up arrow | Increment the slider value by one step. | +| Left arrow | Decrement the slider value by one step (increments in RTL). | +| Down arrow | Decrement the slider value by one step. | +| Page up | Increment the slider value by 10 steps. | +| Page down | Decrement the slider value by 10 steps. | +| End | Set the value to the maximum possible. | +| Home | Set the value to the minimum possible. | + +### Accessibility + +`MatSlider` implements the ARIA `role="slider"` pattern, handling keyboard input and focus +management. Always provide an accessible label for each slider via `aria-label` or +`aria-labelledby`. diff --git a/src/material/legacy-slider/slider.scss b/src/material/legacy-slider/slider.scss new file mode 100644 index 000000000000..c16637bf9230 --- /dev/null +++ b/src/material/legacy-slider/slider.scss @@ -0,0 +1,495 @@ +@use '@angular/cdk'; +@use 'sass:math'; + +@use '../core/style/variables'; +@use '../core/style/vendor-prefixes'; + +// This refers to the thickness of the slider. On a horizontal slider this is the height, on a +// vertical slider this is the width. +$thickness: 48px !default; +$min-size: 128px !default; +$padding: 8px !default; + +$track-thickness: 2px !default; +$thumb-size: 20px !default; +$thumb-border-width: 3px !default; +$thumb-border-width-active: 2px !default; +$thumb-border-width-disabled: 4px !default; + +$thumb-default-scale: 0.7 !default; +$thumb-focus-scale: 1 !default; +$thumb-disabled-scale: 0.5 !default; + +$thumb-arrow-gap: 12px !default; + +$thumb-label-size: 28px !default; + +$tick-size: 2px !default; + +$focus-ring-size: 30px !default; + + +.mat-slider { + display: inline-block; + position: relative; + box-sizing: border-box; + padding: $padding; + outline: none; + vertical-align: middle; + + &:not(.mat-slider-disabled):active, + &.mat-slider-sliding:not(.mat-slider-disabled) { + cursor: grabbing; + } +} + +.mat-slider-wrapper { + // force browser to show background-color when using the print function + @include vendor-prefixes.color-adjust(exact); + position: absolute; +} + +.mat-slider-track-wrapper { + position: absolute; + top: 0; + left: 0; + overflow: hidden; +} + +.mat-slider-track-fill { + position: absolute; + transform-origin: 0 0; + transition: + transform variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, + background-color variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function; +} + +.mat-slider-track-background { + position: absolute; + transform-origin: 100% 100%; + transition: + transform variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, + background-color variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function; +} + +.mat-slider-ticks-container { + position: absolute; + left: 0; + top: 0; + overflow: hidden; +} + +.mat-slider-ticks { + @include vendor-prefixes.private-background-clip(content-box); + background-repeat: repeat; + box-sizing: border-box; + opacity: 0; + transition: opacity variables.$swift-ease-out-duration + variables.$swift-ease-out-timing-function; +} + +.mat-slider-thumb-container { + position: absolute; + z-index: 1; + transition: transform variables.$swift-ease-out-duration + variables.$swift-ease-out-timing-function; +} + +.mat-slider-focus-ring { + position: absolute; + width: $focus-ring-size; + height: $focus-ring-size; + border-radius: 50%; + transform: scale(0); + opacity: 0; + transition: + transform variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, + background-color variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, + opacity variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function; + + .mat-slider.cdk-keyboard-focused &, + .mat-slider.cdk-program-focused & { + transform: scale(1); + opacity: 1; + } +} + +%_mat-slider-cursor { + .mat-slider:not(.mat-slider-disabled):not(.mat-slider-sliding) & { + cursor: grab; + } +} + +.mat-slider-thumb { + @extend %_mat-slider-cursor; + + position: absolute; + right: math.div(-$thumb-size, 2); + bottom: math.div(-$thumb-size, 2); + box-sizing: border-box; + width: $thumb-size; + height: $thumb-size; + border: $thumb-border-width solid transparent; + border-radius: 50%; + transform: scale($thumb-default-scale); + transition: + transform variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, + background-color variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, + border-color variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function; +} + +.mat-slider-thumb-label { + @extend %_mat-slider-cursor; + + display: none; + align-items: center; + justify-content: center; + position: absolute; + width: $thumb-label-size; + height: $thumb-label-size; + border-radius: 50%; + transition: + transform variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, + border-radius variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, + background-color variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function; + + @include cdk.high-contrast(active, off) { + outline: solid 1px; + } +} + +.mat-slider-thumb-label-text { + z-index: 1; + opacity: 0; + transition: opacity variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function; +} + + +// Slider sliding state. +.mat-slider-sliding { + .mat-slider-track-fill, + .mat-slider-track-background, + .mat-slider-thumb-container { + // Must use `transition-duration: 0ms` to disable animation rather than `transition: none`. + // On Mobile Safari `transition: none` causes the slider thumb to appear stuck. + transition-duration: 0ms; + } +} + + +// Slider with ticks when not disabled. +.mat-slider-has-ticks { + + .mat-slider-wrapper::after { + content: ''; + position: absolute; + border-width: 0; + border-style: solid; + opacity: 0; + transition: opacity variables.$swift-ease-out-duration + variables.$swift-ease-out-timing-function; + } + + &.cdk-focused, + &:hover { + &:not(.mat-slider-hide-last-tick) { + .mat-slider-wrapper::after { + opacity: 1; + } + } + + &:not(.mat-slider-disabled) .mat-slider-ticks { + opacity: 1; + } + } +} + + +// Slider with thumb label. +.mat-slider-thumb-label-showing { + .mat-slider-focus-ring { + display: none; + } + + .mat-slider-thumb-label { + display: flex; + } +} + + +// Inverted slider. +.mat-slider-axis-inverted { + .mat-slider-track-fill { + transform-origin: 100% 100%; + } + + .mat-slider-track-background { + transform-origin: 0 0; + } +} + + +// Active slider. +.mat-slider:not(.mat-slider-disabled) { + &.cdk-focused { + &.mat-slider-thumb-label-showing .mat-slider-thumb { + transform: scale(0); + } + + .mat-slider-thumb-label { + border-radius: 50% 50% 0; + } + + .mat-slider-thumb-label-text { + opacity: 1; + } + } + + &.cdk-mouse-focused, + &.cdk-touch-focused, + &.cdk-program-focused { + .mat-slider-thumb { + border-width: $thumb-border-width-active; + transform: scale($thumb-focus-scale); + } + } +} + + +// Disabled slider. +.mat-slider-disabled { + .mat-slider-focus-ring { + transform: scale(0); + opacity: 0; + } + + .mat-slider-thumb { + border-width: $thumb-border-width-disabled; + transform: scale($thumb-disabled-scale); + } + + .mat-slider-thumb-label { + display: none; + } +} + + +// Horizontal slider. +.mat-slider-horizontal { + height: $thickness; + min-width: $min-size; + + .mat-slider-wrapper { + height: $track-thickness; + top: math.div($thickness - $track-thickness, 2); + left: $padding; + right: $padding; + } + + .mat-slider-wrapper::after { + height: $track-thickness; + border-left-width: $tick-size; + right: 0; + top: 0; + } + + .mat-slider-track-wrapper { + height: $track-thickness; + width: 100%; + } + + .mat-slider-track-fill { + height: $track-thickness; + width: 100%; + transform: scaleX(0); + } + + .mat-slider-track-background { + height: $track-thickness; + width: 100%; + transform: scaleX(1); + } + + .mat-slider-ticks-container { + height: $track-thickness; + width: 100%; + + @include cdk.high-contrast(active, off) { + height: 0; + outline: solid $track-thickness; + top: math.div($track-thickness, 2); + } + } + + .mat-slider-ticks { + height: $track-thickness; + width: 100%; + } + + .mat-slider-thumb-container { + width: 100%; + height: 0; + top: 50%; + } + + .mat-slider-focus-ring { + top: math.div(-$focus-ring-size, 2); + right: math.div(-$focus-ring-size, 2); + } + + .mat-slider-thumb-label { + right: math.div(-$thumb-label-size, 2); + top: -($thumb-label-size + $thumb-arrow-gap); + transform: translateY(math.div($thumb-label-size, 2) + $thumb-arrow-gap) + scale(0.01) + rotate(45deg); + } + + .mat-slider-thumb-label-text { + transform: rotate(-45deg); + } + + &.cdk-focused { + .mat-slider-thumb-label { + transform: rotate(45deg); + } + + @include cdk.high-contrast(active, off) { + .mat-slider-thumb-label, + .mat-slider-thumb-label-text { + transform: none; + } + } + } +} + + +// Vertical slider. +.mat-slider-vertical { + width: $thickness; + min-height: $min-size; + + .mat-slider-wrapper { + width: $track-thickness; + top: $padding; + bottom: $padding; + left: math.div($thickness - $track-thickness, 2); + } + + .mat-slider-wrapper::after { + width: $track-thickness; + border-top-width: $tick-size; + bottom: 0; + left: 0; + } + + .mat-slider-track-wrapper { + height: 100%; + width: $track-thickness; + } + + .mat-slider-track-fill { + height: 100%; + width: $track-thickness; + transform: scaleY(0); + } + + .mat-slider-track-background { + height: 100%; + width: $track-thickness; + transform: scaleY(1); + } + + .mat-slider-ticks-container { + width: $track-thickness; + height: 100%; + + @include cdk.high-contrast(active, off) { + width: 0; + outline: solid $track-thickness; + left: math.div($track-thickness, 2); + } + } + + .mat-slider-focus-ring { + bottom: math.div(-$focus-ring-size, 2); + left: math.div(-$focus-ring-size, 2); + } + + .mat-slider-ticks { + width: $track-thickness; + height: 100%; + } + + .mat-slider-thumb-container { + height: 100%; + width: 0; + left: 50%; + } + + .mat-slider-thumb { + @include vendor-prefixes.backface-visibility(hidden); + } + + .mat-slider-thumb-label { + bottom: math.div(-$thumb-label-size, 2); + left: -($thumb-label-size + $thumb-arrow-gap); + transform: translateX(math.div($thumb-label-size, 2) + $thumb-arrow-gap) + scale(0.01) + rotate(-45deg); + } + + .mat-slider-thumb-label-text { + transform: rotate(45deg); + } + + &.cdk-focused { + .mat-slider-thumb-label { + transform: rotate(-45deg); + } + } +} + + +// Slider in RTL languages. +[dir='rtl'] { + .mat-slider-wrapper::after { + left: 0; + right: auto; + } + + .mat-slider-horizontal { + .mat-slider-track-fill { + transform-origin: 100% 100%; + } + + .mat-slider-track-background { + transform-origin: 0 0; + } + + &.mat-slider-axis-inverted { + .mat-slider-track-fill { + transform-origin: 0 0; + } + + .mat-slider-track-background { + transform-origin: 100% 100%; + } + } + } +} + +// Slider inside a component with disabled animations. +.mat-slider._mat-animation-noopable { + .mat-slider-track-fill, + .mat-slider-track-background, + .mat-slider-ticks, + .mat-slider-thumb-container, + .mat-slider-focus-ring, + .mat-slider-thumb, + .mat-slider-thumb-label, + .mat-slider-thumb-label-text, + .mat-slider-has-ticks .mat-slider-wrapper::after { + transition: none; + } +} diff --git a/src/material/legacy-slider/slider.spec.ts b/src/material/legacy-slider/slider.spec.ts new file mode 100644 index 000000000000..5401ba4c7e56 --- /dev/null +++ b/src/material/legacy-slider/slider.spec.ts @@ -0,0 +1,1852 @@ +import {BidiModule} from '@angular/cdk/bidi'; +import { + BACKSPACE, + DOWN_ARROW, + END, + HOME, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + UP_ARROW, + A, +} from '@angular/cdk/keycodes'; +import { + createMouseEvent, + dispatchEvent, + dispatchFakeEvent, + dispatchKeyboardEvent, + dispatchMouseEvent, + createKeyboardEvent, + createTouchEvent, +} from '@angular/cdk/testing/private'; +import {Component, DebugElement, Type, ViewChild} from '@angular/core'; +import {ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {By} from '@angular/platform-browser'; +import {MatLegacySlider, MatLegacySliderModule} from './index'; + +describe('MatSlider', () => { + function createComponent(component: Type): ComponentFixture { + TestBed.configureTestingModule({ + imports: [MatLegacySliderModule, ReactiveFormsModule, FormsModule, BidiModule], + declarations: [component], + }).compileComponents(); + + return TestBed.createComponent(component); + } + + describe('standard slider', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatLegacySlider; + let trackFillElement: HTMLElement; + + beforeEach(() => { + fixture = createComponent(StandardSlider); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + + trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); + }); + + it('should set the default values', () => { + expect(sliderInstance.value).toBe(0); + expect(sliderInstance.min).toBe(0); + expect(sliderInstance.max).toBe(100); + }); + + it('should update the value on mousedown', () => { + expect(sliderInstance.value).toBe(0); + + dispatchMousedownEventSequence(sliderNativeElement, 0.19); + + expect(sliderInstance.value).toBe(19); + }); + + it('should not update when pressing the right mouse button', () => { + expect(sliderInstance.value).toBe(0); + + dispatchMousedownEventSequence(sliderNativeElement, 0.19, 1); + + expect(sliderInstance.value).toBe(0); + }); + + it('should update the value on a slide', () => { + expect(sliderInstance.value).toBe(0); + + dispatchSlideEventSequence(sliderNativeElement, 0, 0.89); + + expect(sliderInstance.value).toBe(89); + }); + + it('should set the value as min when sliding before the track', () => { + expect(sliderInstance.value).toBe(0); + + dispatchSlideEventSequence(sliderNativeElement, 0, -1.33); + + expect(sliderInstance.value).toBe(0); + }); + + it('should set the value as max when sliding past the track', () => { + expect(sliderInstance.value).toBe(0); + + dispatchSlideEventSequence(sliderNativeElement, 0, 1.75); + + expect(sliderInstance.value).toBe(100); + }); + + it('should update the track fill on mousedown', () => { + expect(trackFillElement.style.transform).toContain('scale3d(0, 1, 1)'); + + dispatchMousedownEventSequence(sliderNativeElement, 0.39); + fixture.detectChanges(); + + expect(trackFillElement.style.transform).toContain('scale3d(0.39, 1, 1)'); + }); + + it('should hide the fill element at zero percent', () => { + expect(trackFillElement.style.display).toBe('none'); + + dispatchMousedownEventSequence(sliderNativeElement, 0.39); + fixture.detectChanges(); + + expect(trackFillElement.style.display).toBeFalsy(); + }); + + it('should update the track fill on slide', () => { + expect(trackFillElement.style.transform).toContain('scale3d(0, 1, 1)'); + + dispatchSlideEventSequence(sliderNativeElement, 0, 0.86); + fixture.detectChanges(); + + expect(trackFillElement.style.transform).toContain('scale3d(0.86, 1, 1)'); + }); + + it('should add and remove the mat-slider-sliding class when sliding', () => { + expect(sliderNativeElement.classList).not.toContain('mat-slider-sliding'); + + dispatchSlideStartEvent(sliderNativeElement, 0); + fixture.detectChanges(); + + expect(sliderNativeElement.classList).toContain('mat-slider-sliding'); + + dispatchSlideEndEvent(sliderNativeElement, 0.34); + fixture.detectChanges(); + + expect(sliderNativeElement.classList).not.toContain('mat-slider-sliding'); + }); + + it('should not interrupt sliding by pressing a key', () => { + expect(sliderNativeElement.classList).not.toContain('mat-slider-sliding'); + + dispatchSlideStartEvent(sliderNativeElement, 0); + fixture.detectChanges(); + + expect(sliderNativeElement.classList).toContain('mat-slider-sliding'); + + // Any key code will do here. Use A since it isn't associated with other actions. + dispatchKeyboardEvent(sliderNativeElement, 'keydown', A); + fixture.detectChanges(); + dispatchKeyboardEvent(sliderNativeElement, 'keyup', A); + fixture.detectChanges(); + + expect(sliderNativeElement.classList).toContain('mat-slider-sliding'); + + dispatchSlideEndEvent(sliderNativeElement, 0.34); + fixture.detectChanges(); + + expect(sliderNativeElement.classList).not.toContain('mat-slider-sliding'); + }); + + it('should stop dragging if the page loses focus', () => { + const classlist = sliderNativeElement.classList; + + expect(classlist).not.toContain('mat-slider-sliding'); + + dispatchSlideStartEvent(sliderNativeElement, 0); + fixture.detectChanges(); + + expect(classlist).toContain('mat-slider-sliding'); + + dispatchSlideEvent(sliderNativeElement, 0.34); + fixture.detectChanges(); + + expect(classlist).toContain('mat-slider-sliding'); + + dispatchFakeEvent(window, 'blur'); + fixture.detectChanges(); + + expect(classlist).not.toContain('mat-slider-sliding'); + }); + + it('should reset active state upon blur', () => { + sliderInstance._isActive = true; + + dispatchFakeEvent(sliderNativeElement, 'blur'); + fixture.detectChanges(); + + expect(sliderInstance._isActive).toBe(false); + }); + + it('should reset thumb gap when blurred on min value', () => { + sliderInstance._isActive = true; + sliderInstance.value = 0; + fixture.detectChanges(); + + expect(sliderInstance._getThumbGap()).toBe(10); + + dispatchFakeEvent(sliderNativeElement, 'blur'); + fixture.detectChanges(); + + expect(sliderInstance._getThumbGap()).toBe(7); + }); + + it('should have thumb gap when at min value', () => { + expect(trackFillElement.style.transform).toContain('translateX(-7px)'); + }); + + it('should not have thumb gap when not at min value', () => { + dispatchMousedownEventSequence(sliderNativeElement, 1); + fixture.detectChanges(); + + // Some browsers use '0' and some use '0px', so leave off the closing paren. + expect(trackFillElement.style.transform).toContain('translateX(0'); + }); + + it('should have aria-orientation horizontal', () => { + expect(sliderNativeElement.getAttribute('aria-orientation')).toEqual('horizontal'); + }); + + it('should slide to the max value when the steps do not divide evenly into it', () => { + sliderInstance.min = 5; + sliderInstance.max = 100; + sliderInstance.step = 15; + + dispatchSlideEventSequence(sliderNativeElement, 0, 1); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(100); + }); + + it('should prevent the default action of the `selectstart` event', () => { + const event = dispatchFakeEvent(sliderNativeElement, 'selectstart'); + fixture.detectChanges(); + + expect(event.defaultPrevented).toBe(true); + }); + + it('should have a focus indicator', () => { + expect(sliderNativeElement.classList.contains('mat-focus-indicator')).toBe(true); + }); + + it('should not try to preventDefault on a non-cancelable event', () => { + const event = createTouchEvent('touchstart'); + const spy = spyOn(event, 'preventDefault'); + Object.defineProperty(event, 'cancelable', {value: false}); + + dispatchEvent(sliderNativeElement, event); + fixture.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('disabled slider', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatLegacySlider; + let trackFillElement: HTMLElement; + + beforeEach(() => { + fixture = createComponent(DisabledSlider); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); + }); + + it('should be disabled', () => { + expect(sliderInstance.disabled).toBeTruthy(); + }); + + it('should not change the value on mousedown when disabled', () => { + expect(sliderInstance.value).toBe(0); + + dispatchMousedownEventSequence(sliderNativeElement, 0.63); + + expect(sliderInstance.value).toBe(0); + }); + + it('should not change the value on slide when disabled', () => { + expect(sliderInstance.value).toBe(0); + + dispatchSlideEventSequence(sliderNativeElement, 0, 0.5); + + expect(sliderInstance.value).toBe(0); + }); + + it('should not emit change when disabled', () => { + const onChangeSpy = jasmine.createSpy('slider onChange'); + sliderInstance.change.subscribe(onChangeSpy); + + dispatchSlideEventSequence(sliderNativeElement, 0, 0.5); + + expect(onChangeSpy).toHaveBeenCalledTimes(0); + }); + + it('should not add the mat-slider-active class on mousedown when disabled', () => { + expect(sliderNativeElement.classList).not.toContain('mat-slider-active'); + + dispatchMousedownEventSequence(sliderNativeElement, 0.43); + fixture.detectChanges(); + + expect(sliderNativeElement.classList).not.toContain('mat-slider-active'); + }); + + it('should not add the mat-slider-sliding class on slide when disabled', () => { + expect(sliderNativeElement.classList).not.toContain('mat-slider-sliding'); + + dispatchSlideStartEvent(sliderNativeElement, 0.46); + fixture.detectChanges(); + + expect(sliderNativeElement.classList).not.toContain('mat-slider-sliding'); + }); + + it('should leave thumb gap', () => { + expect(trackFillElement.style.transform).toContain('translateX(-7px)'); + }); + + it('should disable tabbing to the slider', () => { + expect(sliderNativeElement.tabIndex).toBe(-1); + }); + }); + + describe('slider with set min and max', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatLegacySlider; + let trackFillElement: HTMLElement; + let ticksContainerElement: HTMLElement; + let ticksElement: HTMLElement; + let testComponent: SliderWithMinAndMax; + + beforeEach(() => { + fixture = createComponent(SliderWithMinAndMax); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + testComponent = fixture.debugElement.componentInstance; + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MatLegacySlider); + trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); + ticksContainerElement = ( + sliderNativeElement.querySelector('.mat-slider-ticks-container') + ); + ticksElement = sliderNativeElement.querySelector('.mat-slider-ticks'); + }); + + it('should set the default values from the attributes', () => { + expect(sliderInstance.value).toBe(4); + expect(sliderInstance.min).toBe(4); + expect(sliderInstance.max).toBe(6); + }); + + it('should set the correct value on mousedown', () => { + dispatchMousedownEventSequence(sliderNativeElement, 0.09); + fixture.detectChanges(); + + // Computed by multiplying the difference between the min and the max by the percentage from + // the mousedown and adding that to the minimum. + const value = Math.round(4 + 0.09 * (6 - 4)); + expect(sliderInstance.value).toBe(value); + }); + + it('should set the correct value on slide', () => { + dispatchSlideEventSequence(sliderNativeElement, 0, 0.62); + fixture.detectChanges(); + + // Computed by multiplying the difference between the min and the max by the percentage from + // the mousedown and adding that to the minimum. + const value = Math.round(4 + 0.62 * (6 - 4)); + expect(sliderInstance.value).toBe(value); + }); + + it('should snap the fill to the nearest value on mousedown', () => { + dispatchMousedownEventSequence(sliderNativeElement, 0.68); + fixture.detectChanges(); + + // The closest snap is halfway on the slider. + expect(trackFillElement.style.transform).toContain('scale3d(0.5, 1, 1)'); + }); + + it('should snap the fill to the nearest value on slide', () => { + dispatchSlideEventSequence(sliderNativeElement, 0, 0.74); + fixture.detectChanges(); + + // The closest snap is at the halfway point on the slider. + expect(trackFillElement.style.transform).toContain('scale3d(0.5, 1, 1)'); + }); + + it('should adjust fill and ticks on mouse enter when min changes', () => { + testComponent.min = -2; + fixture.detectChanges(); + + dispatchMouseenterEvent(sliderNativeElement); + fixture.detectChanges(); + + expect(trackFillElement.style.transform).toContain('scale3d(0.75, 1, 1)'); + expect(ticksElement.style.backgroundSize).toBe('75% 2px'); + // Make sure it cuts off the last half tick interval. + expect(ticksElement.style.transform).toContain('translateX(37.5%)'); + expect(ticksContainerElement.style.transform).toBe('translateX(-37.5%)'); + }); + + it('should adjust fill and ticks on mouse enter when max changes', () => { + testComponent.min = -2; + fixture.detectChanges(); + + testComponent.max = 10; + fixture.detectChanges(); + + dispatchMouseenterEvent(sliderNativeElement); + fixture.detectChanges(); + + expect(trackFillElement.style.transform).toContain('scale3d(0.5, 1, 1)'); + expect(ticksElement.style.backgroundSize).toBe('50% 2px'); + // Make sure it cuts off the last half tick interval. + expect(ticksElement.style.transform).toContain('translateX(25%)'); + expect(ticksContainerElement.style.transform).toBe('translateX(-25%)'); + }); + + it( + 'should be able to set the min and max values when they are more precise ' + 'than the step', + () => { + // Note that we assign min/max with more decimals than the + // step to ensure that the value doesn't get rounded up. + testComponent.step = 0.5; + testComponent.min = 10.15; + testComponent.max = 50.15; + fixture.detectChanges(); + + dispatchSlideEventSequence(sliderNativeElement, 0.5, 0); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(10.15); + expect(sliderInstance.percent).toBe(0); + + dispatchSlideEventSequence(sliderNativeElement, 0.5, 1); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(50.15); + expect(sliderInstance.percent).toBe(1); + }, + ); + it('should properly update ticks when max value changed to 0', () => { + testComponent.min = 0; + testComponent.max = 100; + fixture.detectChanges(); + + dispatchMouseenterEvent(sliderNativeElement); + fixture.detectChanges(); + + expect(ticksElement.style.backgroundSize).toBe('6% 2px'); + expect(ticksElement.style.transform).toContain('translateX(3%)'); + + testComponent.max = 0; + fixture.detectChanges(); + + dispatchMouseenterEvent(sliderNativeElement); + fixture.detectChanges(); + + expect(ticksElement.style.backgroundSize).toBe('0% 2px'); + expect(ticksElement.style.transform).toContain('translateX(0%)'); + }); + }); + + describe('slider with set value', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatLegacySlider; + + beforeEach(() => { + fixture = createComponent(SliderWithValue); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MatLegacySlider); + }); + + it('should set the default value from the attribute', () => { + expect(sliderInstance.value).toBe(26); + }); + + it('should set the correct value on mousedown', () => { + dispatchMousedownEventSequence(sliderNativeElement, 0.92); + fixture.detectChanges(); + + // On a slider with default max and min the value should be approximately equal to the + // percentage clicked. This should be the case regardless of what the original set value was. + expect(sliderInstance.value).toBe(92); + }); + + it('should set the correct value on slide', () => { + dispatchSlideEventSequence(sliderNativeElement, 0, 0.32); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(32); + }); + }); + + describe('slider with set step', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatLegacySlider; + let trackFillElement: HTMLElement; + + beforeEach(() => { + fixture = createComponent(SliderWithStep); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MatLegacySlider); + trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); + }); + + it('should set the correct step value on mousedown', () => { + expect(sliderInstance.value).toBe(0); + + dispatchMousedownEventSequence(sliderNativeElement, 0.13); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(25); + }); + + it('should snap the fill to a step on mousedown', () => { + dispatchMousedownEventSequence(sliderNativeElement, 0.66); + fixture.detectChanges(); + + // The closest step is at 75% of the slider. + expect(trackFillElement.style.transform).toContain('scale3d(0.75, 1, 1)'); + }); + + it('should set the correct step value on slide', () => { + dispatchSlideEventSequence(sliderNativeElement, 0, 0.07); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(0); + }); + + it('should snap the thumb and fill to a step on slide', () => { + dispatchSlideEventSequence(sliderNativeElement, 0, 0.88); + fixture.detectChanges(); + + // The closest snap is at the end of the slider. + expect(trackFillElement.style.transform).toContain('scale3d(1, 1, 1)'); + }); + + it('should not add decimals to the value if it is a whole number', () => { + fixture.componentInstance.step = 0.1; + fixture.detectChanges(); + + dispatchSlideEventSequence(sliderNativeElement, 0, 1); + + expect(sliderDebugElement.componentInstance.displayValue).toBe(100); + }); + + it('should truncate long decimal values when using a decimal step', () => { + fixture.componentInstance.step = 0.1; + fixture.detectChanges(); + + dispatchSlideEventSequence(sliderNativeElement, 0, 0.333333); + + expect(sliderInstance.value).toBe(33); + }); + + it('should truncate long decimal values when using a decimal step and the arrow keys', () => { + fixture.componentInstance.step = 0.1; + fixture.detectChanges(); + + for (let i = 0; i < 3; i++) { + dispatchKeyboardEvent(sliderNativeElement, 'keydown', UP_ARROW); + } + + expect(sliderInstance.value).toBe(0.3); + }); + + it('should set the truncated value to the aria-valuetext', () => { + fixture.componentInstance.step = 0.1; + fixture.detectChanges(); + + dispatchSlideEventSequence(sliderNativeElement, 0, 0.333333); + fixture.detectChanges(); + + expect(sliderNativeElement.getAttribute('aria-valuetext')).toBe('33'); + }); + + it('should be able to override the aria-valuetext', () => { + fixture.componentInstance.step = 0.1; + fixture.componentInstance.valueText = 'custom'; + fixture.detectChanges(); + + dispatchSlideEventSequence(sliderNativeElement, 0, 0.333333); + fixture.detectChanges(); + + expect(sliderNativeElement.getAttribute('aria-valuetext')).toBe('custom'); + }); + + it('should be able to clear aria-valuetext', () => { + fixture.componentInstance.valueText = ''; + fixture.detectChanges(); + + dispatchSlideEventSequence(sliderNativeElement, 0, 0.333333); + fixture.detectChanges(); + + expect(sliderNativeElement.getAttribute('aria-valuetext')).toBeFalsy(); + }); + }); + + describe('slider with auto ticks', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let ticksContainerElement: HTMLElement; + let ticksElement: HTMLElement; + + beforeEach(() => { + fixture = createComponent(SliderWithAutoTickInterval); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + ticksContainerElement = ( + sliderNativeElement.querySelector('.mat-slider-ticks-container') + ); + ticksElement = sliderNativeElement.querySelector('.mat-slider-ticks'); + }); + + it('should set the correct tick separation on mouse enter', () => { + dispatchMouseenterEvent(sliderNativeElement); + fixture.detectChanges(); + + // Ticks should be 30px apart (therefore 30% for a 100px long slider). + expect(ticksElement.style.backgroundSize).toBe('30% 2px'); + // Make sure it cuts off the last half tick interval. + expect(ticksElement.style.transform).toContain('translateX(15%)'); + expect(ticksContainerElement.style.transform).toBe('translateX(-15%)'); + }); + }); + + describe('slider with set tick interval', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let ticksContainerElement: HTMLElement; + let ticksElement: HTMLElement; + + beforeEach(() => { + fixture = createComponent(SliderWithSetTickInterval); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + ticksContainerElement = ( + sliderNativeElement.querySelector('.mat-slider-ticks-container') + ); + ticksElement = sliderNativeElement.querySelector('.mat-slider-ticks'); + }); + + it('should set the correct tick separation on mouse enter', () => { + dispatchMouseenterEvent(sliderNativeElement); + fixture.detectChanges(); + + // Ticks should be every 18 values (tickInterval of 6 * step size of 3). On a slider 100px + // long with 100 values, this is 18%. + expect(ticksElement.style.backgroundSize).toBe('18% 2px'); + // Make sure it cuts off the last half tick interval. + expect(ticksElement.style.transform).toContain('translateX(9%)'); + expect(ticksContainerElement.style.transform).toBe('translateX(-9%)'); + }); + + it('should be able to reset the tick interval after it has been set', () => { + expect(sliderNativeElement.classList) + .withContext('Expected element to have ticks initially.') + .toContain('mat-slider-has-ticks'); + + fixture.componentInstance.tickInterval = 0; + fixture.detectChanges(); + + expect(sliderNativeElement.classList).not.toContain( + 'mat-slider-has-ticks', + 'Expected element not to have ticks after reset.', + ); + }); + }); + + describe('slider with thumb label', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatLegacySlider; + let thumbLabelTextElement: Element; + + beforeEach(() => { + fixture = createComponent(SliderWithThumbLabel); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + thumbLabelTextElement = sliderNativeElement.querySelector('.mat-slider-thumb-label-text')!; + }); + + it('should add the thumb label class to the slider container', () => { + expect(sliderNativeElement.classList).toContain('mat-slider-thumb-label-showing'); + }); + + it('should update the thumb label text on mousedown', () => { + expect(thumbLabelTextElement.textContent).toBe('0'); + + dispatchMousedownEventSequence(sliderNativeElement, 0.13); + fixture.detectChanges(); + + // The thumb label text is set to the slider's value. These should always be the same. + expect(thumbLabelTextElement.textContent).toBe('13'); + }); + + it('should update the thumb label text on slide', () => { + expect(thumbLabelTextElement.textContent).toBe('0'); + + dispatchSlideEventSequence(sliderNativeElement, 0, 0.56); + fixture.detectChanges(); + + // The thumb label text is set to the slider's value. These should always be the same. + expect(thumbLabelTextElement.textContent).toBe(`${sliderInstance.value}`); + }); + }); + + describe('slider with custom thumb label formatting', () => { + let fixture: ComponentFixture; + let sliderInstance: MatLegacySlider; + let thumbLabelTextElement: Element; + + beforeEach(() => { + fixture = createComponent(SliderWithCustomThumbLabelFormatting); + fixture.detectChanges(); + + const sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + const sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + thumbLabelTextElement = sliderNativeElement.querySelector('.mat-slider-thumb-label-text')!; + }); + + it('should invoke the passed-in `displayWith` function with the value', () => { + spyOn(fixture.componentInstance, 'displayWith').and.callThrough(); + + sliderInstance.value = 1337; + fixture.detectChanges(); + + expect(fixture.componentInstance.displayWith).toHaveBeenCalledWith(1337); + }); + + it('should format the thumb label based on the passed-in `displayWith` function', () => { + sliderInstance.value = 200000; + fixture.detectChanges(); + + expect(thumbLabelTextElement.textContent).toBe('200k'); + }); + }); + + describe('slider with value property binding', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatLegacySlider; + let testComponent: SliderWithOneWayBinding; + let trackFillElement: HTMLElement; + + beforeEach(() => { + fixture = createComponent(SliderWithOneWayBinding); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MatLegacySlider); + trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); + }); + + it('should initialize based on bound value', () => { + expect(sliderInstance.value).toBe(50); + expect(trackFillElement.style.transform).toContain('scale3d(0.5, 1, 1)'); + }); + + it('should update when bound value changes', () => { + testComponent.val = 75; + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(75); + expect(trackFillElement.style.transform).toContain('scale3d(0.75, 1, 1)'); + }); + }); + + describe('slider with set min and max and a value smaller than min', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatLegacySlider; + let trackFillElement: HTMLElement; + + beforeEach(() => { + fixture = createComponent(SliderWithValueSmallerThanMin); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); + }); + + it('should set the value smaller than the min value', () => { + expect(sliderInstance.value).toBe(3); + expect(sliderInstance.min).toBe(4); + expect(sliderInstance.max).toBe(6); + }); + + it('should set the fill to the min value', () => { + expect(trackFillElement.style.transform).toContain('scale3d(0, 1, 1)'); + }); + }); + + describe('slider with set min and max and a value greater than max', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatLegacySlider; + let trackFillElement: HTMLElement; + + beforeEach(() => { + fixture = createComponent(SliderWithValueGreaterThanMax); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); + }); + + it('should set the value greater than the max value', () => { + expect(sliderInstance.value).toBe(7); + expect(sliderInstance.min).toBe(4); + expect(sliderInstance.max).toBe(6); + }); + + it('should set the fill to the max value', () => { + expect(trackFillElement.style.transform).toContain('scale3d(1, 1, 1)'); + }); + }); + + describe('slider with change handler', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let testComponent: SliderWithChangeHandler; + + beforeEach(() => { + fixture = createComponent(SliderWithChangeHandler); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + spyOn(testComponent, 'onChange'); + spyOn(testComponent, 'onInput'); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + }); + + it('should emit change on mouseup', () => { + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchMousedownEventSequence(sliderNativeElement, 0.2); + fixture.detectChanges(); + dispatchSlideEndEvent(sliderNativeElement, 0.2); + + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + }); + + it('should emit change on slide', () => { + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchSlideEventSequence(sliderNativeElement, 0, 0.4); + fixture.detectChanges(); + + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + }); + + it('should not emit multiple changes for the same value', () => { + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchMousedownEventSequence(sliderNativeElement, 0.6); + dispatchSlideEventSequence(sliderNativeElement, 0.6, 0.6); + fixture.detectChanges(); + + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + }); + + it( + 'should dispatch events when changing back to previously emitted value after ' + + 'programmatically setting value', + () => { + expect(testComponent.onChange).not.toHaveBeenCalled(); + expect(testComponent.onInput).not.toHaveBeenCalled(); + + dispatchMousedownEventSequence(sliderNativeElement, 0.2); + fixture.detectChanges(); + + expect(testComponent.onChange).not.toHaveBeenCalled(); + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + + dispatchSlideEndEvent(sliderNativeElement, 0.2); + fixture.detectChanges(); + + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + + testComponent.slider.value = 0; + fixture.detectChanges(); + + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + + dispatchMousedownEventSequence(sliderNativeElement, 0.2); + fixture.detectChanges(); + dispatchSlideEndEvent(sliderNativeElement, 0.2); + + expect(testComponent.onChange).toHaveBeenCalledTimes(2); + expect(testComponent.onInput).toHaveBeenCalledTimes(2); + }, + ); + }); + + describe('slider with input event', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let testComponent: SliderWithChangeHandler; + + beforeEach(() => { + fixture = createComponent(SliderWithChangeHandler); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + spyOn(testComponent, 'onInput'); + spyOn(testComponent, 'onChange'); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + }); + + it('should emit an input event while sliding', () => { + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchMouseenterEvent(sliderNativeElement); + dispatchSlideStartEvent(sliderNativeElement, 0); + dispatchSlideEvent(sliderNativeElement, 0.5); + dispatchSlideEvent(sliderNativeElement, 1); + dispatchSlideEndEvent(sliderNativeElement, 1); + + fixture.detectChanges(); + + // The input event should fire twice, because the slider changed two times. + expect(testComponent.onInput).toHaveBeenCalledTimes(2); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + }); + + it('should emit an input event when clicking', () => { + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchMousedownEventSequence(sliderNativeElement, 0.75); + fixture.detectChanges(); + dispatchSlideEndEvent(sliderNativeElement, 0.75); + + // The `onInput` event should be emitted once due to a single click. + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + }); + }); + + describe('keyboard support', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let testComponent: SliderWithChangeHandler; + let sliderInstance: MatLegacySlider; + let trackFillElement: HTMLElement; + + beforeEach(() => { + fixture = createComponent(SliderWithChangeHandler); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + spyOn(testComponent, 'onInput'); + spyOn(testComponent, 'onChange'); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MatLegacySlider); + trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill') as HTMLElement; + }); + + it('should increment slider by 1 on up arrow pressed', () => { + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', UP_ARROW); + fixture.detectChanges(); + + // The `onInput` event should be emitted once due to a single keyboard press. + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + expect(sliderInstance.value).toBe(1); + }); + + it('should increment slider by 1 on right arrow pressed', () => { + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + // The `onInput` event should be emitted once due to a single keyboard press. + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + expect(sliderInstance.value).toBe(1); + }); + + it('should decrement slider by 1 on down arrow pressed', () => { + sliderInstance.value = 100; + + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + // The `onInput` event should be emitted once due to a single keyboard press. + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + expect(sliderInstance.value).toBe(99); + }); + + it('should decrement slider by 1 on left arrow pressed', () => { + sliderInstance.value = 100; + + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + // The `onInput` event should be emitted once due to a single keyboard press. + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + expect(sliderInstance.value).toBe(99); + }); + + it('should decrement from max when interacting after out-of-bounds value is assigned', () => { + sliderInstance.max = 100; + sliderInstance.value = 200; + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(200); + expect(trackFillElement.style.transform).toContain('scale3d(1, 1, 1)'); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(99); + expect(trackFillElement.style.transform).toContain('scale3d(0.99, 1, 1)'); + }); + + it('should increment slider by 10 on page up pressed', () => { + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', PAGE_UP); + fixture.detectChanges(); + + // The `onInput` event should be emitted once due to a single keyboard press. + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + expect(sliderInstance.value).toBe(10); + }); + + it('should decrement slider by 10 on page down pressed', () => { + sliderInstance.value = 100; + + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', PAGE_DOWN); + fixture.detectChanges(); + + // The `onInput` event should be emitted once due to a single keyboard press. + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + expect(sliderInstance.value).toBe(90); + }); + + it('should set slider to max on end pressed', () => { + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', END); + fixture.detectChanges(); + + // The `onInput` event should be emitted once due to a single keyboard press. + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + expect(sliderInstance.value).toBe(100); + }); + + it('should set slider to min on home pressed', () => { + sliderInstance.value = 100; + + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', HOME); + fixture.detectChanges(); + + // The `onInput` event should be emitted once due to a single keyboard press. + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + expect(sliderInstance.value).toBe(0); + }); + + it(`should take no action for presses of keys it doesn't care about`, () => { + sliderInstance.value = 50; + + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', BACKSPACE); + fixture.detectChanges(); + + // The `onInput` event should be emitted once due to a single keyboard press. + expect(testComponent.onInput).not.toHaveBeenCalled(); + expect(testComponent.onChange).not.toHaveBeenCalled(); + expect(sliderInstance.value).toBe(50); + }); + + it('should ignore events modifier keys', () => { + sliderInstance.value = 0; + + [UP_ARROW, DOWN_ARROW, RIGHT_ARROW, LEFT_ARROW, PAGE_DOWN, PAGE_UP, HOME, END].forEach( + key => { + const event = createKeyboardEvent('keydown', key, undefined, {alt: true}); + dispatchEvent(sliderNativeElement, event); + fixture.detectChanges(); + expect(event.defaultPrevented).toBe(false); + }, + ); + + expect(testComponent.onInput).not.toHaveBeenCalled(); + expect(testComponent.onChange).not.toHaveBeenCalled(); + expect(sliderInstance.value).toBe(0); + }); + }); + + describe('slider with direction and invert', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatLegacySlider; + let testComponent: SliderWithDirAndInvert; + + beforeEach(() => { + fixture = createComponent(SliderWithDirAndInvert); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderInstance = sliderDebugElement.injector.get(MatLegacySlider); + sliderNativeElement = sliderDebugElement.nativeElement; + }); + + it('works in inverted mode', () => { + testComponent.invert = true; + fixture.detectChanges(); + + dispatchMousedownEventSequence(sliderNativeElement, 0.3); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(70); + }); + + it('works in RTL languages', () => { + testComponent.dir = 'rtl'; + fixture.detectChanges(); + + dispatchMousedownEventSequence(sliderNativeElement, 0.3); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(70); + }); + + it('works in RTL languages in inverted mode', () => { + testComponent.dir = 'rtl'; + testComponent.invert = true; + fixture.detectChanges(); + + dispatchMousedownEventSequence(sliderNativeElement, 0.3); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(30); + }); + + it('should re-render slider with updated style upon directionality change', () => { + testComponent.dir = 'rtl'; + fixture.detectChanges(); + + const initialTrackFillStyles = sliderInstance._getTrackFillStyles(); + const initialTicksContainerStyles = sliderInstance._getTicksContainerStyles(); + const initialTicksStyles = sliderInstance._getTicksStyles(); + const initialThumbContainerStyles = sliderInstance._getThumbContainerStyles(); + + testComponent.dir = 'ltr'; + fixture.detectChanges(); + + expect(initialTrackFillStyles).not.toEqual(sliderInstance._getTrackFillStyles()); + expect(initialTicksContainerStyles).not.toEqual(sliderInstance._getTicksContainerStyles()); + expect(initialTicksStyles).not.toEqual(sliderInstance._getTicksStyles()); + expect(initialThumbContainerStyles).not.toEqual(sliderInstance._getThumbContainerStyles()); + }); + + it('should increment inverted slider by 1 on right arrow pressed', () => { + testComponent.invert = true; + fixture.detectChanges(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(1); + }); + + it('should decrement inverted slider by 1 on left arrow pressed', () => { + testComponent.invert = true; + sliderInstance.value = 100; + fixture.detectChanges(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(99); + }); + + it('should decrement RTL slider by 1 on right arrow pressed', () => { + testComponent.dir = 'rtl'; + sliderInstance.value = 100; + fixture.detectChanges(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(99); + }); + + it('should increment RTL slider by 1 on left arrow pressed', () => { + testComponent.dir = 'rtl'; + fixture.detectChanges(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(1); + }); + + it('should decrement inverted RTL slider by 1 on right arrow pressed', () => { + testComponent.dir = 'rtl'; + testComponent.invert = true; + sliderInstance.value = 100; + fixture.detectChanges(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(99); + }); + + it('should increment inverted RTL slider by 1 on left arrow pressed', () => { + testComponent.dir = 'rtl'; + testComponent.invert = true; + fixture.detectChanges(); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(1); + }); + + it('should hide last tick when inverted and at min value', () => { + testComponent.invert = true; + fixture.detectChanges(); + + expect(sliderNativeElement.classList.contains('mat-slider-hide-last-tick')) + .withContext('last tick should be hidden') + .toBe(true); + }); + }); + + describe('vertical slider', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let trackFillElement: HTMLElement; + let sliderInstance: MatLegacySlider; + let testComponent: VerticalSlider; + + beforeEach(() => { + fixture = createComponent(VerticalSlider); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderInstance = sliderDebugElement.injector.get(MatLegacySlider); + sliderNativeElement = sliderDebugElement.nativeElement; + trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); + }); + + it('updates value on mousedown', () => { + dispatchMousedownEventSequence(sliderNativeElement, 0.3); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(70); + }); + + it('updates value on mousedown in inverted mode', () => { + testComponent.invert = true; + fixture.detectChanges(); + + dispatchMousedownEventSequence(sliderNativeElement, 0.3); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(30); + }); + + it('should update the track fill on mousedown', () => { + expect(trackFillElement.style.transform).toContain('scale3d(1, 0, 1)'); + + dispatchMousedownEventSequence(sliderNativeElement, 0.39); + fixture.detectChanges(); + + expect(trackFillElement.style.transform).toContain('scale3d(1, 0.61, 1)'); + }); + + it('should update the track fill on mousedown in inverted mode', () => { + testComponent.invert = true; + fixture.detectChanges(); + + expect(trackFillElement.style.transform).toContain('scale3d(1, 0, 1)'); + + dispatchMousedownEventSequence(sliderNativeElement, 0.39); + fixture.detectChanges(); + + expect(trackFillElement.style.transform).toContain('scale3d(1, 0.39, 1)'); + }); + + it('should have aria-orientation vertical', () => { + expect(sliderNativeElement.getAttribute('aria-orientation')).toEqual('vertical'); + }); + }); + + describe('tabindex', () => { + it('should allow setting the tabIndex through binding', () => { + const fixture = createComponent(SliderWithTabIndexBinding); + fixture.detectChanges(); + + const slider = fixture.debugElement.query(By.directive(MatLegacySlider))!.componentInstance; + + expect(slider.tabIndex) + .withContext('Expected the tabIndex to be set to 0 by default.') + .toBe(0); + + fixture.componentInstance.tabIndex = 3; + fixture.detectChanges(); + + expect(slider.tabIndex).withContext('Expected the tabIndex to have been changed.').toBe(3); + }); + + it('should detect the native tabindex attribute', () => { + const fixture = createComponent(SliderWithNativeTabindexAttr); + fixture.detectChanges(); + + const slider = fixture.debugElement.query(By.directive(MatLegacySlider))!.componentInstance; + + expect(slider.tabIndex) + .withContext('Expected the tabIndex to be set to the value of the native attribute.') + .toBe(5); + }); + }); + + describe('slider with ngModel', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let testComponent: SliderWithNgModel; + + beforeEach(() => { + fixture = createComponent(SliderWithNgModel); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + }); + + it('should update the model on mouseup', () => { + expect(testComponent.val).toBe(0); + + dispatchMousedownEventSequence(sliderNativeElement, 0.76); + fixture.detectChanges(); + dispatchSlideEndEvent(sliderNativeElement, 0.76); + + expect(testComponent.val).toBe(76); + }); + + it('should update the model on slide', () => { + expect(testComponent.val).toBe(0); + + dispatchSlideEventSequence(sliderNativeElement, 0, 0.19); + fixture.detectChanges(); + + expect(testComponent.val).toBe(19); + }); + + it('should update the model on keydown', () => { + expect(testComponent.val).toBe(0); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(testComponent.val).toBe(1); + }); + + it('should be able to reset a slider by setting the model back to undefined', fakeAsync(() => { + expect(testComponent.slider.value).toBe(0); + + testComponent.val = 5; + fixture.detectChanges(); + flush(); + + expect(testComponent.slider.value).toBe(5); + + testComponent.val = undefined; + fixture.detectChanges(); + flush(); + + expect(testComponent.slider.value).toBe(0); + })); + }); + + describe('slider as a custom form control', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatLegacySlider; + let testComponent: SliderWithFormControl; + + beforeEach(() => { + fixture = createComponent(SliderWithFormControl); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MatLegacySlider); + }); + + it('should not update the control when the value is updated', () => { + expect(testComponent.control.value).toBe(0); + + sliderInstance.value = 11; + fixture.detectChanges(); + + expect(testComponent.control.value).toBe(0); + }); + + it('should update the control on mouseup', () => { + expect(testComponent.control.value).toBe(0); + + dispatchMousedownEventSequence(sliderNativeElement, 0.76); + fixture.detectChanges(); + dispatchSlideEndEvent(sliderNativeElement, 0.76); + + expect(testComponent.control.value).toBe(76); + }); + + it('should update the control on slide', () => { + expect(testComponent.control.value).toBe(0); + + dispatchSlideEventSequence(sliderNativeElement, 0, 0.19); + fixture.detectChanges(); + + expect(testComponent.control.value).toBe(19); + }); + + it('should update the value when the control is set', () => { + expect(sliderInstance.value).toBe(0); + + testComponent.control.setValue(7); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(7); + }); + + it('should update the disabled state when control is disabled', () => { + expect(sliderInstance.disabled).toBe(false); + + testComponent.control.disable(); + fixture.detectChanges(); + + expect(sliderInstance.disabled).toBe(true); + }); + + it('should update the disabled state when the control is enabled', () => { + sliderInstance.disabled = true; + + testComponent.control.enable(); + fixture.detectChanges(); + + expect(sliderInstance.disabled).toBe(false); + }); + + it('should have the correct control state initially and after interaction', () => { + const sliderControl = testComponent.control; + + // The control should start off valid, pristine, and untouched. + expect(sliderControl.valid).toBe(true); + expect(sliderControl.pristine).toBe(true); + expect(sliderControl.touched).toBe(false); + + // After changing the value, the control should become dirty (not pristine), + // but remain untouched. + dispatchMousedownEventSequence(sliderNativeElement, 0.5); + fixture.detectChanges(); + dispatchSlideEndEvent(sliderNativeElement, 0.5); + + expect(sliderControl.valid).toBe(true); + expect(sliderControl.pristine).toBe(false); + expect(sliderControl.touched).toBe(false); + + // If the control has been visited due to interaction, the control should remain + // dirty and now also be touched. + sliderInstance._onBlur(); + fixture.detectChanges(); + + expect(sliderControl.valid).toBe(true); + expect(sliderControl.pristine).toBe(false); + expect(sliderControl.touched).toBe(true); + }); + }); + + describe('slider with a two-way binding', () => { + let fixture: ComponentFixture; + let testComponent: SliderWithTwoWayBinding; + let sliderNativeElement: HTMLElement; + + beforeEach(() => { + fixture = createComponent(SliderWithTwoWayBinding); + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + let sliderDebugElement = fixture.debugElement.query(By.directive(MatLegacySlider))!; + sliderNativeElement = sliderDebugElement.nativeElement; + }); + + it('should sync the value binding in both directions', () => { + expect(testComponent.value).toBe(0); + expect(testComponent.slider.value).toBe(0); + + dispatchMousedownEventSequence(sliderNativeElement, 0.1); + fixture.detectChanges(); + dispatchSlideEndEvent(sliderNativeElement, 0.1); + + expect(testComponent.value).toBe(10); + expect(testComponent.slider.value).toBe(10); + + testComponent.value = 20; + fixture.detectChanges(); + + expect(testComponent.value).toBe(20); + expect(testComponent.slider.value).toBe(20); + }); + }); +}); + +// Disable animations and make the slider an even 100px (+ 8px padding on either side) +// so we get nice round values in tests. +const styles = ` + .mat-slider-horizontal { min-width: 116px !important; } + .mat-slider-vertical { min-height: 116px !important; } + .mat-slider-track-fill { transition: none !important; } +`; + +@Component({ + template: ``, + styles: [styles], +}) +class StandardSlider {} + +@Component({ + template: ``, + styles: [styles], +}) +class DisabledSlider {} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithMinAndMax { + min = 4; + max = 6; + step = 1; +} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithValue {} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithStep { + step = 25; + valueText: string; +} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithAutoTickInterval {} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithSetTickInterval { + tickInterval = 6; +} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithThumbLabel {} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithCustomThumbLabelFormatting { + displayWith(value: number) { + if (value >= 1000) { + return value / 1000 + 'k'; + } + + return value; + } +} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithOneWayBinding { + val = 50; +} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithFormControl { + control = new FormControl(0); +} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithNgModel { + @ViewChild(MatLegacySlider) slider: MatLegacySlider; + val: number | undefined = 0; +} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithValueSmallerThanMin {} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithValueGreaterThanMax {} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithChangeHandler { + onChange() {} + onInput() {} + + @ViewChild(MatLegacySlider) slider: MatLegacySlider; +} + +@Component({ + template: `
`, + styles: [styles], +}) +class SliderWithDirAndInvert { + dir = 'ltr'; + invert = false; +} + +@Component({ + template: ``, + styles: [styles], +}) +class VerticalSlider { + invert = false; +} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithTabIndexBinding { + tabIndex: number; +} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithNativeTabindexAttr { + tabIndex: number; +} + +@Component({ + template: '', + styles: [styles], +}) +class SliderWithTwoWayBinding { + @ViewChild(MatLegacySlider) slider: MatLegacySlider; + value = 0; +} + +/** + * Dispatches a mousedown event sequence (consisting of moueseenter, mousedown) from an element. + * Note: The mouse event truncates the position for the event. + * @param sliderElement The mat-slider element from which the event will be dispatched. + * @param percentage The percentage of the slider where the event should occur. Used to find the + * physical location of the pointer. + * @param button Button that should be held down when starting to drag the slider. + */ +function dispatchMousedownEventSequence( + sliderElement: HTMLElement, + percentage: number, + button = 0, +): void { + const trackElement = sliderElement.querySelector('.mat-slider-wrapper')!; + const dimensions = trackElement.getBoundingClientRect(); + const x = dimensions.left + dimensions.width * percentage; + const y = dimensions.top + dimensions.height * percentage; + + dispatchMouseenterEvent(sliderElement); + dispatchEvent(sliderElement, createMouseEvent('mousedown', x, y, undefined, undefined, button)); +} + +/** + * Dispatches a slide event sequence (consisting of slidestart, slide, slideend) from an element. + * @param sliderElement The mat-slider element from which the event will be dispatched. + * @param startPercent The percentage of the slider where the slide will begin. + * @param endPercent The percentage of the slider where the slide will end. + */ +function dispatchSlideEventSequence( + sliderElement: HTMLElement, + startPercent: number, + endPercent: number, +): void { + dispatchMouseenterEvent(sliderElement); + dispatchSlideStartEvent(sliderElement, startPercent); + dispatchSlideEvent(sliderElement, startPercent); + dispatchSlideEvent(sliderElement, endPercent); + dispatchSlideEndEvent(sliderElement, endPercent); +} + +/** + * Dispatches a slide event from an element. + * @param sliderElement The mat-slider element from which the event will be dispatched. + * @param percent The percentage of the slider where the slide will happen. + */ +function dispatchSlideEvent(sliderElement: HTMLElement, percent: number): void { + const trackElement = sliderElement.querySelector('.mat-slider-wrapper')!; + const dimensions = trackElement.getBoundingClientRect(); + const x = dimensions.left + dimensions.width * percent; + const y = dimensions.top + dimensions.height * percent; + dispatchMouseEvent(document, 'mousemove', x, y); +} + +/** + * Dispatches a slidestart event from an element. + * @param sliderElement The mat-slider element from which the event will be dispatched. + * @param percent The percentage of the slider where the slide will begin. + */ +function dispatchSlideStartEvent(sliderElement: HTMLElement, percent: number): void { + const trackElement = sliderElement.querySelector('.mat-slider-wrapper')!; + const dimensions = trackElement.getBoundingClientRect(); + const x = dimensions.left + dimensions.width * percent; + const y = dimensions.top + dimensions.height * percent; + dispatchMouseenterEvent(sliderElement); + dispatchMouseEvent(sliderElement, 'mousedown', x, y); +} + +/** + * Dispatches a slideend event from an element. + * @param sliderElement The mat-slider element from which the event will be dispatched. + * @param percent The percentage of the slider where the slide will end. + */ +function dispatchSlideEndEvent(sliderElement: HTMLElement, percent: number): void { + const trackElement = sliderElement.querySelector('.mat-slider-wrapper')!; + const dimensions = trackElement.getBoundingClientRect(); + const x = dimensions.left + dimensions.width * percent; + const y = dimensions.top + dimensions.height * percent; + dispatchMouseEvent(document, 'mouseup', x, y); +} + +/** + * Dispatches a mouseenter event from an element. + * Note: The mouse event truncates the position for the event. + * @param element The element from which the event will be dispatched. + */ +function dispatchMouseenterEvent(element: HTMLElement): void { + const dimensions = element.getBoundingClientRect(); + const y = dimensions.top; + const x = dimensions.left; + dispatchMouseEvent(element, 'mouseenter', x, y); +} diff --git a/src/material/legacy-slider/slider.ts b/src/material/legacy-slider/slider.ts new file mode 100644 index 000000000000..7fa2a16c9192 --- /dev/null +++ b/src/material/legacy-slider/slider.ts @@ -0,0 +1,1022 @@ +/** + * @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.io/license + */ + +import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y'; +import {Directionality} from '@angular/cdk/bidi'; +import { + BooleanInput, + coerceBooleanProperty, + coerceNumberProperty, + NumberInput, +} from '@angular/cdk/coercion'; +import { + DOWN_ARROW, + END, + HOME, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + UP_ARROW, + hasModifierKey, +} from '@angular/cdk/keycodes'; +import { + Attribute, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + forwardRef, + Inject, + Input, + OnDestroy, + Optional, + Output, + ViewChild, + ViewEncapsulation, + NgZone, + AfterViewInit, +} from '@angular/core'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; +import { + CanColor, + CanDisable, + HasTabIndex, + mixinColor, + mixinDisabled, + mixinTabIndex, +} from '@angular/material/core'; +import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; +import {normalizePassiveListenerOptions} from '@angular/cdk/platform'; +import {DOCUMENT} from '@angular/common'; +import {Subscription} from 'rxjs'; + +const activeEventOptions = normalizePassiveListenerOptions({passive: false}); + +/** + * Visually, a 30px separation between tick marks looks best. This is very subjective but it is + * the default separation we chose. + */ +const MIN_AUTO_TICK_SEPARATION = 30; + +/** The thumb gap size for a disabled slider. */ +const DISABLED_THUMB_GAP = 7; + +/** The thumb gap size for a non-active slider at its minimum value. */ +const MIN_VALUE_NONACTIVE_THUMB_GAP = 7; + +/** The thumb gap size for an active slider at its minimum value. */ +const MIN_VALUE_ACTIVE_THUMB_GAP = 10; + +/** + * Provider Expression that allows mat-slider to register as a ControlValueAccessor. + * This allows it to support [(ngModel)] and [formControl]. + * @docs-private + */ +export const MAT_SLIDER_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MatLegacySlider), + multi: true, +}; + +/** A simple change event emitted by the MatSlider component. */ +export class MatLegacySliderChange { + /** The MatSlider that changed. */ + source: MatLegacySlider; + + /** The new value of the source slider. */ + value: number | null; +} + +// Boilerplate for applying mixins to MatSlider. +/** @docs-private */ +const _MatSliderBase = mixinTabIndex( + mixinColor( + mixinDisabled( + class { + constructor(public _elementRef: ElementRef) {} + }, + ), + 'accent', + ), +); + +/** + * Allows users to select from a range of values by moving the slider thumb. It is similar in + * behavior to the native `` element. + */ +@Component({ + selector: 'mat-slider', + exportAs: 'matSlider', + providers: [MAT_SLIDER_VALUE_ACCESSOR], + host: { + '(focus)': '_onFocus()', + '(blur)': '_onBlur()', + '(keydown)': '_onKeydown($event)', + '(keyup)': '_onKeyup()', + '(mouseenter)': '_onMouseenter()', + + // On Safari starting to slide temporarily triggers text selection mode which + // show the wrong cursor. We prevent it by stopping the `selectstart` event. + '(selectstart)': '$event.preventDefault()', + 'class': 'mat-slider mat-focus-indicator', + 'role': 'slider', + '[tabIndex]': 'tabIndex', + '[attr.aria-disabled]': 'disabled', + '[attr.aria-valuemax]': 'max', + '[attr.aria-valuemin]': 'min', + '[attr.aria-valuenow]': 'value', + + // NVDA and Jaws appear to announce the `aria-valuenow` by calculating its percentage based + // on its value between `aria-valuemin` and `aria-valuemax`. Due to how decimals are handled, + // it can cause the slider to read out a very long value like 0.20000068 if the current value + // is 0.2 with a min of 0 and max of 1. We work around the issue by setting `aria-valuetext` + // to the same value that we set on the slider's thumb which will be truncated. + '[attr.aria-valuetext]': 'valueText == null ? displayValue : valueText', + '[attr.aria-orientation]': 'vertical ? "vertical" : "horizontal"', + '[class.mat-slider-disabled]': 'disabled', + '[class.mat-slider-has-ticks]': 'tickInterval', + '[class.mat-slider-horizontal]': '!vertical', + '[class.mat-slider-axis-inverted]': '_shouldInvertAxis()', + // Class binding which is only used by the test harness as there is no other + // way for the harness to detect if mouse coordinates need to be inverted. + '[class.mat-slider-invert-mouse-coords]': '_shouldInvertMouseCoords()', + '[class.mat-slider-sliding]': '_isSliding', + '[class.mat-slider-thumb-label-showing]': 'thumbLabel', + '[class.mat-slider-vertical]': 'vertical', + '[class.mat-slider-min-value]': '_isMinValue()', + '[class.mat-slider-hide-last-tick]': + 'disabled || _isMinValue() && _getThumbGap() && _shouldInvertAxis()', + '[class._mat-animation-noopable]': '_animationMode === "NoopAnimations"', + }, + templateUrl: 'slider.html', + styleUrls: ['slider.css'], + inputs: ['disabled', 'color', 'tabIndex'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MatLegacySlider + extends _MatSliderBase + implements ControlValueAccessor, OnDestroy, CanDisable, CanColor, AfterViewInit, HasTabIndex +{ + /** Whether the slider is inverted. */ + @Input() + get invert(): boolean { + return this._invert; + } + set invert(value: BooleanInput) { + this._invert = coerceBooleanProperty(value); + } + private _invert = false; + + /** The maximum value that the slider can have. */ + @Input() + get max(): number { + return this._max; + } + set max(v: NumberInput) { + this._max = coerceNumberProperty(v, this._max); + this._percent = this._calculatePercentage(this._value); + + // Since this also modifies the percentage, we need to let the change detection know. + this._changeDetectorRef.markForCheck(); + } + private _max: number = 100; + + /** The minimum value that the slider can have. */ + @Input() + get min(): number { + return this._min; + } + set min(v: NumberInput) { + this._min = coerceNumberProperty(v, this._min); + this._percent = this._calculatePercentage(this._value); + + // Since this also modifies the percentage, we need to let the change detection know. + this._changeDetectorRef.markForCheck(); + } + private _min: number = 0; + + /** The values at which the thumb will snap. */ + @Input() + get step(): number { + return this._step; + } + set step(v: NumberInput) { + this._step = coerceNumberProperty(v, this._step); + + if (this._step % 1 !== 0) { + this._roundToDecimal = this._step.toString().split('.').pop()!.length; + } + + // Since this could modify the label, we need to notify the change detection. + this._changeDetectorRef.markForCheck(); + } + private _step: number = 1; + + /** Whether or not to show the thumb label. */ + @Input() + get thumbLabel(): boolean { + return this._thumbLabel; + } + set thumbLabel(value: BooleanInput) { + this._thumbLabel = coerceBooleanProperty(value); + } + private _thumbLabel: boolean = false; + + /** + * How often to show ticks. Relative to the step so that a tick always appears on a step. + * Ex: Tick interval of 4 with a step of 3 will draw a tick every 4 steps (every 12 values). + */ + @Input() + get tickInterval(): 'auto' | number { + return this._tickInterval; + } + set tickInterval(value: 'auto' | NumberInput) { + if (value === 'auto') { + this._tickInterval = 'auto'; + } else if (typeof value === 'number' || typeof value === 'string') { + this._tickInterval = coerceNumberProperty(value, this._tickInterval as number); + } else { + this._tickInterval = 0; + } + } + private _tickInterval: 'auto' | number = 0; + + /** Value of the slider. */ + @Input() + get value(): number { + // If the value needs to be read and it is still uninitialized, initialize it to the min. + if (this._value === null) { + this.value = this._min; + } + return this._value as number; + } + set value(v: NumberInput) { + if (v !== this._value) { + let value = coerceNumberProperty(v, 0); + + // While incrementing by a decimal we can end up with values like 33.300000000000004. + // Truncate it to ensure that it matches the label and to make it easier to work with. + if (this._roundToDecimal && value !== this.min && value !== this.max) { + value = parseFloat(value.toFixed(this._roundToDecimal)); + } + + this._value = value; + this._percent = this._calculatePercentage(this._value); + + // Since this also modifies the percentage, we need to let the change detection know. + this._changeDetectorRef.markForCheck(); + } + } + private _value: number | null = null; + + /** + * Function that will be used to format the value before it is displayed + * in the thumb label. Can be used to format very large number in order + * for them to fit into the slider thumb. + */ + @Input() displayWith: (value: number) => string | number; + + /** Text corresponding to the slider's value. Used primarily for improved accessibility. */ + @Input() valueText: string; + + /** Whether the slider is vertical. */ + @Input() + get vertical(): boolean { + return this._vertical; + } + set vertical(value: BooleanInput) { + this._vertical = coerceBooleanProperty(value); + } + private _vertical = false; + + /** Event emitted when the slider value has changed. */ + @Output() readonly change: EventEmitter = + new EventEmitter(); + + /** Event emitted when the slider thumb moves. */ + @Output() readonly input: EventEmitter = + new EventEmitter(); + + /** + * Emits when the raw value of the slider changes. This is here primarily + * to facilitate the two-way binding for the `value` input. + * @docs-private + */ + @Output() readonly valueChange: EventEmitter = new EventEmitter(); + + /** The value to be used for display purposes. */ + get displayValue(): string | number { + if (this.displayWith) { + // Value is never null but since setters and getters cannot have + // different types, the value getter is also typed to return null. + return this.displayWith(this.value!); + } + + // Note that this could be improved further by rounding something like 0.999 to 1 or + // 0.899 to 0.9, however it is very performance sensitive, because it gets called on + // every change detection cycle. + if (this._roundToDecimal && this.value && this.value % 1 !== 0) { + return this.value.toFixed(this._roundToDecimal); + } + + return this.value || 0; + } + + /** set focus to the host element */ + focus(options?: FocusOptions) { + this._focusHostElement(options); + } + + /** blur the host element */ + blur() { + this._blurHostElement(); + } + + /** onTouch function registered via registerOnTouch (ControlValueAccessor). */ + onTouched: () => any = () => {}; + + /** The percentage of the slider that coincides with the value. */ + get percent(): number { + return this._clamp(this._percent); + } + private _percent: number = 0; + + /** + * Whether or not the thumb is sliding and what the user is using to slide it with. + * Used to determine if there should be a transition for the thumb and fill track. + */ + _isSliding: 'keyboard' | 'pointer' | null = null; + + /** + * Whether or not the slider is active (clicked or sliding). + * Used to shrink and grow the thumb as according to the Material Design spec. + */ + _isActive: boolean = false; + + /** + * Whether the axis of the slider is inverted. + * (i.e. whether moving the thumb in the positive x or y direction decreases the slider's value). + */ + _shouldInvertAxis() { + // Standard non-inverted mode for a vertical slider should be dragging the thumb from bottom to + // top. However from a y-axis standpoint this is inverted. + return this.vertical ? !this.invert : this.invert; + } + + /** Whether the slider is at its minimum value. */ + _isMinValue() { + return this.percent === 0; + } + + /** + * The amount of space to leave between the slider thumb and the track fill & track background + * elements. + */ + _getThumbGap() { + if (this.disabled) { + return DISABLED_THUMB_GAP; + } + if (this._isMinValue() && !this.thumbLabel) { + return this._isActive ? MIN_VALUE_ACTIVE_THUMB_GAP : MIN_VALUE_NONACTIVE_THUMB_GAP; + } + return 0; + } + + /** CSS styles for the track background element. */ + _getTrackBackgroundStyles(): {[key: string]: string} { + const axis = this.vertical ? 'Y' : 'X'; + const scale = this.vertical ? `1, ${1 - this.percent}, 1` : `${1 - this.percent}, 1, 1`; + const sign = this._shouldInvertMouseCoords() ? '-' : ''; + + return { + // scale3d avoids some rendering issues in Chrome. See #12071. + transform: `translate${axis}(${sign}${this._getThumbGap()}px) scale3d(${scale})`, + }; + } + + /** CSS styles for the track fill element. */ + _getTrackFillStyles(): {[key: string]: string} { + const percent = this.percent; + const axis = this.vertical ? 'Y' : 'X'; + const scale = this.vertical ? `1, ${percent}, 1` : `${percent}, 1, 1`; + const sign = this._shouldInvertMouseCoords() ? '' : '-'; + + return { + // scale3d avoids some rendering issues in Chrome. See #12071. + transform: `translate${axis}(${sign}${this._getThumbGap()}px) scale3d(${scale})`, + // iOS Safari has a bug where it won't re-render elements which start of as `scale(0)` until + // something forces a style recalculation on it. Since we'll end up with `scale(0)` when + // the value of the slider is 0, we can easily get into this situation. We force a + // recalculation by changing the element's `display` when it goes from 0 to any other value. + display: percent === 0 ? 'none' : '', + }; + } + + /** CSS styles for the ticks container element. */ + _getTicksContainerStyles(): {[key: string]: string} { + let axis = this.vertical ? 'Y' : 'X'; + // For a horizontal slider in RTL languages we push the ticks container off the left edge + // instead of the right edge to avoid causing a horizontal scrollbar to appear. + let sign = !this.vertical && this._getDirection() == 'rtl' ? '' : '-'; + let offset = (this._tickIntervalPercent / 2) * 100; + return { + 'transform': `translate${axis}(${sign}${offset}%)`, + }; + } + + /** CSS styles for the ticks element. */ + _getTicksStyles(): {[key: string]: string} { + let tickSize = this._tickIntervalPercent * 100; + let backgroundSize = this.vertical ? `2px ${tickSize}%` : `${tickSize}% 2px`; + let axis = this.vertical ? 'Y' : 'X'; + // Depending on the direction we pushed the ticks container, push the ticks the opposite + // direction to re-center them but clip off the end edge. In RTL languages we need to flip the + // ticks 180 degrees so we're really cutting off the end edge abd not the start. + let sign = !this.vertical && this._getDirection() == 'rtl' ? '-' : ''; + let rotate = !this.vertical && this._getDirection() == 'rtl' ? ' rotate(180deg)' : ''; + let styles: {[key: string]: string} = { + 'backgroundSize': backgroundSize, + // Without translateZ ticks sometimes jitter as the slider moves on Chrome & Firefox. + 'transform': `translateZ(0) translate${axis}(${sign}${tickSize / 2}%)${rotate}`, + }; + + if (this._isMinValue() && this._getThumbGap()) { + const shouldInvertAxis = this._shouldInvertAxis(); + let side: string; + + if (this.vertical) { + side = shouldInvertAxis ? 'Bottom' : 'Top'; + } else { + side = shouldInvertAxis ? 'Right' : 'Left'; + } + + styles[`padding${side}`] = `${this._getThumbGap()}px`; + } + + return styles; + } + + _getThumbContainerStyles(): {[key: string]: string} { + const shouldInvertAxis = this._shouldInvertAxis(); + let axis = this.vertical ? 'Y' : 'X'; + // For a horizontal slider in RTL languages we push the thumb container off the left edge + // instead of the right edge to avoid causing a horizontal scrollbar to appear. + let invertOffset = + this._getDirection() == 'rtl' && !this.vertical ? !shouldInvertAxis : shouldInvertAxis; + let offset = (invertOffset ? this.percent : 1 - this.percent) * 100; + return { + 'transform': `translate${axis}(-${offset}%)`, + }; + } + + /** The size of a tick interval as a percentage of the size of the track. */ + private _tickIntervalPercent: number = 0; + + /** The dimensions of the slider. */ + private _sliderDimensions: ClientRect | null = null; + + private _controlValueAccessorChangeFn: (value: any) => void = () => {}; + + /** Decimal places to round to, based on the step amount. */ + private _roundToDecimal: number; + + /** Subscription to the Directionality change EventEmitter. */ + private _dirChangeSubscription = Subscription.EMPTY; + + /** The value of the slider when the slide start event fires. */ + private _valueOnSlideStart: number | null; + + /** Reference to the inner slider wrapper element. */ + @ViewChild('sliderWrapper') private _sliderWrapper: ElementRef; + + /** + * Whether mouse events should be converted to a slider position by calculating their distance + * from the right or bottom edge of the slider as opposed to the top or left. + */ + _shouldInvertMouseCoords() { + const shouldInvertAxis = this._shouldInvertAxis(); + return this._getDirection() == 'rtl' && !this.vertical ? !shouldInvertAxis : shouldInvertAxis; + } + + /** The language direction for this slider element. */ + private _getDirection() { + return this._dir && this._dir.value == 'rtl' ? 'rtl' : 'ltr'; + } + + /** Keeps track of the last pointer event that was captured by the slider. */ + private _lastPointerEvent: MouseEvent | TouchEvent | null; + + /** Used to subscribe to global move and end events */ + protected _document: Document; + + /** + * Identifier used to attribute a touch event to a particular slider. + * Will be undefined if one of the following conditions is true: + * - The user isn't dragging using a touch device. + * - The browser doesn't support `Touch.identifier`. + * - Dragging hasn't started yet. + */ + private _touchId: number | undefined; + + constructor( + elementRef: ElementRef, + private _focusMonitor: FocusMonitor, + private _changeDetectorRef: ChangeDetectorRef, + @Optional() private _dir: Directionality, + @Attribute('tabindex') tabIndex: string, + private _ngZone: NgZone, + @Inject(DOCUMENT) _document: any, + @Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string, + ) { + super(elementRef); + this._document = _document; + this.tabIndex = parseInt(tabIndex) || 0; + + _ngZone.runOutsideAngular(() => { + const element = elementRef.nativeElement; + element.addEventListener('mousedown', this._pointerDown, activeEventOptions); + element.addEventListener('touchstart', this._pointerDown, activeEventOptions); + }); + } + + ngAfterViewInit() { + this._focusMonitor.monitor(this._elementRef, true).subscribe((origin: FocusOrigin) => { + this._isActive = !!origin && origin !== 'keyboard'; + this._changeDetectorRef.detectChanges(); + }); + if (this._dir) { + this._dirChangeSubscription = this._dir.change.subscribe(() => { + this._changeDetectorRef.markForCheck(); + }); + } + } + + ngOnDestroy() { + const element = this._elementRef.nativeElement; + element.removeEventListener('mousedown', this._pointerDown, activeEventOptions); + element.removeEventListener('touchstart', this._pointerDown, activeEventOptions); + this._lastPointerEvent = null; + this._removeGlobalEvents(); + this._focusMonitor.stopMonitoring(this._elementRef); + this._dirChangeSubscription.unsubscribe(); + } + + _onMouseenter() { + if (this.disabled) { + return; + } + + // We save the dimensions of the slider here so we can use them to update the spacing of the + // ticks and determine where on the slider click and slide events happen. + this._sliderDimensions = this._getSliderDimensions(); + this._updateTickIntervalPercent(); + } + + _onFocus() { + // We save the dimensions of the slider here so we can use them to update the spacing of the + // ticks and determine where on the slider click and slide events happen. + this._sliderDimensions = this._getSliderDimensions(); + this._updateTickIntervalPercent(); + } + + _onBlur() { + this.onTouched(); + } + + _onKeydown(event: KeyboardEvent) { + if ( + this.disabled || + hasModifierKey(event) || + (this._isSliding && this._isSliding !== 'keyboard') + ) { + return; + } + + const oldValue = this.value; + + switch (event.keyCode) { + case PAGE_UP: + this._increment(10); + break; + case PAGE_DOWN: + this._increment(-10); + break; + case END: + this.value = this.max; + break; + case HOME: + this.value = this.min; + break; + case LEFT_ARROW: + // NOTE: For a sighted user it would make more sense that when they press an arrow key on an + // inverted slider the thumb moves in that direction. However for a blind user, nothing + // about the slider indicates that it is inverted. They will expect left to be decrement, + // regardless of how it appears on the screen. For speakers ofRTL languages, they probably + // expect left to mean increment. Therefore we flip the meaning of the side arrow keys for + // RTL. For inverted sliders we prefer a good a11y experience to having it "look right" for + // sighted users, therefore we do not swap the meaning. + this._increment(this._getDirection() == 'rtl' ? 1 : -1); + break; + case UP_ARROW: + this._increment(1); + break; + case RIGHT_ARROW: + // See comment on LEFT_ARROW about the conditions under which we flip the meaning. + this._increment(this._getDirection() == 'rtl' ? -1 : 1); + break; + case DOWN_ARROW: + this._increment(-1); + break; + default: + // Return if the key is not one that we explicitly handle to avoid calling preventDefault on + // it. + return; + } + + if (oldValue != this.value) { + this._emitInputEvent(); + this._emitChangeEvent(); + } + + this._isSliding = 'keyboard'; + event.preventDefault(); + } + + _onKeyup() { + if (this._isSliding === 'keyboard') { + this._isSliding = null; + } + } + + /** Called when the user has put their pointer down on the slider. */ + private _pointerDown = (event: TouchEvent | MouseEvent) => { + // Don't do anything if the slider is disabled or the + // user is using anything other than the main mouse button. + if (this.disabled || this._isSliding || (!isTouchEvent(event) && event.button !== 0)) { + return; + } + + this._ngZone.run(() => { + this._touchId = isTouchEvent(event) + ? getTouchIdForSlider(event, this._elementRef.nativeElement) + : undefined; + const pointerPosition = getPointerPositionOnPage(event, this._touchId); + + if (pointerPosition) { + const oldValue = this.value; + this._isSliding = 'pointer'; + this._lastPointerEvent = event; + this._focusHostElement(); + this._onMouseenter(); // Simulate mouseenter in case this is a mobile device. + this._bindGlobalEvents(event); + this._focusHostElement(); + this._updateValueFromPosition(pointerPosition); + this._valueOnSlideStart = oldValue; + + // Despite the fact that we explicitly bind active events, in some cases the browser + // still dispatches non-cancelable events which cause this call to throw an error. + // There doesn't appear to be a good way of avoiding them. See #23820. + if (event.cancelable) { + event.preventDefault(); + } + + // Emit a change and input event if the value changed. + if (oldValue != this.value) { + this._emitInputEvent(); + } + } + }); + }; + + /** + * Called when the user has moved their pointer after + * starting to drag. Bound on the document level. + */ + private _pointerMove = (event: TouchEvent | MouseEvent) => { + if (this._isSliding === 'pointer') { + const pointerPosition = getPointerPositionOnPage(event, this._touchId); + + if (pointerPosition) { + // Prevent the slide from selecting anything else. + if (event.cancelable) { + event.preventDefault(); + } + const oldValue = this.value; + this._lastPointerEvent = event; + this._updateValueFromPosition(pointerPosition); + + // Native range elements always emit `input` events when the value changed while sliding. + if (oldValue != this.value) { + this._emitInputEvent(); + } + } + } + }; + + /** Called when the user has lifted their pointer. Bound on the document level. */ + private _pointerUp = (event: TouchEvent | MouseEvent) => { + if (this._isSliding === 'pointer') { + if ( + !isTouchEvent(event) || + typeof this._touchId !== 'number' || + // Note that we use `changedTouches`, rather than `touches` because it + // seems like in most cases `touches` is empty for `touchend` events. + findMatchingTouch(event.changedTouches, this._touchId) + ) { + if (event.cancelable) { + event.preventDefault(); + } + this._removeGlobalEvents(); + this._isSliding = null; + this._touchId = undefined; + + if (this._valueOnSlideStart != this.value && !this.disabled) { + this._emitChangeEvent(); + } + + this._valueOnSlideStart = this._lastPointerEvent = null; + } + } + }; + + /** Called when the window has lost focus. */ + private _windowBlur = () => { + // If the window is blurred while dragging we need to stop dragging because the + // browser won't dispatch the `mouseup` and `touchend` events anymore. + if (this._lastPointerEvent) { + this._pointerUp(this._lastPointerEvent); + } + }; + + /** Use defaultView of injected document if available or fallback to global window reference */ + private _getWindow(): Window { + return this._document.defaultView || window; + } + + /** + * Binds our global move and end events. They're bound at the document level and only while + * dragging so that the user doesn't have to keep their pointer exactly over the slider + * as they're swiping across the screen. + */ + private _bindGlobalEvents(triggerEvent: TouchEvent | MouseEvent) { + // Note that we bind the events to the `document`, because it allows us to capture + // drag cancel events where the user's pointer is outside the browser window. + const document = this._document; + const isTouch = isTouchEvent(triggerEvent); + const moveEventName = isTouch ? 'touchmove' : 'mousemove'; + const endEventName = isTouch ? 'touchend' : 'mouseup'; + document.addEventListener(moveEventName, this._pointerMove, activeEventOptions); + document.addEventListener(endEventName, this._pointerUp, activeEventOptions); + + if (isTouch) { + document.addEventListener('touchcancel', this._pointerUp, activeEventOptions); + } + + const window = this._getWindow(); + + if (typeof window !== 'undefined' && window) { + window.addEventListener('blur', this._windowBlur); + } + } + + /** Removes any global event listeners that we may have added. */ + private _removeGlobalEvents() { + const document = this._document; + document.removeEventListener('mousemove', this._pointerMove, activeEventOptions); + document.removeEventListener('mouseup', this._pointerUp, activeEventOptions); + document.removeEventListener('touchmove', this._pointerMove, activeEventOptions); + document.removeEventListener('touchend', this._pointerUp, activeEventOptions); + document.removeEventListener('touchcancel', this._pointerUp, activeEventOptions); + + const window = this._getWindow(); + + if (typeof window !== 'undefined' && window) { + window.removeEventListener('blur', this._windowBlur); + } + } + + /** Increments the slider by the given number of steps (negative number decrements). */ + private _increment(numSteps: number) { + // Pre-clamp the current value since it's allowed to be + // out of bounds when assigned programmatically. + const clampedValue = this._clamp(this.value || 0, this.min, this.max); + this.value = this._clamp(clampedValue + this.step * numSteps, this.min, this.max); + } + + /** Calculate the new value from the new physical location. The value will always be snapped. */ + private _updateValueFromPosition(pos: {x: number; y: number}) { + if (!this._sliderDimensions) { + return; + } + + let offset = this.vertical ? this._sliderDimensions.top : this._sliderDimensions.left; + let size = this.vertical ? this._sliderDimensions.height : this._sliderDimensions.width; + let posComponent = this.vertical ? pos.y : pos.x; + + // The exact value is calculated from the event and used to find the closest snap value. + let percent = this._clamp((posComponent - offset) / size); + + if (this._shouldInvertMouseCoords()) { + percent = 1 - percent; + } + + // Since the steps may not divide cleanly into the max value, if the user + // slid to 0 or 100 percent, we jump to the min/max value. This approach + // is slightly more intuitive than using `Math.ceil` below, because it + // follows the user's pointer closer. + if (percent === 0) { + this.value = this.min; + } else if (percent === 1) { + this.value = this.max; + } else { + const exactValue = this._calculateValue(percent); + + // This calculation finds the closest step by finding the closest + // whole number divisible by the step relative to the min. + const closestValue = Math.round((exactValue - this.min) / this.step) * this.step + this.min; + + // The value needs to snap to the min and max. + this.value = this._clamp(closestValue, this.min, this.max); + } + } + + /** Emits a change event if the current value is different from the last emitted value. */ + private _emitChangeEvent() { + this._controlValueAccessorChangeFn(this.value); + this.valueChange.emit(this.value); + this.change.emit(this._createChangeEvent()); + } + + /** Emits an input event when the current value is different from the last emitted value. */ + private _emitInputEvent() { + this.input.emit(this._createChangeEvent()); + } + + /** Updates the amount of space between ticks as a percentage of the width of the slider. */ + private _updateTickIntervalPercent() { + if (!this.tickInterval || !this._sliderDimensions) { + return; + } + + let tickIntervalPercent: number; + if (this.tickInterval == 'auto') { + let trackSize = this.vertical ? this._sliderDimensions.height : this._sliderDimensions.width; + let pixelsPerStep = (trackSize * this.step) / (this.max - this.min); + let stepsPerTick = Math.ceil(MIN_AUTO_TICK_SEPARATION / pixelsPerStep); + let pixelsPerTick = stepsPerTick * this.step; + tickIntervalPercent = pixelsPerTick / trackSize; + } else { + tickIntervalPercent = (this.tickInterval * this.step) / (this.max - this.min); + } + this._tickIntervalPercent = isSafeNumber(tickIntervalPercent) ? tickIntervalPercent : 0; + } + + /** Creates a slider change object from the specified value. */ + private _createChangeEvent(value = this.value): MatLegacySliderChange { + let event = new MatLegacySliderChange(); + + event.source = this; + event.value = value; + + return event; + } + + /** Calculates the percentage of the slider that a value is. */ + private _calculatePercentage(value: number | null) { + const percentage = ((value || 0) - this.min) / (this.max - this.min); + return isSafeNumber(percentage) ? percentage : 0; + } + + /** Calculates the value a percentage of the slider corresponds to. */ + private _calculateValue(percentage: number) { + return this.min + percentage * (this.max - this.min); + } + + /** Return a number between two numbers. */ + private _clamp(value: number, min = 0, max = 1) { + return Math.max(min, Math.min(value, max)); + } + + /** + * Get the bounding client rect of the slider track element. + * The track is used rather than the native element to ignore the extra space that the thumb can + * take up. + */ + private _getSliderDimensions() { + return this._sliderWrapper ? this._sliderWrapper.nativeElement.getBoundingClientRect() : null; + } + + /** + * Focuses the native element. + * Currently only used to allow a blur event to fire but will be used with keyboard input later. + */ + private _focusHostElement(options?: FocusOptions) { + this._elementRef.nativeElement.focus(options); + } + + /** Blurs the native element. */ + private _blurHostElement() { + this._elementRef.nativeElement.blur(); + } + + /** + * Sets the model value. Implemented as part of ControlValueAccessor. + * @param value + */ + writeValue(value: any) { + this.value = value; + } + + /** + * Registers a callback to be triggered when the value has changed. + * Implemented as part of ControlValueAccessor. + * @param fn Callback to be registered. + */ + registerOnChange(fn: (value: any) => void) { + this._controlValueAccessorChangeFn = fn; + } + + /** + * Registers a callback to be triggered when the component is touched. + * Implemented as part of ControlValueAccessor. + * @param fn Callback to be registered. + */ + registerOnTouched(fn: any) { + this.onTouched = fn; + } + + /** + * Sets whether the component should be disabled. + * Implemented as part of ControlValueAccessor. + * @param isDisabled + */ + setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + } +} + +/** Checks if number is safe for calculation */ +function isSafeNumber(value: number) { + return !isNaN(value) && isFinite(value); +} + +/** Returns whether an event is a touch event. */ +function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent { + // This function is called for every pixel that the user has dragged so we need it to be + // as fast as possible. Since we only bind mouse events and touch events, we can assume + // that if the event's name starts with `t`, it's a touch event. + return event.type[0] === 't'; +} + +/** Gets the coordinates of a touch or mouse event relative to the viewport. */ +function getPointerPositionOnPage(event: MouseEvent | TouchEvent, id: number | undefined) { + let point: {clientX: number; clientY: number} | undefined; + + if (isTouchEvent(event)) { + // The `identifier` could be undefined if the browser doesn't support `TouchEvent.identifier`. + // If that's the case, attribute the first touch to all active sliders. This should still cover + // the most common case while only breaking multi-touch. + if (typeof id === 'number') { + point = findMatchingTouch(event.touches, id) || findMatchingTouch(event.changedTouches, id); + } else { + // `touches` will be empty for start/end events so we have to fall back to `changedTouches`. + point = event.touches[0] || event.changedTouches[0]; + } + } else { + point = event; + } + + return point ? {x: point.clientX, y: point.clientY} : undefined; +} + +/** Finds a `Touch` with a specific ID in a `TouchList`. */ +function findMatchingTouch(touches: TouchList, id: number): Touch | undefined { + for (let i = 0; i < touches.length; i++) { + if (touches[i].identifier === id) { + return touches[i]; + } + } + + return undefined; +} + +/** Gets the unique ID of a touch that matches a specific slider. */ +function getTouchIdForSlider(event: TouchEvent, sliderHost: HTMLElement): number | undefined { + for (let i = 0; i < event.touches.length; i++) { + const target = event.touches[i].target as HTMLElement; + + if (sliderHost === target || sliderHost.contains(target)) { + return event.touches[i].identifier; + } + } + + return undefined; +} diff --git a/src/material-experimental/mdc-slider/testing/BUILD.bazel b/src/material/legacy-slider/testing/BUILD.bazel similarity index 66% rename from src/material-experimental/mdc-slider/testing/BUILD.bazel rename to src/material/legacy-slider/testing/BUILD.bazel index 201517f8cdfa..a2cda7d83100 100644 --- a/src/material-experimental/mdc-slider/testing/BUILD.bazel +++ b/src/material/legacy-slider/testing/BUILD.bazel @@ -11,24 +11,30 @@ ts_library( deps = [ "//src/cdk/coercion", "//src/cdk/testing", - "//src/material-experimental/mdc-slider", ], ) +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +) + ng_test_library( name = "unit_tests_lib", - srcs = glob(["**/*.spec.ts"]), + srcs = glob( + ["**/*.spec.ts"], + ), deps = [ ":testing", "//src/cdk/testing", "//src/cdk/testing/testbed", - "//src/material-experimental/mdc-slider", + "//src/material/legacy-slider", + "@npm//@angular/forms", + "@npm//@angular/platform-browser", ], ) ng_web_test_suite( name = "unit_tests", - deps = [ - ":unit_tests_lib", - ], + deps = [":unit_tests_lib"], ) diff --git a/src/material-experimental/mdc-slider/testing/index.ts b/src/material/legacy-slider/testing/index.ts similarity index 100% rename from src/material-experimental/mdc-slider/testing/index.ts rename to src/material/legacy-slider/testing/index.ts diff --git a/src/material-experimental/mdc-slider/testing/public-api.ts b/src/material/legacy-slider/testing/public-api.ts similarity index 87% rename from src/material-experimental/mdc-slider/testing/public-api.ts rename to src/material/legacy-slider/testing/public-api.ts index 8c467ce19baf..608327c182fe 100644 --- a/src/material-experimental/mdc-slider/testing/public-api.ts +++ b/src/material/legacy-slider/testing/public-api.ts @@ -7,5 +7,4 @@ */ export * from './slider-harness'; -export * from './slider-thumb-harness'; export * from './slider-harness-filters'; diff --git a/src/material/legacy-slider/testing/slider-harness-filters.ts b/src/material/legacy-slider/testing/slider-harness-filters.ts new file mode 100644 index 000000000000..93d079d9ca10 --- /dev/null +++ b/src/material/legacy-slider/testing/slider-harness-filters.ts @@ -0,0 +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.io/license + */ +import {BaseHarnessFilters} from '@angular/cdk/testing'; + +/** A set of criteria that can be used to filter a list of `MatSliderHarness` instances. */ +export interface SliderHarnessFilters extends BaseHarnessFilters {} diff --git a/src/material/legacy-slider/testing/slider-harness.spec.ts b/src/material/legacy-slider/testing/slider-harness.spec.ts new file mode 100644 index 000000000000..b95212f97d67 --- /dev/null +++ b/src/material/legacy-slider/testing/slider-harness.spec.ts @@ -0,0 +1,191 @@ +import {HarnessLoader} from '@angular/cdk/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatLegacySliderModule} from '@angular/material/legacy-slider'; +import {MatLegacySliderHarness} from '@angular/material/legacy-slider/testing'; + +describe('Non-MDC-based MatSliderHarness', () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MatLegacySliderModule], + declarations: [SliderHarnessTest], + }).compileComponents(); + + fixture = TestBed.createComponent(SliderHarnessTest); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + }); + + it('should load all slider harnesses', async () => { + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); + expect(sliders.length).toBe(3); + }); + + it('should load slider harness by id', async () => { + const sliders = await loader.getAllHarnesses( + MatLegacySliderHarness.with({selector: '#my-slider'}), + ); + expect(sliders.length).toBe(1); + }); + + it('should get id of slider', async () => { + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); + expect(await sliders[0].getId()).toBe(null); + expect(await sliders[1].getId()).toBe('my-slider'); + expect(await sliders[2].getId()).toBe(null); + }); + + it('should get value of slider', async () => { + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); + expect(await sliders[0].getValue()).toBe(50); + expect(await sliders[1].getValue()).toBe(0); + expect(await sliders[2].getValue()).toBe(225); + }); + + it('should get percentage of slider', async () => { + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); + expect(await sliders[0].getPercentage()).toBe(0.5); + expect(await sliders[1].getPercentage()).toBe(0); + expect(await sliders[2].getPercentage()).toBe(0.5); + }); + + it('should get max value of slider', async () => { + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); + expect(await sliders[0].getMaxValue()).toBe(100); + expect(await sliders[1].getMaxValue()).toBe(100); + expect(await sliders[2].getMaxValue()).toBe(250); + }); + + it('should get min value of slider', async () => { + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); + expect(await sliders[0].getMinValue()).toBe(0); + expect(await sliders[1].getMinValue()).toBe(0); + expect(await sliders[2].getMinValue()).toBe(200); + }); + + it('should get display value of slider', async () => { + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); + expect(await sliders[0].getDisplayValue()).toBe(null); + expect(await sliders[1].getDisplayValue()).toBe('Null'); + expect(await sliders[2].getDisplayValue()).toBe('#225'); + }); + + it('should get orientation of slider', async () => { + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); + expect(await sliders[0].getOrientation()).toBe('horizontal'); + expect(await sliders[1].getOrientation()).toBe('horizontal'); + expect(await sliders[2].getOrientation()).toBe('vertical'); + }); + + it('should be able to focus slider', async () => { + // the first slider is disabled. + const slider = (await loader.getAllHarnesses(MatLegacySliderHarness))[1]; + expect(await slider.isFocused()).toBe(false); + await slider.focus(); + expect(await slider.isFocused()).toBe(true); + }); + + it('should be able to blur slider', async () => { + // the first slider is disabled. + const slider = (await loader.getAllHarnesses(MatLegacySliderHarness))[1]; + expect(await slider.isFocused()).toBe(false); + await slider.focus(); + expect(await slider.isFocused()).toBe(true); + await slider.blur(); + expect(await slider.isFocused()).toBe(false); + }); + + it('should be able to set value of slider', async () => { + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); + expect(await sliders[1].getValue()).toBe(0); + expect(await sliders[2].getValue()).toBe(225); + + await sliders[1].setValue(33); + await sliders[2].setValue(300); + + expect(await sliders[1].getValue()).toBe(33); + // value should be clamped to the maximum. + expect(await sliders[2].getValue()).toBe(250); + }); + + it('should be able to set value of slider in rtl', async () => { + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); + expect(await sliders[1].getValue()).toBe(0); + expect(await sliders[2].getValue()).toBe(225); + + // should not retrieve incorrect values in case slider is inverted + // due to RTL page layout. + fixture.componentInstance.dir = 'rtl'; + fixture.detectChanges(); + + await sliders[1].setValue(80); + expect(await sliders[1].getValue()).toBe(80); + }); + + it('should get disabled state of slider', async () => { + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); + expect(await sliders[0].isDisabled()).toBe(true); + expect(await sliders[1].isDisabled()).toBe(false); + expect(await sliders[2].isDisabled()).toBe(false); + }); + + it('should be able to set value of inverted slider', async () => { + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); + expect(await sliders[1].getValue()).toBe(0); + expect(await sliders[2].getValue()).toBe(225); + + fixture.componentInstance.invertSliders = true; + fixture.detectChanges(); + + await sliders[1].setValue(75); + await sliders[2].setValue(210); + + expect(await sliders[1].getValue()).toBe(75); + expect(await sliders[2].getValue()).toBe(210); + }); + + it('should be able to set value of inverted slider in rtl', async () => { + const sliders = await loader.getAllHarnesses(MatLegacySliderHarness); + expect(await sliders[1].getValue()).toBe(0); + expect(await sliders[2].getValue()).toBe(225); + + fixture.componentInstance.invertSliders = true; + fixture.componentInstance.dir = 'rtl'; + fixture.detectChanges(); + + await sliders[1].setValue(75); + await sliders[2].setValue(210); + + expect(await sliders[1].getValue()).toBe(75); + expect(await sliders[2].getValue()).toBe(210); + }); +}); + +@Component({ + template: ` + +
+ +
+ + + `, +}) +class SliderHarnessTest { + sliderId = 'my-slider'; + invertSliders = false; + dir = 'ltr'; + + displayFn(value: number | null) { + if (!value) { + return 'Null'; + } + return `#${value}`; + } +} diff --git a/src/material/legacy-slider/testing/slider-harness.ts b/src/material/legacy-slider/testing/slider-harness.ts new file mode 100644 index 000000000000..df7abfe21f17 --- /dev/null +++ b/src/material/legacy-slider/testing/slider-harness.ts @@ -0,0 +1,135 @@ +/** + * @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.io/license + */ + +import {ComponentHarness, HarnessPredicate, parallel} from '@angular/cdk/testing'; +import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; +import {SliderHarnessFilters} from './slider-harness-filters'; + +/** Harness for interacting with a standard mat-slider in tests. */ +export class MatLegacySliderHarness extends ComponentHarness { + /** The selector for the host element of a `MatSlider` instance. */ + static hostSelector = '.mat-slider'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a `MatSliderHarness` that meets + * certain criteria. + * @param options Options for filtering which slider instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: SliderHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MatLegacySliderHarness, options); + } + + private _textLabel = this.locatorFor('.mat-slider-thumb-label-text'); + private _wrapper = this.locatorFor('.mat-slider-wrapper'); + + /** Gets the slider's id. */ + async getId(): Promise { + const id = await (await this.host()).getAttribute('id'); + // In case no id has been specified, the "id" property always returns + // an empty string. To make this method more explicit, we return null. + return id !== '' ? id : null; + } + + /** + * Gets the current display value of the slider. Returns a null promise if the thumb label is + * disabled. + */ + async getDisplayValue(): Promise { + const [host, textLabel] = await parallel(() => [this.host(), this._textLabel()]); + if (await host.hasClass('mat-slider-thumb-label-showing')) { + return textLabel.text(); + } + return null; + } + + /** Gets the current percentage value of the slider. */ + async getPercentage(): Promise { + return this._calculatePercentage(await this.getValue()); + } + + /** Gets the current value of the slider. */ + async getValue(): Promise { + return coerceNumberProperty(await (await this.host()).getAttribute('aria-valuenow')); + } + + /** Gets the maximum value of the slider. */ + async getMaxValue(): Promise { + return coerceNumberProperty(await (await this.host()).getAttribute('aria-valuemax')); + } + + /** Gets the minimum value of the slider. */ + async getMinValue(): Promise { + return coerceNumberProperty(await (await this.host()).getAttribute('aria-valuemin')); + } + + /** Whether the slider is disabled. */ + async isDisabled(): Promise { + const disabled = (await this.host()).getAttribute('aria-disabled'); + return coerceBooleanProperty(await disabled); + } + + /** Gets the orientation of the slider. */ + async getOrientation(): Promise<'horizontal' | 'vertical'> { + // "aria-orientation" will always be set to either "horizontal" or "vertical". + return (await this.host()).getAttribute('aria-orientation') as any; + } + + /** + * Sets the value of the slider by clicking on the slider track. + * + * Note that in rare cases the value cannot be set to the exact specified value. This + * can happen if not every value of the slider maps to a single pixel that could be + * clicked using mouse interaction. In such cases consider using the keyboard to + * select the given value or expand the slider's size for a better user experience. + */ + async setValue(value: number): Promise { + const [sliderEl, wrapperEl, orientation] = await parallel(() => [ + this.host(), + this._wrapper(), + this.getOrientation(), + ]); + let percentage = await this._calculatePercentage(value); + const {height, width} = await wrapperEl.getDimensions(); + const isVertical = orientation === 'vertical'; + + // In case the slider is inverted in LTR mode or not inverted in RTL mode, + // we need to invert the percentage so that the proper value is set. + if (await sliderEl.hasClass('mat-slider-invert-mouse-coords')) { + percentage = 1 - percentage; + } + + // We need to round the new coordinates because creating fake DOM + // events will cause the coordinates to be rounded down. + const relativeX = isVertical ? 0 : Math.round(width * percentage); + const relativeY = isVertical ? Math.round(height * percentage) : 0; + + await wrapperEl.click(relativeX, relativeY); + } + + /** Focuses the slider. */ + async focus(): Promise { + return (await this.host()).focus(); + } + + /** Blurs the slider. */ + async blur(): Promise { + return (await this.host()).blur(); + } + + /** Whether the slider is focused. */ + async isFocused(): Promise { + return (await this.host()).isFocused(); + } + + /** Calculates the percentage of the given value. */ + private async _calculatePercentage(value: number) { + const [min, max] = await parallel(() => [this.getMinValue(), this.getMaxValue()]); + return (value - min) / (max - min); + } +} diff --git a/src/material/slider/BUILD.bazel b/src/material/slider/BUILD.bazel index 4b3b59037f67..5a974bc84b49 100644 --- a/src/material/slider/BUILD.bazel +++ b/src/material/slider/BUILD.bazel @@ -1,6 +1,8 @@ +load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") load( "//tools:defaults.bzl", "markdown_to_html", + "ng_e2e_test_library", "ng_module", "ng_test_library", "ng_web_test_suite", @@ -16,40 +18,60 @@ ng_module( ["**/*.ts"], exclude = ["**/*.spec.ts"], ), - assets = [":slider.css"] + glob(["**/*.html"]), + assets = [ + ":slider_scss", + ":slider_thumb_scss", + ] + glob(["**/*.html"]), deps = [ - "//src/cdk/a11y", "//src/cdk/bidi", "//src/cdk/coercion", - "//src/cdk/keycodes", "//src/cdk/platform", "//src/material/core", - "@npm//@angular/animations", - "@npm//@angular/common", - "@npm//@angular/core", "@npm//@angular/forms", - "@npm//@angular/platform-browser", - "@npm//rxjs", + "@npm//@material/base", + "@npm//@material/slider", ], ) sass_library( name = "slider_scss_lib", srcs = glob(["**/_*.scss"]), - deps = ["//src/material/core:core_scss_lib"], + deps = [ + "//:mdc_sass_lib", + "//src/material/core:core_scss_lib", + ], ) sass_binary( name = "slider_scss", src = "slider.scss", deps = [ - "//src/cdk:sass_lib", + "//:mdc_sass_lib", "//src/material/core:core_scss_lib", ], ) +sass_binary( + name = "slider_thumb_scss", + src = "slider-thumb.scss", +) + +markdown_to_html( + name = "overview", + srcs = [":slider.md"], +) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +) + +########### +# Testing +########### + ng_test_library( - name = "unit_test_sources", + name = "slider_tests_lib", srcs = glob( ["**/*.spec.ts"], exclude = ["**/*.e2e.spec.ts"], @@ -58,24 +80,37 @@ ng_test_library( ":slider", "//src/cdk/bidi", "//src/cdk/keycodes", + "//src/cdk/platform", "//src/cdk/testing/private", - "//src/material/testing", + "//src/material/core", "@npm//@angular/forms", "@npm//@angular/platform-browser", + "@npm//@material/slider", + "@npm//rxjs", ], ) ng_web_test_suite( name = "unit_tests", - deps = [":unit_test_sources"], + deps = [ + ":slider_tests_lib", + ], ) -markdown_to_html( - name = "overview", - srcs = [":slider.md"], +ng_e2e_test_library( + name = "e2e_test_sources", + srcs = glob(["**/*.e2e.spec.ts"]), + deps = [ + ":slider", + "//src/cdk/testing/private/e2e", + "@npm//@material/slider", + ], ) -filegroup( - name = "source-files", - srcs = glob(["**/*.ts"]), +e2e_test_suite( + name = "e2e_tests", + deps = [ + ":e2e_test_sources", + "//src/cdk/testing/private/e2e", + ], ) diff --git a/src/material/slider/README.md b/src/material/slider/README.md index 0242d8a8153c..bcfdb2a23c20 100644 --- a/src/material/slider/README.md +++ b/src/material/slider/README.md @@ -1 +1 @@ -Please see the official documentation at https://material.angular.io/components/component/slider \ No newline at end of file +Please see the official documentation at https://material.angular.io/components/component/slider diff --git a/src/material/slider/_slider-legacy-index.scss b/src/material/slider/_slider-legacy-index.scss deleted file mode 100644 index 6055dcce5d8a..000000000000 --- a/src/material/slider/_slider-legacy-index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@forward 'slider-theme' hide color, theme, typography; -@forward 'slider-theme' as mat-slider-* hide mat-slider-density, mat-slider-inner-content-theme; diff --git a/src/material/slider/_slider-theme.import.scss b/src/material/slider/_slider-theme.import.scss index 33b2b790d0cb..dc7a93eca3fc 100644 --- a/src/material/slider/_slider-theme.import.scss +++ b/src/material/slider/_slider-theme.import.scss @@ -1,8 +1,2 @@ -@forward '../core/theming/theming.import'; -@forward '../core/typography/typography-utils.import'; -@forward 'slider-theme' hide color, theme, typography; -@forward 'slider-theme' as mat-slider-* hide mat-slider-density, mat-slider-inner-content-theme; +@forward 'slider-theme' as mat-mdc-slider-*; -@import '../core/theming/palette'; -@import '../core/theming/theming'; -@import '../core/typography/typography-utils'; diff --git a/src/material/slider/_slider-theme.scss b/src/material/slider/_slider-theme.scss index 745564eb7fd2..db580a02d03c 100644 --- a/src/material/slider/_slider-theme.scss +++ b/src/material/slider/_slider-theme.scss @@ -1,188 +1,61 @@ @use 'sass:map'; -@use 'sass:meta'; + +@use '@material/slider/slider' as mdc-slider; +@use '@material/slider/slider-theme'; +@use '@material/theme/variables' as theme-variables; +@use '../core/mdc-helpers/mdc-helpers'; @use '../core/theming/theming'; @use '../core/typography/typography'; -@use '../core/typography/typography-utils'; - -@mixin _inner-content-theme($palette) { - .mat-slider-track-fill, - .mat-slider-thumb, - .mat-slider-thumb-label { - background-color: theming.get-color-from-palette($palette); - } +@use '../core/ripple/ripple-theme'; - .mat-slider-thumb-label-text { - color: theming.get-color-from-palette($palette, default-contrast); - } - - .mat-slider-focus-ring { - $opacity: 0.2; - $color: theming.get-color-from-palette($palette, default, $opacity); - background-color: $color; - - // `mat-color` uses `rgba` for the opacity which won't work with - // CSS variables so we need to use `opacity` as a fallback. - @if (meta.type-of($color) != color) { - opacity: $opacity; - } - } -} @mixin color($config-or-theme) { $config: theming.get-color-config($config-or-theme); - $primary: map.get($config, primary); - $accent: map.get($config, accent); - $warn: map.get($config, warn); - $background: map.get($config, background); - $foreground: map.get($config, foreground); - - $mat-slider-off-color: theming.get-color-from-palette($foreground, slider-off); - $mat-slider-off-focused-color: theming.get-color-from-palette($foreground, slider-off-active); - $mat-slider-disabled-color: theming.get-color-from-palette($foreground, slider-off); - $mat-slider-labeled-min-value-thumb-color: - theming.get-color-from-palette($foreground, slider-min); - $mat-slider-labeled-min-value-thumb-label-color: - theming.get-color-from-palette($foreground, slider-off); - $mat-slider-tick-opacity: 0.7; - $mat-slider-tick-color: - theming.get-color-from-palette($foreground, base, $mat-slider-tick-opacity); - $mat-slider-tick-size: 2px; - - .mat-slider-track-background { - background-color: $mat-slider-off-color; - } - - .mat-slider { - &.mat-primary { - @include _inner-content-theme($primary); - } - - &.mat-accent { - @include _inner-content-theme($accent); - } - - &.mat-warn { - @include _inner-content-theme($warn); - } - } - - .mat-slider:hover, - .mat-slider.cdk-focused { - .mat-slider-track-background { - background-color: $mat-slider-off-focused-color; - } - } - - .mat-slider.mat-slider-disabled { - .mat-slider-track-background, - .mat-slider-track-fill, - .mat-slider-thumb { - background-color: $mat-slider-disabled-color; - } - - &:hover { - .mat-slider-track-background { - background-color: $mat-slider-disabled-color; - } - } - } - - .mat-slider.mat-slider-min-value { - .mat-slider-focus-ring { - $opacity: 0.12; - $color: theming.get-color-from-palette($foreground, base, $opacity); - background-color: $color; - - // `mat-color` uses `rgba` for the opacity which won't work with - // CSS variables so we need to use `opacity` as a fallback. - @if (meta.type-of($color) != color) { - opacity: $opacity; + @include mdc-helpers.using-mdc-theme($config) { + @include mdc-slider.without-ripple($query: mdc-helpers.$mdc-theme-styles-query); + + .mat-mdc-slider { + &.mat-primary, &.mat-accent, &.mat-warn { + $is-dark: map.get($config, is-dark); + $indicator-color: if($is-dark, white, black); + $indicator-text-color: if($is-dark, black, white); + $indicator-opacity: if($is-dark, 0.9, 0.6); + + @include slider-theme.value-indicator-color( + $color: $indicator-color, + $opacity: $indicator-opacity, + $query: mdc-helpers.$mdc-theme-styles-query + ); + @include slider-theme.value-indicator-text-color( + $color: $indicator-text-color, + $query: mdc-helpers.$mdc-theme-styles-query + ); } - } - &.mat-slider-thumb-label-showing { - .mat-slider-thumb, - .mat-slider-thumb-label { - background-color: $mat-slider-labeled-min-value-thumb-color; + &.mat-primary { + @include _custom-slider-color(primary, on-primary); } - &.cdk-focused { - .mat-slider-thumb, - .mat-slider-thumb-label { - background-color: $mat-slider-labeled-min-value-thumb-label-color; - } + &.mat-accent { + @include _custom-slider-color(secondary, on-secondary); } - } - &:not(.mat-slider-thumb-label-showing) { - .mat-slider-thumb { - border-color: $mat-slider-off-color; - background-color: transparent; + &.mat-warn { + @include _custom-slider-color(error, on-error); } - - &:hover, - &.cdk-focused { - .mat-slider-thumb { - border-color: $mat-slider-off-focused-color; - } - - &.mat-slider-disabled .mat-slider-thumb { - border-color: $mat-slider-disabled-color; - } - } - } - } - - .mat-slider-has-ticks .mat-slider-wrapper::after { - border-color: $mat-slider-tick-color; - - // `mat-color` uses `rgba` for the opacity which won't work with - // CSS variables so we need to use `opacity` as a fallback. - @if (meta.type-of($mat-slider-tick-color) != color) { - opacity: $mat-slider-tick-opacity; - } - } - - .mat-slider-horizontal .mat-slider-ticks { - background-image: repeating-linear-gradient(to right, $mat-slider-tick-color, - $mat-slider-tick-color $mat-slider-tick-size, transparent 0, transparent); - // Firefox doesn't draw the gradient correctly with 'to right' - // (see https://bugzilla.mozilla.org/show_bug.cgi?id=1314319). - background-image: -moz-repeating-linear-gradient(0.0001deg, $mat-slider-tick-color, - $mat-slider-tick-color $mat-slider-tick-size, transparent 0, transparent); - - // `mat-color` uses `rgba` for the opacity which won't work with - // CSS variables so we need to use `opacity` as a fallback. - @if (meta.type-of($mat-slider-tick-color) != color) { - opacity: $mat-slider-tick-opacity; - } - } - - .mat-slider-vertical .mat-slider-ticks { - background-image: repeating-linear-gradient(to bottom, $mat-slider-tick-color, - $mat-slider-tick-color $mat-slider-tick-size, transparent 0, transparent); - - // `mat-color` uses `rgba` for the opacity which won't work with - // CSS variables so we need to use `opacity` as a fallback. - @if (meta.type-of($mat-slider-tick-color) != color) { - opacity: $mat-slider-tick-opacity; } } } @mixin typography($config-or-theme) { - $config: typography.private-typography-to-2014-config( + $config: typography.private-typography-to-2018-config( theming.get-typography-config($config-or-theme)); - .mat-slider-thumb-label-text { - font: { - family: typography-utils.font-family($config); - size: typography-utils.font-size($config, caption); - weight: typography-utils.font-weight($config, body-2); - } + @include mdc-helpers.using-mdc-typography($config) { + @include mdc-slider.without-ripple($query: mdc-helpers.$mdc-typography-styles-query); } } -@mixin _density($config-or-theme) {} +@mixin density($config-or-theme) {} @mixin theme($theme-or-color-config) { $theme: theming.private-legacy-get-theme($theme-or-color-config); @@ -195,10 +68,60 @@ @include color($color); } @if $density != null { - @include _density($density); + @include density($density); } @if $typography != null { @include typography($typography); } } } + +@mixin _custom-slider-color($color, $on-color) { + @include slider-theme.thumb-color( + $color-or-map: ( + default: $color, + disabled: on-surface, + ), + $query: mdc-helpers.$mdc-theme-styles-query + ); + @include slider-theme.track-active-color( + $color-or-map: ( + default: $color, + disabled: on-surface, + ), + $query: mdc-helpers.$mdc-theme-styles-query + ); + @include slider-theme.track-inactive-color( + $color-or-map: ( + default: $color, + disabled: on-surface, + ), + $query: mdc-helpers.$mdc-theme-styles-query + ); + @include slider-theme.tick-mark-active-color( + $color-or-map: ( + default: $on-color, + disabled: surface, + ), + $query: mdc-helpers.$mdc-theme-styles-query + ); + @include slider-theme.tick-mark-inactive-color( + $color-or-map: ( + default: $color, + disabled: on-surface, + ), + $query: mdc-helpers.$mdc-theme-styles-query + ); + $ripple-color: map.get(theme-variables.$property-values, $color); + @include ripple-theme.color(( + foreground: ( + base: $ripple-color + ), + )); + .mat-mdc-slider-hover-ripple { + background-color: rgba($ripple-color, 0.05); + } + .mat-mdc-slider-focus-ripple, .mat-mdc-slider-active-ripple { + background-color: rgba($ripple-color, 0.2); + } +} diff --git a/src/material-experimental/mdc-slider/global-change-and-input-listener.ts b/src/material/slider/global-change-and-input-listener.ts similarity index 100% rename from src/material-experimental/mdc-slider/global-change-and-input-listener.ts rename to src/material/slider/global-change-and-input-listener.ts diff --git a/src/material-experimental/mdc-slider/module.ts b/src/material/slider/module.ts similarity index 100% rename from src/material-experimental/mdc-slider/module.ts rename to src/material/slider/module.ts diff --git a/src/material/slider/public-api.ts b/src/material/slider/public-api.ts index a203c15f18ac..294dbb8a1be7 100644 --- a/src/material/slider/public-api.ts +++ b/src/material/slider/public-api.ts @@ -6,5 +6,5 @@ * found in the LICENSE file at https://angular.io/license */ -export * from './slider-module'; -export * from './slider'; +export {MatSlider, MatSliderThumb, MatSliderDragEvent} from './slider'; +export {MatSliderModule} from './module'; diff --git a/src/material-experimental/mdc-slider/slider-thumb.html b/src/material/slider/slider-thumb.html similarity index 100% rename from src/material-experimental/mdc-slider/slider-thumb.html rename to src/material/slider/slider-thumb.html diff --git a/src/material-experimental/mdc-slider/slider-thumb.scss b/src/material/slider/slider-thumb.scss similarity index 100% rename from src/material-experimental/mdc-slider/slider-thumb.scss rename to src/material/slider/slider-thumb.scss diff --git a/src/material-experimental/mdc-slider/slider.e2e.spec.ts b/src/material/slider/slider.e2e.spec.ts similarity index 100% rename from src/material-experimental/mdc-slider/slider.e2e.spec.ts rename to src/material/slider/slider.e2e.spec.ts diff --git a/src/material/slider/slider.html b/src/material/slider/slider.html index d607be8ade39..caa46f348407 100644 --- a/src/material/slider/slider.html +++ b/src/material/slider/slider.html @@ -1,16 +1,22 @@ -
-
-
-
+ + + + +
+
+
+
-
-
-
-
-
-
-
- {{displayValue}} -
+
+
+ + + + diff --git a/src/material/slider/slider.scss b/src/material/slider/slider.scss index c16637bf9230..1ff9540d6e08 100644 --- a/src/material/slider/slider.scss +++ b/src/material/slider/slider.scss @@ -1,495 +1,48 @@ -@use '@angular/cdk'; -@use 'sass:math'; +@use '@material/slider/slider' as mdc-slider; +@use '../core/mdc-helpers/mdc-helpers'; -@use '../core/style/variables'; -@use '../core/style/vendor-prefixes'; - -// This refers to the thickness of the slider. On a horizontal slider this is the height, on a -// vertical slider this is the width. -$thickness: 48px !default; -$min-size: 128px !default; -$padding: 8px !default; - -$track-thickness: 2px !default; -$thumb-size: 20px !default; -$thumb-border-width: 3px !default; -$thumb-border-width-active: 2px !default; -$thumb-border-width-disabled: 4px !default; - -$thumb-default-scale: 0.7 !default; -$thumb-focus-scale: 1 !default; -$thumb-disabled-scale: 0.5 !default; - -$thumb-arrow-gap: 12px !default; - -$thumb-label-size: 28px !default; - -$tick-size: 2px !default; - -$focus-ring-size: 30px !default; +@include mdc-helpers.disable-mdc-fallback-declarations { + @include mdc-slider.without-ripple($query: mdc-helpers.$mdc-base-styles-query); +} +$mat-slider-min-size: 128px !default; +$mat-slider-horizontal-margin: 8px !default; -.mat-slider { +// Overwrites the mdc-slider default styles to match the visual appearance of the +// Angular Material standard slider. This involves making the slider an inline-block +// element, aligning it in the vertical middle of a line, specifying a default minimum +// width and adding horizontal margin. +.mat-mdc-slider { display: inline-block; - position: relative; box-sizing: border-box; - padding: $padding; outline: none; vertical-align: middle; - - &:not(.mat-slider-disabled):active, - &.mat-slider-sliding:not(.mat-slider-disabled) { - cursor: grabbing; - } -} - -.mat-slider-wrapper { - // force browser to show background-color when using the print function - @include vendor-prefixes.color-adjust(exact); - position: absolute; -} - -.mat-slider-track-wrapper { - position: absolute; - top: 0; - left: 0; - overflow: hidden; -} - -.mat-slider-track-fill { - position: absolute; - transform-origin: 0 0; - transition: - transform variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, - background-color variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function; -} - -.mat-slider-track-background { - position: absolute; - transform-origin: 100% 100%; - transition: - transform variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, - background-color variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function; -} - -.mat-slider-ticks-container { - position: absolute; - left: 0; - top: 0; - overflow: hidden; -} - -.mat-slider-ticks { - @include vendor-prefixes.private-background-clip(content-box); - background-repeat: repeat; - box-sizing: border-box; - opacity: 0; - transition: opacity variables.$swift-ease-out-duration - variables.$swift-ease-out-timing-function; -} - -.mat-slider-thumb-container { - position: absolute; - z-index: 1; - transition: transform variables.$swift-ease-out-duration - variables.$swift-ease-out-timing-function; -} - -.mat-slider-focus-ring { - position: absolute; - width: $focus-ring-size; - height: $focus-ring-size; - border-radius: 50%; - transform: scale(0); - opacity: 0; - transition: - transform variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, - background-color variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, - opacity variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function; - - .mat-slider.cdk-keyboard-focused &, - .mat-slider.cdk-program-focused & { - transform: scale(1); - opacity: 1; - } -} - -%_mat-slider-cursor { - .mat-slider:not(.mat-slider-disabled):not(.mat-slider-sliding) & { - cursor: grab; - } -} - -.mat-slider-thumb { - @extend %_mat-slider-cursor; - - position: absolute; - right: math.div(-$thumb-size, 2); - bottom: math.div(-$thumb-size, 2); - box-sizing: border-box; - width: $thumb-size; - height: $thumb-size; - border: $thumb-border-width solid transparent; - border-radius: 50%; - transform: scale($thumb-default-scale); - transition: - transform variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, - background-color variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, - border-color variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function; -} - -.mat-slider-thumb-label { - @extend %_mat-slider-cursor; - - display: none; - align-items: center; - justify-content: center; - position: absolute; - width: $thumb-label-size; - height: $thumb-label-size; - border-radius: 50%; - transition: - transform variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, - border-radius variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function, - background-color variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function; - - @include cdk.high-contrast(active, off) { - outline: solid 1px; - } -} - -.mat-slider-thumb-label-text { - z-index: 1; - opacity: 0; - transition: opacity variables.$swift-ease-out-duration variables.$swift-ease-out-timing-function; -} - - -// Slider sliding state. -.mat-slider-sliding { - .mat-slider-track-fill, - .mat-slider-track-background, - .mat-slider-thumb-container { - // Must use `transition-duration: 0ms` to disable animation rather than `transition: none`. - // On Mobile Safari `transition: none` causes the slider thumb to appear stuck. - transition-duration: 0ms; - } -} - - -// Slider with ticks when not disabled. -.mat-slider-has-ticks { - - .mat-slider-wrapper::after { - content: ''; - position: absolute; - border-width: 0; - border-style: solid; - opacity: 0; - transition: opacity variables.$swift-ease-out-duration - variables.$swift-ease-out-timing-function; - } - - &.cdk-focused, - &:hover { - &:not(.mat-slider-hide-last-tick) { - .mat-slider-wrapper::after { - opacity: 1; - } - } - - &:not(.mat-slider-disabled) .mat-slider-ticks { - opacity: 1; - } - } -} - - -// Slider with thumb label. -.mat-slider-thumb-label-showing { - .mat-slider-focus-ring { - display: none; + margin: { + left: $mat-slider-horizontal-margin; + right: $mat-slider-horizontal-margin; } - .mat-slider-thumb-label { - display: flex; - } -} - - -// Inverted slider. -.mat-slider-axis-inverted { - .mat-slider-track-fill { - transform-origin: 100% 100%; - } - - .mat-slider-track-background { - transform-origin: 0 0; - } -} - - -// Active slider. -.mat-slider:not(.mat-slider-disabled) { - &.cdk-focused { - &.mat-slider-thumb-label-showing .mat-slider-thumb { - transform: scale(0); - } - - .mat-slider-thumb-label { - border-radius: 50% 50% 0; - } + // Unset the default "width" property from the MDC slider class. We don't want + // the slider to automatically expand horizontally for backwards compatibility. + width: auto; + min-width: $mat-slider-min-size - (2 * $mat-slider-horizontal-margin); - .mat-slider-thumb-label-text { - opacity: 1; + &._mat-animation-noopable { + &.mdc-slider--discrete .mdc-slider__thumb, + &.mdc-slider--discrete .mdc-slider__track--active_fill, + .mdc-slider__value-indicator { + transition: none; } } - &.cdk-mouse-focused, - &.cdk-touch-focused, - &.cdk-program-focused { - .mat-slider-thumb { - border-width: $thumb-border-width-active; - transform: scale($thumb-focus-scale); - } + // Slider components have to set `border-radius: 50%` in order to support density scaling + // which will clip a square focus indicator so we have to turn it into a circle. + .mat-mdc-focus-indicator::before { + border-radius: 50%; } } - -// Disabled slider. -.mat-slider-disabled { - .mat-slider-focus-ring { - transform: scale(0); - opacity: 0; - } - - .mat-slider-thumb { - border-width: $thumb-border-width-disabled; - transform: scale($thumb-disabled-scale); - } - - .mat-slider-thumb-label { - display: none; - } -} - - -// Horizontal slider. -.mat-slider-horizontal { - height: $thickness; - min-width: $min-size; - - .mat-slider-wrapper { - height: $track-thickness; - top: math.div($thickness - $track-thickness, 2); - left: $padding; - right: $padding; - } - - .mat-slider-wrapper::after { - height: $track-thickness; - border-left-width: $tick-size; - right: 0; - top: 0; - } - - .mat-slider-track-wrapper { - height: $track-thickness; - width: 100%; - } - - .mat-slider-track-fill { - height: $track-thickness; - width: 100%; - transform: scaleX(0); - } - - .mat-slider-track-background { - height: $track-thickness; - width: 100%; - transform: scaleX(1); - } - - .mat-slider-ticks-container { - height: $track-thickness; - width: 100%; - - @include cdk.high-contrast(active, off) { - height: 0; - outline: solid $track-thickness; - top: math.div($track-thickness, 2); - } - } - - .mat-slider-ticks { - height: $track-thickness; - width: 100%; - } - - .mat-slider-thumb-container { - width: 100%; - height: 0; - top: 50%; - } - - .mat-slider-focus-ring { - top: math.div(-$focus-ring-size, 2); - right: math.div(-$focus-ring-size, 2); - } - - .mat-slider-thumb-label { - right: math.div(-$thumb-label-size, 2); - top: -($thumb-label-size + $thumb-arrow-gap); - transform: translateY(math.div($thumb-label-size, 2) + $thumb-arrow-gap) - scale(0.01) - rotate(45deg); - } - - .mat-slider-thumb-label-text { - transform: rotate(-45deg); - } - - &.cdk-focused { - .mat-slider-thumb-label { - transform: rotate(45deg); - } - - @include cdk.high-contrast(active, off) { - .mat-slider-thumb-label, - .mat-slider-thumb-label-text { - transform: none; - } - } - } -} - - -// Vertical slider. -.mat-slider-vertical { - width: $thickness; - min-height: $min-size; - - .mat-slider-wrapper { - width: $track-thickness; - top: $padding; - bottom: $padding; - left: math.div($thickness - $track-thickness, 2); - } - - .mat-slider-wrapper::after { - width: $track-thickness; - border-top-width: $tick-size; - bottom: 0; - left: 0; - } - - .mat-slider-track-wrapper { - height: 100%; - width: $track-thickness; - } - - .mat-slider-track-fill { - height: 100%; - width: $track-thickness; - transform: scaleY(0); - } - - .mat-slider-track-background { - height: 100%; - width: $track-thickness; - transform: scaleY(1); - } - - .mat-slider-ticks-container { - width: $track-thickness; - height: 100%; - - @include cdk.high-contrast(active, off) { - width: 0; - outline: solid $track-thickness; - left: math.div($track-thickness, 2); - } - } - - .mat-slider-focus-ring { - bottom: math.div(-$focus-ring-size, 2); - left: math.div(-$focus-ring-size, 2); - } - - .mat-slider-ticks { - width: $track-thickness; - height: 100%; - } - - .mat-slider-thumb-container { - height: 100%; - width: 0; - left: 50%; - } - - .mat-slider-thumb { - @include vendor-prefixes.backface-visibility(hidden); - } - - .mat-slider-thumb-label { - bottom: math.div(-$thumb-label-size, 2); - left: -($thumb-label-size + $thumb-arrow-gap); - transform: translateX(math.div($thumb-label-size, 2) + $thumb-arrow-gap) - scale(0.01) - rotate(-45deg); - } - - .mat-slider-thumb-label-text { - transform: rotate(45deg); - } - - &.cdk-focused { - .mat-slider-thumb-label { - transform: rotate(-45deg); - } - } -} - - -// Slider in RTL languages. -[dir='rtl'] { - .mat-slider-wrapper::after { - left: 0; - right: auto; - } - - .mat-slider-horizontal { - .mat-slider-track-fill { - transform-origin: 100% 100%; - } - - .mat-slider-track-background { - transform-origin: 0 0; - } - - &.mat-slider-axis-inverted { - .mat-slider-track-fill { - transform-origin: 0 0; - } - - .mat-slider-track-background { - transform-origin: 100% 100%; - } - } - } -} - -// Slider inside a component with disabled animations. -.mat-slider._mat-animation-noopable { - .mat-slider-track-fill, - .mat-slider-track-background, - .mat-slider-ticks, - .mat-slider-thumb-container, - .mat-slider-focus-ring, - .mat-slider-thumb, - .mat-slider-thumb-label, - .mat-slider-thumb-label-text, - .mat-slider-has-ticks .mat-slider-wrapper::after { - transition: none; - } +// In the MDC slider the focus indicator is inside the thumb. +.mdc-slider__thumb--focused .mat-mdc-focus-indicator::before { + content: ''; } diff --git a/src/material/slider/slider.spec.ts b/src/material/slider/slider.spec.ts index a6fc53808d2b..0ca3cfc1c05a 100644 --- a/src/material/slider/slider.spec.ts +++ b/src/material/slider/slider.spec.ts @@ -1,907 +1,869 @@ -import {BidiModule} from '@angular/cdk/bidi'; -import { - BACKSPACE, - DOWN_ARROW, - END, - HOME, - LEFT_ARROW, - PAGE_DOWN, - PAGE_UP, - RIGHT_ARROW, - UP_ARROW, - A, -} from '@angular/cdk/keycodes'; +/** + * @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.io/license + */ + +import {BidiModule, Directionality} from '@angular/cdk/bidi'; +import {Platform} from '@angular/cdk/platform'; import { - createMouseEvent, - dispatchEvent, dispatchFakeEvent, - dispatchKeyboardEvent, dispatchMouseEvent, - createKeyboardEvent, - createTouchEvent, -} from '@angular/cdk/testing/private'; -import {Component, DebugElement, Type, ViewChild} from '@angular/core'; -import {ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing'; + dispatchPointerEvent, + dispatchTouchEvent, +} from '../../cdk/testing/private'; +import {Component, Provider, QueryList, Type, ViewChild, ViewChildren} from '@angular/core'; +import {ComponentFixture, fakeAsync, flush, TestBed, waitForAsync} from '@angular/core/testing'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {By} from '@angular/platform-browser'; -import {MatSlider, MatSliderModule} from './index'; +import {Thumb} from '@material/slider'; +import {of} from 'rxjs'; +import {MatSliderModule} from './module'; +import {MatSlider, MatSliderThumb, MatSliderVisualThumb} from './slider'; + +interface Point { + x: number; + y: number; +} + +describe('MDC-based MatSlider', () => { + let platform: Platform; -describe('MatSlider', () => { - function createComponent(component: Type): ComponentFixture { + function createComponent(component: Type, providers: Provider[] = []): ComponentFixture { TestBed.configureTestingModule({ - imports: [MatSliderModule, ReactiveFormsModule, FormsModule, BidiModule], + imports: [FormsModule, MatSliderModule, ReactiveFormsModule, BidiModule], declarations: [component], + providers: [...providers], }).compileComponents(); + platform = TestBed.inject(Platform); + // Mock #setPointerCapture as it throws errors on pointerdown without a real pointerId. + spyOn(Element.prototype, 'setPointerCapture'); + return TestBed.createComponent(component); } describe('standard slider', () => { let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; let sliderInstance: MatSlider; - let trackFillElement: HTMLElement; + let inputInstance: MatSliderThumb; - beforeEach(() => { + beforeEach(waitForAsync(() => { fixture = createComponent(StandardSlider); fixture.detectChanges(); - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); sliderInstance = sliderDebugElement.componentInstance; - - trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); - }); + inputInstance = sliderInstance._getInput(Thumb.END); + })); it('should set the default values', () => { - expect(sliderInstance.value).toBe(0); + expect(inputInstance.value).toBe(0); expect(sliderInstance.min).toBe(0); expect(sliderInstance.max).toBe(100); + expect(inputInstance._hostElement.getAttribute('aria-valuetext')).toBe('0'); }); it('should update the value on mousedown', () => { - expect(sliderInstance.value).toBe(0); - - dispatchMousedownEventSequence(sliderNativeElement, 0.19); - - expect(sliderInstance.value).toBe(19); - }); - - it('should not update when pressing the right mouse button', () => { - expect(sliderInstance.value).toBe(0); - - dispatchMousedownEventSequence(sliderNativeElement, 0.19, 1); - - expect(sliderInstance.value).toBe(0); + setValueByClick(sliderInstance, 19, platform.IOS); + expect(inputInstance.value).toBe(19); }); it('should update the value on a slide', () => { - expect(sliderInstance.value).toBe(0); - - dispatchSlideEventSequence(sliderNativeElement, 0, 0.89); - - expect(sliderInstance.value).toBe(89); + slideToValue(sliderInstance, 77, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(77); }); it('should set the value as min when sliding before the track', () => { - expect(sliderInstance.value).toBe(0); - - dispatchSlideEventSequence(sliderNativeElement, 0, -1.33); - - expect(sliderInstance.value).toBe(0); + slideToValue(sliderInstance, -1, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(0); }); it('should set the value as max when sliding past the track', () => { - expect(sliderInstance.value).toBe(0); - - dispatchSlideEventSequence(sliderNativeElement, 0, 1.75); - - expect(sliderInstance.value).toBe(100); + slideToValue(sliderInstance, 101, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(100); }); - it('should update the track fill on mousedown', () => { - expect(trackFillElement.style.transform).toContain('scale3d(0, 1, 1)'); - - dispatchMousedownEventSequence(sliderNativeElement, 0.39); - fixture.detectChanges(); - - expect(trackFillElement.style.transform).toContain('scale3d(0.39, 1, 1)'); + it('should focus the slider input when clicking on the slider', () => { + expect(document.activeElement).not.toBe(inputInstance._hostElement); + setValueByClick(sliderInstance, 0, platform.IOS); + expect(document.activeElement).toBe(inputInstance._hostElement); }); - it('should hide the fill element at zero percent', () => { - expect(trackFillElement.style.display).toBe('none'); - - dispatchMousedownEventSequence(sliderNativeElement, 0.39); - fixture.detectChanges(); - - expect(trackFillElement.style.display).toBeFalsy(); + it('should not break on when the page layout changes', () => { + sliderInstance._elementRef.nativeElement.style.marginLeft = '100px'; + setValueByClick(sliderInstance, 10, platform.IOS); + expect(inputInstance.value).toBe(10); + sliderInstance._elementRef.nativeElement.style.marginLeft = 'initial'; }); - it('should update the track fill on slide', () => { - expect(trackFillElement.style.transform).toContain('scale3d(0, 1, 1)'); - - dispatchSlideEventSequence(sliderNativeElement, 0, 0.86); - fixture.detectChanges(); - - expect(trackFillElement.style.transform).toContain('scale3d(0.86, 1, 1)'); + it('should not throw if destroyed before initialization is complete', () => { + fixture.destroy(); + fixture = TestBed.createComponent(StandardSlider); + expect(() => fixture.destroy()).not.toThrow(); }); + }); - it('should add and remove the mat-slider-sliding class when sliding', () => { - expect(sliderNativeElement.classList).not.toContain('mat-slider-sliding'); - - dispatchSlideStartEvent(sliderNativeElement, 0); - fixture.detectChanges(); - - expect(sliderNativeElement.classList).toContain('mat-slider-sliding'); + describe('standard range slider', () => { + let sliderInstance: MatSlider; + let startInputInstance: MatSliderThumb; + let sliderElement: HTMLElement; + let endInputInstance: MatSliderThumb; - dispatchSlideEndEvent(sliderNativeElement, 0.34); + beforeEach(waitForAsync(() => { + const fixture = createComponent(StandardRangeSlider); fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + startInputInstance = sliderInstance._getInput(Thumb.START); + sliderElement = sliderDebugElement.nativeElement; + endInputInstance = sliderInstance._getInput(Thumb.END); + })); - expect(sliderNativeElement.classList).not.toContain('mat-slider-sliding'); + it('should set the default values', () => { + expect(startInputInstance.value).toBe(0); + expect(endInputInstance.value).toBe(100); + expect(sliderInstance.min).toBe(0); + expect(sliderInstance.max).toBe(100); + expect(startInputInstance._hostElement.getAttribute('aria-valuetext')).toBe('0'); + expect(endInputInstance._hostElement.getAttribute('aria-valuetext')).toBe('100'); }); - it('should not interrupt sliding by pressing a key', () => { - expect(sliderNativeElement.classList).not.toContain('mat-slider-sliding'); - - dispatchSlideStartEvent(sliderNativeElement, 0); - fixture.detectChanges(); - - expect(sliderNativeElement.classList).toContain('mat-slider-sliding'); - - // Any key code will do here. Use A since it isn't associated with other actions. - dispatchKeyboardEvent(sliderNativeElement, 'keydown', A); - fixture.detectChanges(); - dispatchKeyboardEvent(sliderNativeElement, 'keyup', A); - fixture.detectChanges(); - - expect(sliderNativeElement.classList).toContain('mat-slider-sliding'); - - dispatchSlideEndEvent(sliderNativeElement, 0.34); - fixture.detectChanges(); - - expect(sliderNativeElement.classList).not.toContain('mat-slider-sliding'); + it('should update the start value on a slide', () => { + slideToValue(sliderInstance, 19, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(19); }); - it('should stop dragging if the page loses focus', () => { - const classlist = sliderNativeElement.classList; - - expect(classlist).not.toContain('mat-slider-sliding'); - - dispatchSlideStartEvent(sliderNativeElement, 0); - fixture.detectChanges(); - - expect(classlist).toContain('mat-slider-sliding'); - - dispatchSlideEvent(sliderNativeElement, 0.34); - fixture.detectChanges(); + it('should update the end value on a slide', () => { + slideToValue(sliderInstance, 27, Thumb.END, platform.IOS); + expect(endInputInstance.value).toBe(27); + }); - expect(classlist).toContain('mat-slider-sliding'); + it('should update the start value on mousedown behind the start thumb', () => { + sliderInstance._setValue(19, Thumb.START); + setValueByClick(sliderInstance, 12, platform.IOS); + expect(startInputInstance.value).toBe(12); + }); - dispatchFakeEvent(window, 'blur'); - fixture.detectChanges(); + it('should update the end value on mousedown in front of the end thumb', () => { + sliderInstance._setValue(27, Thumb.END); + setValueByClick(sliderInstance, 55, platform.IOS); + expect(endInputInstance.value).toBe(55); + }); - expect(classlist).not.toContain('mat-slider-sliding'); + it('should set the start value as min when sliding before the track', () => { + slideToValue(sliderInstance, -1, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(0); }); - it('should reset active state upon blur', () => { - sliderInstance._isActive = true; + it('should set the end value as max when sliding past the track', () => { + slideToValue(sliderInstance, 101, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(100); + }); - dispatchFakeEvent(sliderNativeElement, 'blur'); - fixture.detectChanges(); + it('should not let the start thumb slide past the end thumb', () => { + sliderInstance._setValue(50, Thumb.END); + slideToValue(sliderInstance, 75, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(50); + }); - expect(sliderInstance._isActive).toBe(false); + it('should not let the end thumb slide before the start thumb', () => { + sliderInstance._setValue(50, Thumb.START); + slideToValue(sliderInstance, 25, Thumb.END, platform.IOS); + expect(startInputInstance.value).toBe(50); }); - it('should reset thumb gap when blurred on min value', () => { - sliderInstance._isActive = true; - sliderInstance.value = 0; - fixture.detectChanges(); + it('should have a strong focus indicator in each of the thumbs', () => { + const indicators = sliderElement.querySelectorAll( + '.mat-mdc-slider-visual-thumb .mat-mdc-focus-indicator', + ); + expect(indicators.length).toBe(2); + }); + }); - expect(sliderInstance._getThumbGap()).toBe(10); + describe('disabled slider', () => { + let sliderInstance: MatSlider; + let inputInstance: MatSliderThumb; - dispatchFakeEvent(sliderNativeElement, 'blur'); + beforeEach(waitForAsync(() => { + const fixture = createComponent(DisabledSlider); fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + })); - expect(sliderInstance._getThumbGap()).toBe(7); + it('should be disabled', () => { + expect(sliderInstance.disabled).toBeTrue(); }); - it('should have thumb gap when at min value', () => { - expect(trackFillElement.style.transform).toContain('translateX(-7px)'); + it('should have the disabled class on the root element', () => { + expect(sliderInstance._elementRef.nativeElement.classList).toContain('mdc-slider--disabled'); }); - it('should not have thumb gap when not at min value', () => { - dispatchMousedownEventSequence(sliderNativeElement, 1); - fixture.detectChanges(); + it('should set the disabled attribute on the input element', () => { + expect(inputInstance._hostElement.disabled).toBeTrue(); + }); - // Some browsers use '0' and some use '0px', so leave off the closing paren. - expect(trackFillElement.style.transform).toContain('translateX(0'); + it('should not update the value on mousedown', () => { + setValueByClick(sliderInstance, 19, platform.IOS); + expect(inputInstance.value).toBe(0); }); - it('should have aria-orientation horizontal', () => { - expect(sliderNativeElement.getAttribute('aria-orientation')).toEqual('horizontal'); + it('should not update the value on a slide', () => { + slideToValue(sliderInstance, 77, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(0); }); + }); - it('should slide to the max value when the steps do not divide evenly into it', () => { - sliderInstance.min = 5; - sliderInstance.max = 100; - sliderInstance.step = 15; + describe('disabled range slider', () => { + let sliderInstance: MatSlider; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; - dispatchSlideEventSequence(sliderNativeElement, 0, 1); + beforeEach(waitForAsync(() => { + const fixture = createComponent(DisabledRangeSlider); fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + startInputInstance = sliderInstance._getInput(Thumb.START); + endInputInstance = sliderInstance._getInput(Thumb.END); + })); - expect(sliderInstance.value).toBe(100); + it('should be disabled', () => { + expect(sliderInstance.disabled).toBeTrue(); }); - it('should prevent the default action of the `selectstart` event', () => { - const event = dispatchFakeEvent(sliderNativeElement, 'selectstart'); - fixture.detectChanges(); + it('should have the disabled class on the root element', () => { + expect(sliderInstance._elementRef.nativeElement.classList).toContain('mdc-slider--disabled'); + }); - expect(event.defaultPrevented).toBe(true); + it('should set the disabled attribute on the input elements', () => { + expect(startInputInstance._hostElement.disabled).toBeTrue(); + expect(endInputInstance._hostElement.disabled).toBeTrue(); }); - it('should have a focus indicator', () => { - expect(sliderNativeElement.classList.contains('mat-focus-indicator')).toBe(true); + it('should not update the start value on a slide', () => { + slideToValue(sliderInstance, 19, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(0); }); - it('should not try to preventDefault on a non-cancelable event', () => { - const event = createTouchEvent('touchstart'); - const spy = spyOn(event, 'preventDefault'); - Object.defineProperty(event, 'cancelable', {value: false}); + it('should not update the end value on a slide', () => { + slideToValue(sliderInstance, 27, Thumb.END, platform.IOS); + expect(endInputInstance.value).toBe(100); + }); - dispatchEvent(sliderNativeElement, event); - fixture.detectChanges(); + it('should not update the start value on mousedown behind the start thumb', () => { + sliderInstance._setValue(19, Thumb.START); + setValueByClick(sliderInstance, 12, platform.IOS); + expect(startInputInstance.value).toBe(19); + }); - expect(spy).not.toHaveBeenCalled(); + it('should update the end value on mousedown in front of the end thumb', () => { + sliderInstance._setValue(27, Thumb.END); + setValueByClick(sliderInstance, 55, platform.IOS); + expect(endInputInstance.value).toBe(27); }); }); - describe('disabled slider', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let sliderInstance: MatSlider; - let trackFillElement: HTMLElement; + describe('ripple states', () => { + let inputInstance: MatSliderThumb; + let thumbInstance: MatSliderVisualThumb; + let thumbElement: HTMLElement; + let thumbX: number; + let thumbY: number; + + beforeEach(waitForAsync(() => { + const fixture = createComponent(StandardSlider); + fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + const sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + thumbInstance = sliderInstance._getThumb(Thumb.END); + thumbElement = thumbInstance._getHostElement(); + const thumbDimensions = thumbElement.getBoundingClientRect(); + thumbX = thumbDimensions.left - thumbDimensions.width / 2; + thumbY = thumbDimensions.top - thumbDimensions.height / 2; + })); - beforeEach(() => { - fixture = createComponent(DisabledSlider); - fixture.detectChanges(); + function isRippleVisible(selector: string) { + flushRippleTransitions(); + return thumbElement.querySelector(`.mat-mdc-slider-${selector}-ripple`) !== null; + } - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.componentInstance; - trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); - }); + function flushRippleTransitions() { + thumbElement.querySelectorAll('.mat-ripple-element').forEach(el => { + dispatchFakeEvent(el, 'transitionend'); + }); + } - it('should be disabled', () => { - expect(sliderInstance.disabled).toBeTruthy(); - }); + function blur() { + inputInstance._hostElement.blur(); + } - it('should not change the value on mousedown when disabled', () => { - expect(sliderInstance.value).toBe(0); + function mouseenter() { + dispatchMouseEvent(thumbElement, 'mouseenter', thumbX, thumbY); + } - dispatchMousedownEventSequence(sliderNativeElement, 0.63); + function mouseleave() { + dispatchMouseEvent(thumbElement, 'mouseleave', thumbX, thumbY); + } - expect(sliderInstance.value).toBe(0); - }); + function pointerdown() { + dispatchPointerOrTouchEvent( + thumbElement, + PointerEventType.POINTER_DOWN, + thumbX, + thumbY, + platform.IOS, + ); + } - it('should not change the value on slide when disabled', () => { - expect(sliderInstance.value).toBe(0); + function pointerup() { + dispatchPointerOrTouchEvent( + thumbElement, + PointerEventType.POINTER_UP, + thumbX, + thumbY, + platform.IOS, + ); + } - dispatchSlideEventSequence(sliderNativeElement, 0, 0.5); + it('should show the hover ripple on mouseenter', fakeAsync(() => { + expect(isRippleVisible('hover')).toBeFalse(); + mouseenter(); + expect(isRippleVisible('hover')).toBeTrue(); + })); - expect(sliderInstance.value).toBe(0); - }); + it('should hide the hover ripple on mouseleave', fakeAsync(() => { + mouseenter(); + mouseleave(); + expect(isRippleVisible('hover')).toBeFalse(); + })); - it('should not emit change when disabled', () => { - const onChangeSpy = jasmine.createSpy('slider onChange'); - sliderInstance.change.subscribe(onChangeSpy); + it('should show the focus ripple on pointerdown', fakeAsync(() => { + expect(isRippleVisible('focus')).toBeFalse(); + pointerdown(); + expect(isRippleVisible('focus')).toBeTrue(); + })); - dispatchSlideEventSequence(sliderNativeElement, 0, 0.5); + it('should continue to show the focus ripple on pointerup', fakeAsync(() => { + pointerdown(); + pointerup(); + expect(isRippleVisible('focus')).toBeTrue(); + })); - expect(onChangeSpy).toHaveBeenCalledTimes(0); - }); + it('should hide the focus ripple on blur', fakeAsync(() => { + pointerdown(); + pointerup(); + blur(); + expect(isRippleVisible('focus')).toBeFalse(); + })); - it('should not add the mat-slider-active class on mousedown when disabled', () => { - expect(sliderNativeElement.classList).not.toContain('mat-slider-active'); + it('should show the active ripple on pointerdown', fakeAsync(() => { + expect(isRippleVisible('active')).toBeFalse(); + pointerdown(); + expect(isRippleVisible('active')).toBeTrue(); + })); - dispatchMousedownEventSequence(sliderNativeElement, 0.43); - fixture.detectChanges(); + it('should hide the active ripple on pointerup', fakeAsync(() => { + pointerdown(); + pointerup(); + expect(isRippleVisible('active')).toBeFalse(); + })); - expect(sliderNativeElement.classList).not.toContain('mat-slider-active'); - }); + // Edge cases. - it('should not add the mat-slider-sliding class on slide when disabled', () => { - expect(sliderNativeElement.classList).not.toContain('mat-slider-sliding'); + it('should not show the hover ripple if the thumb is already focused', fakeAsync(() => { + pointerdown(); + mouseenter(); + expect(isRippleVisible('hover')).toBeFalse(); + })); - dispatchSlideStartEvent(sliderNativeElement, 0.46); - fixture.detectChanges(); + it('should hide the hover ripple if the thumb is focused', fakeAsync(() => { + mouseenter(); + pointerdown(); + expect(isRippleVisible('hover')).toBeFalse(); + })); - expect(sliderNativeElement.classList).not.toContain('mat-slider-sliding'); - }); + it('should not hide the focus ripple if the thumb is pressed', fakeAsync(() => { + pointerdown(); + blur(); + expect(isRippleVisible('focus')).toBeTrue(); + })); - it('should leave thumb gap', () => { - expect(trackFillElement.style.transform).toContain('translateX(-7px)'); - }); + it('should not hide the hover ripple on blur if the thumb is hovered', fakeAsync(() => { + mouseenter(); + pointerdown(); + pointerup(); + blur(); + expect(isRippleVisible('hover')).toBeTrue(); + })); - it('should disable tabbing to the slider', () => { - expect(sliderNativeElement.tabIndex).toBe(-1); - }); + it('should hide the focus ripple on drag end if the thumb already lost focus', fakeAsync(() => { + pointerdown(); + blur(); + pointerup(); + expect(isRippleVisible('focus')).toBeFalse(); + })); }); describe('slider with set min and max', () => { let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; let sliderInstance: MatSlider; - let trackFillElement: HTMLElement; - let ticksContainerElement: HTMLElement; - let ticksElement: HTMLElement; - let testComponent: SliderWithMinAndMax; + let inputInstance: MatSliderThumb; - beforeEach(() => { + beforeEach(waitForAsync(() => { fixture = createComponent(SliderWithMinAndMax); fixture.detectChanges(); - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - testComponent = fixture.debugElement.componentInstance; - sliderNativeElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.injector.get(MatSlider); - trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); - ticksContainerElement = ( - sliderNativeElement.querySelector('.mat-slider-ticks-container') - ); - ticksElement = sliderNativeElement.querySelector('.mat-slider-ticks'); - }); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + })); it('should set the default values from the attributes', () => { - expect(sliderInstance.value).toBe(4); - expect(sliderInstance.min).toBe(4); - expect(sliderInstance.max).toBe(6); + expect(inputInstance.value).toBe(25); + expect(sliderInstance.min).toBe(25); + expect(sliderInstance.max).toBe(75); }); it('should set the correct value on mousedown', () => { - dispatchMousedownEventSequence(sliderNativeElement, 0.09); - fixture.detectChanges(); - - // Computed by multiplying the difference between the min and the max by the percentage from - // the mousedown and adding that to the minimum. - const value = Math.round(4 + 0.09 * (6 - 4)); - expect(sliderInstance.value).toBe(value); + setValueByClick(sliderInstance, 33, platform.IOS); + expect(inputInstance.value).toBe(33); }); it('should set the correct value on slide', () => { - dispatchSlideEventSequence(sliderNativeElement, 0, 0.62); - fixture.detectChanges(); - - // Computed by multiplying the difference between the min and the max by the percentage from - // the mousedown and adding that to the minimum. - const value = Math.round(4 + 0.62 * (6 - 4)); - expect(sliderInstance.value).toBe(value); + slideToValue(sliderInstance, 55, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(55); }); - it('should snap the fill to the nearest value on mousedown', () => { - dispatchMousedownEventSequence(sliderNativeElement, 0.68); - fixture.detectChanges(); + it( + 'should be able to set the min and max values when they are more precise ' + 'than the step', + () => { + sliderInstance.step = 10; + slideToValue(sliderInstance, 25, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(25); + slideToValue(sliderInstance, 75, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(75); + }, + ); + }); - // The closest snap is halfway on the slider. - expect(trackFillElement.style.transform).toContain('scale3d(0.5, 1, 1)'); - }); + describe('range slider with set min and max', () => { + let fixture: ComponentFixture; + let sliderInstance: MatSlider; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; - it('should snap the fill to the nearest value on slide', () => { - dispatchSlideEventSequence(sliderNativeElement, 0, 0.74); + beforeEach(waitForAsync(() => { + fixture = createComponent(RangeSliderWithMinAndMax); fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + startInputInstance = sliderInstance._getInput(Thumb.START); + endInputInstance = sliderInstance._getInput(Thumb.END); + })); - // The closest snap is at the halfway point on the slider. - expect(trackFillElement.style.transform).toContain('scale3d(0.5, 1, 1)'); + it('should set the default values from the attributes', () => { + expect(startInputInstance.value).toBe(25); + expect(endInputInstance.value).toBe(75); + expect(sliderInstance.min).toBe(25); + expect(sliderInstance.max).toBe(75); }); - it('should adjust fill and ticks on mouse enter when min changes', () => { - testComponent.min = -2; - fixture.detectChanges(); - - dispatchMouseenterEvent(sliderNativeElement); - fixture.detectChanges(); - - expect(trackFillElement.style.transform).toContain('scale3d(0.75, 1, 1)'); - expect(ticksElement.style.backgroundSize).toBe('75% 2px'); - // Make sure it cuts off the last half tick interval. - expect(ticksElement.style.transform).toContain('translateX(37.5%)'); - expect(ticksContainerElement.style.transform).toBe('translateX(-37.5%)'); + it('should set the correct start value on mousedown behind the start thumb', () => { + sliderInstance._setValue(50, Thumb.START); + setValueByClick(sliderInstance, 33, platform.IOS); + expect(startInputInstance.value).toBe(33); }); - it('should adjust fill and ticks on mouse enter when max changes', () => { - testComponent.min = -2; - fixture.detectChanges(); - - testComponent.max = 10; - fixture.detectChanges(); + it('should set the correct end value on mousedown behind the end thumb', () => { + sliderInstance._setValue(50, Thumb.END); + setValueByClick(sliderInstance, 66, platform.IOS); + expect(endInputInstance.value).toBe(66); + }); - dispatchMouseenterEvent(sliderNativeElement); - fixture.detectChanges(); + it('should set the correct start value on slide', () => { + slideToValue(sliderInstance, 40, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(40); + }); - expect(trackFillElement.style.transform).toContain('scale3d(0.5, 1, 1)'); - expect(ticksElement.style.backgroundSize).toBe('50% 2px'); - // Make sure it cuts off the last half tick interval. - expect(ticksElement.style.transform).toContain('translateX(25%)'); - expect(ticksContainerElement.style.transform).toBe('translateX(-25%)'); + it('should set the correct end value on slide', () => { + slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); + expect(endInputInstance.value).toBe(60); }); it( 'should be able to set the min and max values when they are more precise ' + 'than the step', () => { - // Note that we assign min/max with more decimals than the - // step to ensure that the value doesn't get rounded up. - testComponent.step = 0.5; - testComponent.min = 10.15; - testComponent.max = 50.15; - fixture.detectChanges(); - - dispatchSlideEventSequence(sliderNativeElement, 0.5, 0); - fixture.detectChanges(); - - expect(sliderInstance.value).toBe(10.15); - expect(sliderInstance.percent).toBe(0); - - dispatchSlideEventSequence(sliderNativeElement, 0.5, 1); + sliderInstance.step = 10; fixture.detectChanges(); - - expect(sliderInstance.value).toBe(50.15); - expect(sliderInstance.percent).toBe(1); + slideToValue(sliderInstance, 25, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(25); + slideToValue(sliderInstance, 75, Thumb.END, platform.IOS); + expect(endInputInstance.value).toBe(75); }, ); - it('should properly update ticks when max value changed to 0', () => { - testComponent.min = 0; - testComponent.max = 100; - fixture.detectChanges(); - - dispatchMouseenterEvent(sliderNativeElement); - fixture.detectChanges(); - - expect(ticksElement.style.backgroundSize).toBe('6% 2px'); - expect(ticksElement.style.transform).toContain('translateX(3%)'); - - testComponent.max = 0; - fixture.detectChanges(); - - dispatchMouseenterEvent(sliderNativeElement); - fixture.detectChanges(); - - expect(ticksElement.style.backgroundSize).toBe('0% 2px'); - expect(ticksElement.style.transform).toContain('translateX(0%)'); - }); }); describe('slider with set value', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; let sliderInstance: MatSlider; + let inputInstance: MatSliderThumb; - beforeEach(() => { - fixture = createComponent(SliderWithValue); + beforeEach(waitForAsync(() => { + const fixture = createComponent(SliderWithValue); fixture.detectChanges(); - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.injector.get(MatSlider); - }); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + })); it('should set the default value from the attribute', () => { - expect(sliderInstance.value).toBe(26); + expect(inputInstance.value).toBe(50); }); it('should set the correct value on mousedown', () => { - dispatchMousedownEventSequence(sliderNativeElement, 0.92); - fixture.detectChanges(); - - // On a slider with default max and min the value should be approximately equal to the - // percentage clicked. This should be the case regardless of what the original set value was. - expect(sliderInstance.value).toBe(92); + setValueByClick(sliderInstance, 19, platform.IOS); + expect(inputInstance.value).toBe(19); }); it('should set the correct value on slide', () => { - dispatchSlideEventSequence(sliderNativeElement, 0, 0.32); - fixture.detectChanges(); - - expect(sliderInstance.value).toBe(32); + slideToValue(sliderInstance, 77, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(77); }); }); - describe('slider with set step', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; + describe('range slider with set value', () => { let sliderInstance: MatSlider; - let trackFillElement: HTMLElement; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; - beforeEach(() => { - fixture = createComponent(SliderWithStep); + beforeEach(waitForAsync(() => { + const fixture = createComponent(RangeSliderWithValue); fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + startInputInstance = sliderInstance._getInput(Thumb.START); + endInputInstance = sliderInstance._getInput(Thumb.END); + })); - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.injector.get(MatSlider); - trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); + it('should set the default value from the attribute', () => { + expect(startInputInstance.value).toBe(25); + expect(endInputInstance.value).toBe(75); }); - it('should set the correct step value on mousedown', () => { - expect(sliderInstance.value).toBe(0); - - dispatchMousedownEventSequence(sliderNativeElement, 0.13); - fixture.detectChanges(); + it('should set the correct start value on mousedown behind the start thumb', () => { + setValueByClick(sliderInstance, 19, platform.IOS); + expect(startInputInstance.value).toBe(19); + }); - expect(sliderInstance.value).toBe(25); + it('should set the correct start value on mousedown in front of the end thumb', () => { + setValueByClick(sliderInstance, 77, platform.IOS); + expect(endInputInstance.value).toBe(77); }); - it('should snap the fill to a step on mousedown', () => { - dispatchMousedownEventSequence(sliderNativeElement, 0.66); - fixture.detectChanges(); + it('should set the correct start value on slide', () => { + slideToValue(sliderInstance, 73, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(73); + }); - // The closest step is at 75% of the slider. - expect(trackFillElement.style.transform).toContain('scale3d(0.75, 1, 1)'); + it('should set the correct end value on slide', () => { + slideToValue(sliderInstance, 99, Thumb.END, platform.IOS); + expect(endInputInstance.value).toBe(99); }); + }); - it('should set the correct step value on slide', () => { - dispatchSlideEventSequence(sliderNativeElement, 0, 0.07); + describe('slider with set step', () => { + let fixture: ComponentFixture; + let sliderInstance: MatSlider; + let inputInstance: MatSliderThumb; + + beforeEach(waitForAsync(() => { + fixture = createComponent(SliderWithStep); fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + })); - expect(sliderInstance.value).toBe(0); + it('should set the correct step value on mousedown', () => { + expect(inputInstance.value).toBe(0); + setValueByClick(sliderInstance, 13, platform.IOS); + expect(inputInstance.value).toBe(25); }); - it('should snap the thumb and fill to a step on slide', () => { - dispatchSlideEventSequence(sliderNativeElement, 0, 0.88); - fixture.detectChanges(); - - // The closest snap is at the end of the slider. - expect(trackFillElement.style.transform).toContain('scale3d(1, 1, 1)'); + it('should set the correct step value on slide', () => { + slideToValue(sliderInstance, 12, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(0); }); it('should not add decimals to the value if it is a whole number', () => { - fixture.componentInstance.step = 0.1; - fixture.detectChanges(); - - dispatchSlideEventSequence(sliderNativeElement, 0, 1); - - expect(sliderDebugElement.componentInstance.displayValue).toBe(100); + sliderInstance.step = 0.1; + slideToValue(sliderInstance, 100, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(100); }); it('should truncate long decimal values when using a decimal step', () => { - fixture.componentInstance.step = 0.1; - fixture.detectChanges(); - - dispatchSlideEventSequence(sliderNativeElement, 0, 0.333333); - - expect(sliderInstance.value).toBe(33); - }); - - it('should truncate long decimal values when using a decimal step and the arrow keys', () => { - fixture.componentInstance.step = 0.1; - fixture.detectChanges(); - - for (let i = 0; i < 3; i++) { - dispatchKeyboardEvent(sliderNativeElement, 'keydown', UP_ARROW); - } - - expect(sliderInstance.value).toBe(0.3); + sliderInstance.step = 0.5; + slideToValue(sliderInstance, 55.555, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(55.5); }); + }); - it('should set the truncated value to the aria-valuetext', () => { - fixture.componentInstance.step = 0.1; - fixture.detectChanges(); + describe('range slider with set step', () => { + let fixture: ComponentFixture; + let sliderInstance: MatSlider; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; - dispatchSlideEventSequence(sliderNativeElement, 0, 0.333333); + beforeEach(waitForAsync(() => { + fixture = createComponent(RangeSliderWithStep); fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + startInputInstance = sliderInstance._getInput(Thumb.START); + endInputInstance = sliderInstance._getInput(Thumb.END); + })); - expect(sliderNativeElement.getAttribute('aria-valuetext')).toBe('33'); + it('should set the correct step value on mousedown behind the start thumb', () => { + sliderInstance._setValue(50, Thumb.START); + setValueByClick(sliderInstance, 13, platform.IOS); + expect(startInputInstance.value).toBe(25); }); - it('should be able to override the aria-valuetext', () => { - fixture.componentInstance.step = 0.1; - fixture.componentInstance.valueText = 'custom'; - fixture.detectChanges(); - - dispatchSlideEventSequence(sliderNativeElement, 0, 0.333333); - fixture.detectChanges(); - - expect(sliderNativeElement.getAttribute('aria-valuetext')).toBe('custom'); + it('should set the correct step value on mousedown in front of the end thumb', () => { + sliderInstance._setValue(50, Thumb.END); + setValueByClick(sliderInstance, 63, platform.IOS); + expect(endInputInstance.value).toBe(75); }); - it('should be able to clear aria-valuetext', () => { - fixture.componentInstance.valueText = ''; - fixture.detectChanges(); - - dispatchSlideEventSequence(sliderNativeElement, 0, 0.333333); - fixture.detectChanges(); - - expect(sliderNativeElement.getAttribute('aria-valuetext')).toBeFalsy(); + it('should set the correct start thumb step value on slide', () => { + slideToValue(sliderInstance, 26, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(25); }); - }); - - describe('slider with auto ticks', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let ticksContainerElement: HTMLElement; - let ticksElement: HTMLElement; - beforeEach(() => { - fixture = createComponent(SliderWithAutoTickInterval); - fixture.detectChanges(); - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; - ticksContainerElement = ( - sliderNativeElement.querySelector('.mat-slider-ticks-container') - ); - ticksElement = sliderNativeElement.querySelector('.mat-slider-ticks'); + it('should set the correct end thumb step value on slide', () => { + slideToValue(sliderInstance, 45, Thumb.END, platform.IOS); + expect(endInputInstance.value).toBe(50); }); - it('should set the correct tick separation on mouse enter', () => { - dispatchMouseenterEvent(sliderNativeElement); - fixture.detectChanges(); - - // Ticks should be 30px apart (therefore 30% for a 100px long slider). - expect(ticksElement.style.backgroundSize).toBe('30% 2px'); - // Make sure it cuts off the last half tick interval. - expect(ticksElement.style.transform).toContain('translateX(15%)'); - expect(ticksContainerElement.style.transform).toBe('translateX(-15%)'); + it('should not add decimals to the end value if it is a whole number', () => { + sliderInstance.step = 0.1; + slideToValue(sliderInstance, 100, Thumb.END, platform.IOS); + expect(endInputInstance.value).toBe(100); }); - }); - - describe('slider with set tick interval', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let ticksContainerElement: HTMLElement; - let ticksElement: HTMLElement; - beforeEach(() => { - fixture = createComponent(SliderWithSetTickInterval); - fixture.detectChanges(); - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; - ticksContainerElement = ( - sliderNativeElement.querySelector('.mat-slider-ticks-container') - ); - ticksElement = sliderNativeElement.querySelector('.mat-slider-ticks'); + it('should not add decimals to the start value if it is a whole number', () => { + sliderInstance.step = 0.1; + slideToValue(sliderInstance, 100, Thumb.END, platform.IOS); + expect(endInputInstance.value).toBe(100); }); - it('should set the correct tick separation on mouse enter', () => { - dispatchMouseenterEvent(sliderNativeElement); - fixture.detectChanges(); - - // Ticks should be every 18 values (tickInterval of 6 * step size of 3). On a slider 100px - // long with 100 values, this is 18%. - expect(ticksElement.style.backgroundSize).toBe('18% 2px'); - // Make sure it cuts off the last half tick interval. - expect(ticksElement.style.transform).toContain('translateX(9%)'); - expect(ticksContainerElement.style.transform).toBe('translateX(-9%)'); + it('should truncate long decimal start values when using a decimal step', () => { + sliderInstance.step = 0.1; + slideToValue(sliderInstance, 33.7, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(33.7); }); - it('should be able to reset the tick interval after it has been set', () => { - expect(sliderNativeElement.classList) - .withContext('Expected element to have ticks initially.') - .toContain('mat-slider-has-ticks'); + it('should truncate long decimal end values when using a decimal step', () => { + sliderInstance.step = 0.1; + slideToValue(sliderInstance, 33.7, Thumb.END, platform.IOS); + expect(endInputInstance.value).toBe(33.7); - fixture.componentInstance.tickInterval = 0; - fixture.detectChanges(); - - expect(sliderNativeElement.classList).not.toContain( - 'mat-slider-has-ticks', - 'Expected element not to have ticks after reset.', - ); + // NOTE(wagnermaciel): Different browsers treat the clientX dispatched by us differently. + // Below is an example of a case that should work but because Firefox rounds the clientX + // down, the clientX that gets dispatched (1695.998...) is not the same clientX that the MDC + // Foundation receives (1695). This means the test will pass on chromium but fail on Firefox. + // + // slideToValue(sliderInstance, 66.66, Thumb.END, platform.IOS); + // expect(endInputInstance.value).toBe(66.7); }); }); - describe('slider with thumb label', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; + describe('slider with custom thumb label formatting', () => { + let fixture: ComponentFixture; let sliderInstance: MatSlider; - let thumbLabelTextElement: Element; + let inputInstance: MatSliderThumb; + let valueIndicatorTextElement: Element; beforeEach(() => { - fixture = createComponent(SliderWithThumbLabel); + fixture = createComponent(DiscreteSliderWithDisplayWith); fixture.detectChanges(); - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; + const sliderNativeElement = sliderDebugElement.nativeElement; sliderInstance = sliderDebugElement.componentInstance; - thumbLabelTextElement = sliderNativeElement.querySelector('.mat-slider-thumb-label-text')!; + valueIndicatorTextElement = sliderNativeElement.querySelector( + '.mdc-slider__value-indicator-text', + )!; + inputInstance = sliderInstance._getInput(Thumb.END); }); - it('should add the thumb label class to the slider container', () => { - expect(sliderNativeElement.classList).toContain('mat-slider-thumb-label-showing'); + it('should set the aria-valuetext attribute with the given `displayWith` function', () => { + expect(inputInstance._hostElement.getAttribute('aria-valuetext')).toBe('$1'); + sliderInstance._setValue(10000, Thumb.END); + expect(inputInstance._hostElement.getAttribute('aria-valuetext')).toBe('$10k'); }); - it('should update the thumb label text on mousedown', () => { - expect(thumbLabelTextElement.textContent).toBe('0'); - - dispatchMousedownEventSequence(sliderNativeElement, 0.13); - fixture.detectChanges(); - - // The thumb label text is set to the slider's value. These should always be the same. - expect(thumbLabelTextElement.textContent).toBe('13'); + it('should invoke the passed-in `displayWith` function with the value', () => { + spyOn(sliderInstance, 'displayWith').and.callThrough(); + sliderInstance._setValue(1337, Thumb.END); + expect(sliderInstance.displayWith).toHaveBeenCalledWith(1337); }); - it('should update the thumb label text on slide', () => { - expect(thumbLabelTextElement.textContent).toBe('0'); - - dispatchSlideEventSequence(sliderNativeElement, 0, 0.56); + it('should format the thumb label based on the passed-in `displayWith` function', () => { + sliderInstance._setValue(200000, Thumb.END); fixture.detectChanges(); - - // The thumb label text is set to the slider's value. These should always be the same. - expect(thumbLabelTextElement.textContent).toBe(`${sliderInstance.value}`); + expect(valueIndicatorTextElement.textContent).toBe('$200k'); }); }); - describe('slider with custom thumb label formatting', () => { - let fixture: ComponentFixture; + describe('range slider with custom thumb label formatting', () => { + let fixture: ComponentFixture; let sliderInstance: MatSlider; - let thumbLabelTextElement: Element; + let startValueIndicatorTextElement: Element; + let endValueIndicatorTextElement: Element; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; beforeEach(() => { - fixture = createComponent(SliderWithCustomThumbLabelFormatting); + fixture = createComponent(DiscreteRangeSliderWithDisplayWith); fixture.detectChanges(); - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - const sliderNativeElement = sliderDebugElement.nativeElement; sliderInstance = sliderDebugElement.componentInstance; - thumbLabelTextElement = sliderNativeElement.querySelector('.mat-slider-thumb-label-text')!; + startInputInstance = sliderInstance._getInput(Thumb.START); + endInputInstance = sliderInstance._getInput(Thumb.END); + + const startThumbElement = sliderInstance._getThumbElement(Thumb.START); + const endThumbElement = sliderInstance._getThumbElement(Thumb.END); + startValueIndicatorTextElement = startThumbElement.querySelector( + '.mdc-slider__value-indicator-text', + )!; + endValueIndicatorTextElement = endThumbElement.querySelector( + '.mdc-slider__value-indicator-text', + )!; }); - it('should invoke the passed-in `displayWith` function with the value', () => { - spyOn(fixture.componentInstance, 'displayWith').and.callThrough(); + it('should set the aria-valuetext attribute with the given `displayWith` function', () => { + expect(startInputInstance._hostElement.getAttribute('aria-valuetext')).toBe('$1'); + expect(endInputInstance._hostElement.getAttribute('aria-valuetext')).toBe('$1000k'); + sliderInstance._setValue(250000, Thumb.START); + sliderInstance._setValue(810000, Thumb.END); + expect(startInputInstance._hostElement.getAttribute('aria-valuetext')).toBe('$250k'); + expect(endInputInstance._hostElement.getAttribute('aria-valuetext')).toBe('$810k'); + }); - sliderInstance.value = 1337; - fixture.detectChanges(); + it('should invoke the passed-in `displayWith` function with the start value', () => { + spyOn(sliderInstance, 'displayWith').and.callThrough(); + sliderInstance._setValue(1337, Thumb.START); + expect(sliderInstance.displayWith).toHaveBeenCalledWith(1337); + }); - expect(fixture.componentInstance.displayWith).toHaveBeenCalledWith(1337); + it('should invoke the passed-in `displayWith` function with the end value', () => { + spyOn(sliderInstance, 'displayWith').and.callThrough(); + sliderInstance._setValue(5996, Thumb.END); + expect(sliderInstance.displayWith).toHaveBeenCalledWith(5996); }); - it('should format the thumb label based on the passed-in `displayWith` function', () => { - sliderInstance.value = 200000; + it('should format the start thumb label based on the passed-in `displayWith` function', () => { + sliderInstance._setValue(200000, Thumb.START); fixture.detectChanges(); + expect(startValueIndicatorTextElement.textContent).toBe('$200k'); + }); - expect(thumbLabelTextElement.textContent).toBe('200k'); + it('should format the end thumb label based on the passed-in `displayWith` function', () => { + sliderInstance._setValue(700000, Thumb.END); + fixture.detectChanges(); + expect(endValueIndicatorTextElement.textContent).toBe('$700k'); }); }); describe('slider with value property binding', () => { let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let sliderInstance: MatSlider; let testComponent: SliderWithOneWayBinding; - let trackFillElement: HTMLElement; + let inputInstance: MatSliderThumb; - beforeEach(() => { + beforeEach(waitForAsync(() => { fixture = createComponent(SliderWithOneWayBinding); fixture.detectChanges(); - testComponent = fixture.debugElement.componentInstance; - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.injector.get(MatSlider); - trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); - }); - - it('should initialize based on bound value', () => { - expect(sliderInstance.value).toBe(50); - expect(trackFillElement.style.transform).toContain('scale3d(0.5, 1, 1)'); - }); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + const sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + })); it('should update when bound value changes', () => { - testComponent.val = 75; + testComponent.value = 75; fixture.detectChanges(); - - expect(sliderInstance.value).toBe(75); - expect(trackFillElement.style.transform).toContain('scale3d(0.75, 1, 1)'); + expect(inputInstance.value).toBe(75); }); }); - describe('slider with set min and max and a value smaller than min', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let sliderInstance: MatSlider; - let trackFillElement: HTMLElement; + describe('range slider with value property binding', () => { + let fixture: ComponentFixture; + let testComponent: RangeSliderWithOneWayBinding; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; - beforeEach(() => { - fixture = createComponent(SliderWithValueSmallerThanMin); + beforeEach(waitForAsync(() => { + fixture = createComponent(RangeSliderWithOneWayBinding); fixture.detectChanges(); + testComponent = fixture.debugElement.componentInstance; + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + const sliderInstance = sliderDebugElement.componentInstance; + startInputInstance = sliderInstance._getInput(Thumb.START); + endInputInstance = sliderInstance._getInput(Thumb.END); + })); - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.componentInstance; - trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); - }); - - it('should set the value smaller than the min value', () => { - expect(sliderInstance.value).toBe(3); - expect(sliderInstance.min).toBe(4); - expect(sliderInstance.max).toBe(6); - }); - - it('should set the fill to the min value', () => { - expect(trackFillElement.style.transform).toContain('scale3d(0, 1, 1)'); - }); - }); - - describe('slider with set min and max and a value greater than max', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let sliderInstance: MatSlider; - let trackFillElement: HTMLElement; - - beforeEach(() => { - fixture = createComponent(SliderWithValueGreaterThanMax); + it('should update when bound start value changes', () => { + testComponent.startValue = 30; fixture.detectChanges(); - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.componentInstance; - trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); + expect(startInputInstance.value).toBe(30); }); - it('should set the value greater than the max value', () => { - expect(sliderInstance.value).toBe(7); - expect(sliderInstance.min).toBe(4); - expect(sliderInstance.max).toBe(6); - }); - - it('should set the fill to the max value', () => { - expect(trackFillElement.style.transform).toContain('scale3d(1, 1, 1)'); + it('should update when bound end value changes', () => { + testComponent.endValue = 70; + fixture.detectChanges(); + expect(endInputInstance.value).toBe(70); }); }); describe('slider with change handler', () => { + let sliderInstance: MatSlider; + let inputInstance: MatSliderThumb; + let sliderElement: HTMLElement; let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; let testComponent: SliderWithChangeHandler; - beforeEach(() => { + beforeEach(waitForAsync(() => { fixture = createComponent(SliderWithChangeHandler); fixture.detectChanges(); - testComponent = fixture.debugElement.componentInstance; - spyOn(testComponent, 'onChange'); - spyOn(testComponent, 'onInput'); - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; - }); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + })); it('should emit change on mouseup', () => { expect(testComponent.onChange).not.toHaveBeenCalled(); - - dispatchMousedownEventSequence(sliderNativeElement, 0.2); - fixture.detectChanges(); - dispatchSlideEndEvent(sliderNativeElement, 0.2); - + setValueByClick(sliderInstance, 20, platform.IOS); expect(testComponent.onChange).toHaveBeenCalledTimes(1); }); it('should emit change on slide', () => { expect(testComponent.onChange).not.toHaveBeenCalled(); - - dispatchSlideEventSequence(sliderNativeElement, 0, 0.4); - fixture.detectChanges(); - + slideToValue(sliderInstance, 40, Thumb.END, platform.IOS); expect(testComponent.onChange).toHaveBeenCalledTimes(1); }); it('should not emit multiple changes for the same value', () => { expect(testComponent.onChange).not.toHaveBeenCalled(); - dispatchMousedownEventSequence(sliderNativeElement, 0.6); - dispatchSlideEventSequence(sliderNativeElement, 0.6, 0.6); - fixture.detectChanges(); + setValueByClick(sliderInstance, 60, platform.IOS); + slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); + setValueByClick(sliderInstance, 60, platform.IOS); + slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); expect(testComponent.onChange).toHaveBeenCalledTimes(1); }); @@ -910,30 +872,35 @@ describe('MatSlider', () => { 'should dispatch events when changing back to previously emitted value after ' + 'programmatically setting value', () => { + const dispatchSliderEvent = (type: PointerEventType, value: number) => { + const {x, y} = getCoordsForValue(sliderInstance, value); + dispatchPointerOrTouchEvent(sliderElement, type, x, y, platform.IOS); + }; + expect(testComponent.onChange).not.toHaveBeenCalled(); expect(testComponent.onInput).not.toHaveBeenCalled(); - dispatchMousedownEventSequence(sliderNativeElement, 0.2); + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 20); fixture.detectChanges(); expect(testComponent.onChange).not.toHaveBeenCalled(); expect(testComponent.onInput).toHaveBeenCalledTimes(1); - dispatchSlideEndEvent(sliderNativeElement, 0.2); + dispatchSliderEvent(PointerEventType.POINTER_UP, 20); fixture.detectChanges(); expect(testComponent.onChange).toHaveBeenCalledTimes(1); expect(testComponent.onInput).toHaveBeenCalledTimes(1); - testComponent.slider.value = 0; + inputInstance.value = 0; fixture.detectChanges(); expect(testComponent.onChange).toHaveBeenCalledTimes(1); expect(testComponent.onInput).toHaveBeenCalledTimes(1); - dispatchMousedownEventSequence(sliderNativeElement, 0.2); + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 20); fixture.detectChanges(); - dispatchSlideEndEvent(sliderNativeElement, 0.2); + dispatchSliderEvent(PointerEventType.POINTER_UP, 20); expect(testComponent.onChange).toHaveBeenCalledTimes(2); expect(testComponent.onInput).toHaveBeenCalledTimes(2); @@ -941,594 +908,662 @@ describe('MatSlider', () => { ); }); - describe('slider with input event', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let testComponent: SliderWithChangeHandler; - - beforeEach(() => { - fixture = createComponent(SliderWithChangeHandler); - fixture.detectChanges(); - - testComponent = fixture.debugElement.componentInstance; - spyOn(testComponent, 'onInput'); - spyOn(testComponent, 'onChange'); - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; - }); - - it('should emit an input event while sliding', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); - - dispatchMouseenterEvent(sliderNativeElement); - dispatchSlideStartEvent(sliderNativeElement, 0); - dispatchSlideEvent(sliderNativeElement, 0.5); - dispatchSlideEvent(sliderNativeElement, 1); - dispatchSlideEndEvent(sliderNativeElement, 1); - - fixture.detectChanges(); - - // The input event should fire twice, because the slider changed two times. - expect(testComponent.onInput).toHaveBeenCalledTimes(2); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - }); - - it('should emit an input event when clicking', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); - - dispatchMousedownEventSequence(sliderNativeElement, 0.75); - fixture.detectChanges(); - dispatchSlideEndEvent(sliderNativeElement, 0.75); - - // The `onInput` event should be emitted once due to a single click. - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - }); - }); - - describe('keyboard support', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let testComponent: SliderWithChangeHandler; + describe('range slider with change handlers', () => { let sliderInstance: MatSlider; - let trackFillElement: HTMLElement; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; + let sliderElement: HTMLElement; + let fixture: ComponentFixture; + let testComponent: RangeSliderWithChangeHandler; - beforeEach(() => { - fixture = createComponent(SliderWithChangeHandler); + beforeEach(waitForAsync(() => { + fixture = createComponent(RangeSliderWithChangeHandler); fixture.detectChanges(); - testComponent = fixture.debugElement.componentInstance; - spyOn(testComponent, 'onInput'); - spyOn(testComponent, 'onChange'); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + startInputInstance = sliderInstance._getInput(Thumb.START); + endInputInstance = sliderInstance._getInput(Thumb.END); + })); - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.injector.get(MatSlider); - trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill') as HTMLElement; + it('should emit change on mouseup on the start thumb', () => { + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + setValueByClick(sliderInstance, 20, platform.IOS); + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); }); - it('should increment slider by 1 on up arrow pressed', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); - - dispatchKeyboardEvent(sliderNativeElement, 'keydown', UP_ARROW); - fixture.detectChanges(); - - // The `onInput` event should be emitted once due to a single keyboard press. - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - expect(sliderInstance.value).toBe(1); + it('should emit change on mouseup on the end thumb', () => { + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + setValueByClick(sliderInstance, 80, platform.IOS); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); }); - it('should increment slider by 1 on right arrow pressed', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); - - dispatchKeyboardEvent(sliderNativeElement, 'keydown', RIGHT_ARROW); - fixture.detectChanges(); - - // The `onInput` event should be emitted once due to a single keyboard press. - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - expect(sliderInstance.value).toBe(1); + it('should emit change on start thumb slide', () => { + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + slideToValue(sliderInstance, 40, Thumb.START, platform.IOS); + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); }); - it('should decrement slider by 1 on down arrow pressed', () => { - sliderInstance.value = 100; - - expect(testComponent.onChange).not.toHaveBeenCalled(); - - dispatchKeyboardEvent(sliderNativeElement, 'keydown', DOWN_ARROW); - fixture.detectChanges(); - - // The `onInput` event should be emitted once due to a single keyboard press. - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - expect(sliderInstance.value).toBe(99); + it('should emit change on end thumb slide', () => { + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); }); - it('should decrement slider by 1 on left arrow pressed', () => { - sliderInstance.value = 100; + it('should not emit multiple changes for the same start thumb value', () => { + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - expect(testComponent.onChange).not.toHaveBeenCalled(); - - dispatchKeyboardEvent(sliderNativeElement, 'keydown', LEFT_ARROW); - fixture.detectChanges(); + setValueByClick(sliderInstance, 30, platform.IOS); + slideToValue(sliderInstance, 30, Thumb.START, platform.IOS); + setValueByClick(sliderInstance, 30, platform.IOS); + slideToValue(sliderInstance, 30, Thumb.START, platform.IOS); - // The `onInput` event should be emitted once due to a single keyboard press. - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - expect(sliderInstance.value).toBe(99); + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); }); - it('should decrement from max when interacting after out-of-bounds value is assigned', () => { - sliderInstance.max = 100; - sliderInstance.value = 200; - fixture.detectChanges(); - - expect(sliderInstance.value).toBe(200); - expect(trackFillElement.style.transform).toContain('scale3d(1, 1, 1)'); + it('should not emit multiple changes for the same end thumb value', () => { + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - dispatchKeyboardEvent(sliderNativeElement, 'keydown', LEFT_ARROW); - fixture.detectChanges(); + setValueByClick(sliderInstance, 60, platform.IOS); + slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); + setValueByClick(sliderInstance, 60, platform.IOS); + slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); - expect(sliderInstance.value).toBe(99); - expect(trackFillElement.style.transform).toContain('scale3d(0.99, 1, 1)'); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); }); - it('should increment slider by 10 on page up pressed', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); - - dispatchKeyboardEvent(sliderNativeElement, 'keydown', PAGE_UP); - fixture.detectChanges(); + it( + 'should dispatch events when changing back to previously emitted value after ' + + 'programmatically setting the start value', + () => { + const dispatchSliderEvent = (type: PointerEventType, value: number) => { + const {x, y} = getCoordsForValue(sliderInstance, value); + dispatchPointerOrTouchEvent(sliderElement, type, x, y, platform.IOS); + }; - // The `onInput` event should be emitted once due to a single keyboard press. - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - expect(sliderInstance.value).toBe(10); - }); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - it('should decrement slider by 10 on page down pressed', () => { - sliderInstance.value = 100; + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 20); + fixture.detectChanges(); - expect(testComponent.onChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - dispatchKeyboardEvent(sliderNativeElement, 'keydown', PAGE_DOWN); - fixture.detectChanges(); + dispatchSliderEvent(PointerEventType.POINTER_UP, 20); + fixture.detectChanges(); - // The `onInput` event should be emitted once due to a single keyboard press. - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - expect(sliderInstance.value).toBe(90); - }); + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - it('should set slider to max on end pressed', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); + startInputInstance.value = 0; + fixture.detectChanges(); - dispatchKeyboardEvent(sliderNativeElement, 'keydown', END); - fixture.detectChanges(); + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - // The `onInput` event should be emitted once due to a single keyboard press. - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - expect(sliderInstance.value).toBe(100); - }); + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 20); + fixture.detectChanges(); + dispatchSliderEvent(PointerEventType.POINTER_UP, 20); - it('should set slider to min on home pressed', () => { - sliderInstance.value = 100; + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(2); + expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(2); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); + }, + ); - expect(testComponent.onChange).not.toHaveBeenCalled(); + it( + 'should dispatch events when changing back to previously emitted value after ' + + 'programmatically setting the end value', + () => { + const dispatchSliderEvent = (type: PointerEventType, value: number) => { + const {x, y} = getCoordsForValue(sliderInstance, value); + dispatchPointerOrTouchEvent(sliderElement, type, x, y, platform.IOS); + }; - dispatchKeyboardEvent(sliderNativeElement, 'keydown', HOME); - fixture.detectChanges(); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - // The `onInput` event should be emitted once due to a single keyboard press. - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - expect(sliderInstance.value).toBe(0); - }); + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 80); + fixture.detectChanges(); - it(`should take no action for presses of keys it doesn't care about`, () => { - sliderInstance.value = 50; + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(1); - expect(testComponent.onChange).not.toHaveBeenCalled(); + dispatchSliderEvent(PointerEventType.POINTER_UP, 80); + fixture.detectChanges(); - dispatchKeyboardEvent(sliderNativeElement, 'keydown', BACKSPACE); - fixture.detectChanges(); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(1); - // The `onInput` event should be emitted once due to a single keyboard press. - expect(testComponent.onInput).not.toHaveBeenCalled(); - expect(testComponent.onChange).not.toHaveBeenCalled(); - expect(sliderInstance.value).toBe(50); - }); + endInputInstance.value = 100; + fixture.detectChanges(); - it('should ignore events modifier keys', () => { - sliderInstance.value = 0; + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(1); - [UP_ARROW, DOWN_ARROW, RIGHT_ARROW, LEFT_ARROW, PAGE_DOWN, PAGE_UP, HOME, END].forEach( - key => { - const event = createKeyboardEvent('keydown', key, undefined, {alt: true}); - dispatchEvent(sliderNativeElement, event); - fixture.detectChanges(); - expect(event.defaultPrevented).toBe(false); - }, - ); + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 80); + fixture.detectChanges(); + dispatchSliderEvent(PointerEventType.POINTER_UP, 80); - expect(testComponent.onInput).not.toHaveBeenCalled(); - expect(testComponent.onChange).not.toHaveBeenCalled(); - expect(sliderInstance.value).toBe(0); - }); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(2); + expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(2); + }, + ); }); - describe('slider with direction and invert', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; + describe('slider with input event', () => { let sliderInstance: MatSlider; - let testComponent: SliderWithDirAndInvert; + let sliderElement: HTMLElement; + let testComponent: SliderWithChangeHandler; - beforeEach(() => { - fixture = createComponent(SliderWithDirAndInvert); + beforeEach(waitForAsync(() => { + const fixture = createComponent(SliderWithChangeHandler); fixture.detectChanges(); testComponent = fixture.debugElement.componentInstance; - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderInstance = sliderDebugElement.injector.get(MatSlider); - sliderNativeElement = sliderDebugElement.nativeElement; - }); - - it('works in inverted mode', () => { - testComponent.invert = true; - fixture.detectChanges(); - - dispatchMousedownEventSequence(sliderNativeElement, 0.3); - fixture.detectChanges(); - - expect(sliderInstance.value).toBe(70); - }); - - it('works in RTL languages', () => { - testComponent.dir = 'rtl'; - fixture.detectChanges(); - - dispatchMousedownEventSequence(sliderNativeElement, 0.3); - fixture.detectChanges(); - - expect(sliderInstance.value).toBe(70); - }); - - it('works in RTL languages in inverted mode', () => { - testComponent.dir = 'rtl'; - testComponent.invert = true; - fixture.detectChanges(); - - dispatchMousedownEventSequence(sliderNativeElement, 0.3); - fixture.detectChanges(); - - expect(sliderInstance.value).toBe(30); - }); - - it('should re-render slider with updated style upon directionality change', () => { - testComponent.dir = 'rtl'; - fixture.detectChanges(); - const initialTrackFillStyles = sliderInstance._getTrackFillStyles(); - const initialTicksContainerStyles = sliderInstance._getTicksContainerStyles(); - const initialTicksStyles = sliderInstance._getTicksStyles(); - const initialThumbContainerStyles = sliderInstance._getThumbContainerStyles(); - - testComponent.dir = 'ltr'; - fixture.detectChanges(); - - expect(initialTrackFillStyles).not.toEqual(sliderInstance._getTrackFillStyles()); - expect(initialTicksContainerStyles).not.toEqual(sliderInstance._getTicksContainerStyles()); - expect(initialTicksStyles).not.toEqual(sliderInstance._getTicksStyles()); - expect(initialThumbContainerStyles).not.toEqual(sliderInstance._getThumbContainerStyles()); - }); - - it('should increment inverted slider by 1 on right arrow pressed', () => { - testComponent.invert = true; - fixture.detectChanges(); - - dispatchKeyboardEvent(sliderNativeElement, 'keydown', RIGHT_ARROW); - fixture.detectChanges(); - - expect(sliderInstance.value).toBe(1); - }); - - it('should decrement inverted slider by 1 on left arrow pressed', () => { - testComponent.invert = true; - sliderInstance.value = 100; - fixture.detectChanges(); - - dispatchKeyboardEvent(sliderNativeElement, 'keydown', LEFT_ARROW); - fixture.detectChanges(); - - expect(sliderInstance.value).toBe(99); - }); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + sliderElement = sliderInstance._elementRef.nativeElement; + })); - it('should decrement RTL slider by 1 on right arrow pressed', () => { - testComponent.dir = 'rtl'; - sliderInstance.value = 100; - fixture.detectChanges(); + it('should emit an input event while sliding', () => { + const dispatchSliderEvent = (type: PointerEventType, value: number) => { + const {x, y} = getCoordsForValue(sliderInstance, value); + dispatchPointerOrTouchEvent(sliderElement, type, x, y, platform.IOS); + }; - dispatchKeyboardEvent(sliderNativeElement, 'keydown', RIGHT_ARROW); - fixture.detectChanges(); + expect(testComponent.onChange).not.toHaveBeenCalled(); + expect(testComponent.onInput).not.toHaveBeenCalled(); - expect(sliderInstance.value).toBe(99); - }); + // pointer down on current value (should not trigger input event) + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 0); - it('should increment RTL slider by 1 on left arrow pressed', () => { - testComponent.dir = 'rtl'; - fixture.detectChanges(); + // value changes (should trigger input) + dispatchSliderEvent(PointerEventType.POINTER_MOVE, 10); + dispatchSliderEvent(PointerEventType.POINTER_MOVE, 25); - dispatchKeyboardEvent(sliderNativeElement, 'keydown', LEFT_ARROW); - fixture.detectChanges(); + // a new value has been committed (should trigger change event) + dispatchSliderEvent(PointerEventType.POINTER_UP, 25); - expect(sliderInstance.value).toBe(1); + expect(testComponent.onInput).toHaveBeenCalledTimes(2); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); }); - it('should decrement inverted RTL slider by 1 on right arrow pressed', () => { - testComponent.dir = 'rtl'; - testComponent.invert = true; - sliderInstance.value = 100; - fixture.detectChanges(); - - dispatchKeyboardEvent(sliderNativeElement, 'keydown', RIGHT_ARROW); - fixture.detectChanges(); - - expect(sliderInstance.value).toBe(99); + it('should emit an input event when clicking', () => { + expect(testComponent.onChange).not.toHaveBeenCalled(); + expect(testComponent.onInput).not.toHaveBeenCalled(); + setValueByClick(sliderInstance, 75, platform.IOS); + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); }); + }); - it('should increment inverted RTL slider by 1 on left arrow pressed', () => { - testComponent.dir = 'rtl'; - testComponent.invert = true; - fixture.detectChanges(); + describe('range slider with input event', () => { + let sliderInstance: MatSlider; + let sliderElement: HTMLElement; + let testComponent: RangeSliderWithChangeHandler; - dispatchKeyboardEvent(sliderNativeElement, 'keydown', LEFT_ARROW); + beforeEach(waitForAsync(() => { + const fixture = createComponent(RangeSliderWithChangeHandler); fixture.detectChanges(); - expect(sliderInstance.value).toBe(1); - }); + testComponent = fixture.debugElement.componentInstance; - it('should hide last tick when inverted and at min value', () => { - testComponent.invert = true; - fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + sliderElement = sliderInstance._elementRef.nativeElement; + })); - expect(sliderNativeElement.classList.contains('mat-slider-hide-last-tick')) - .withContext('last tick should be hidden') - .toBe(true); - }); - }); + it('should emit an input event while sliding the start thumb', () => { + const dispatchSliderEvent = (type: PointerEventType, value: number) => { + const {x, y} = getCoordsForValue(sliderInstance, value); + dispatchPointerOrTouchEvent(sliderElement, type, x, y, platform.IOS); + }; - describe('vertical slider', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let trackFillElement: HTMLElement; - let sliderInstance: MatSlider; - let testComponent: VerticalSlider; + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - beforeEach(() => { - fixture = createComponent(VerticalSlider); - fixture.detectChanges(); + // pointer down on current start thumb value (should not trigger input event) + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 0); - testComponent = fixture.debugElement.componentInstance; - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderInstance = sliderDebugElement.injector.get(MatSlider); - sliderNativeElement = sliderDebugElement.nativeElement; - trackFillElement = sliderNativeElement.querySelector('.mat-slider-track-fill'); - }); + // value changes (should trigger input) + dispatchSliderEvent(PointerEventType.POINTER_MOVE, 10); + dispatchSliderEvent(PointerEventType.POINTER_MOVE, 25); - it('updates value on mousedown', () => { - dispatchMousedownEventSequence(sliderNativeElement, 0.3); - fixture.detectChanges(); + // a new value has been committed (should trigger change event) + dispatchSliderEvent(PointerEventType.POINTER_UP, 25); - expect(sliderInstance.value).toBe(70); + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(2); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); }); - it('updates value on mousedown in inverted mode', () => { - testComponent.invert = true; - fixture.detectChanges(); + it('should emit an input event while sliding the end thumb', () => { + const dispatchSliderEvent = (type: PointerEventType, value: number) => { + const {x, y} = getCoordsForValue(sliderInstance, value); + dispatchPointerOrTouchEvent(sliderElement, type, x, y, platform.IOS); + }; - dispatchMousedownEventSequence(sliderNativeElement, 0.3); - fixture.detectChanges(); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - expect(sliderInstance.value).toBe(30); - }); + // pointer down on current end thumb value (should not trigger input event) + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 100); - it('should update the track fill on mousedown', () => { - expect(trackFillElement.style.transform).toContain('scale3d(1, 0, 1)'); + // value changes (should trigger input) + dispatchSliderEvent(PointerEventType.POINTER_MOVE, 90); + dispatchSliderEvent(PointerEventType.POINTER_MOVE, 55); - dispatchMousedownEventSequence(sliderNativeElement, 0.39); - fixture.detectChanges(); + // a new value has been committed (should trigger change event) + dispatchSliderEvent(PointerEventType.POINTER_UP, 55); - expect(trackFillElement.style.transform).toContain('scale3d(1, 0.61, 1)'); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(2); }); - it('should update the track fill on mousedown in inverted mode', () => { - testComponent.invert = true; - fixture.detectChanges(); - - expect(trackFillElement.style.transform).toContain('scale3d(1, 0, 1)'); + it('should emit an input event on the start thumb when clicking near it', () => { + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - dispatchMousedownEventSequence(sliderNativeElement, 0.39); - fixture.detectChanges(); + setValueByClick(sliderInstance, 30, platform.IOS); - expect(trackFillElement.style.transform).toContain('scale3d(1, 0.39, 1)'); + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); }); - it('should have aria-orientation vertical', () => { - expect(sliderNativeElement.getAttribute('aria-orientation')).toEqual('vertical'); - }); - }); + it('should emit an input event on the end thumb when clicking near it', () => { + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - describe('tabindex', () => { - it('should allow setting the tabIndex through binding', () => { - const fixture = createComponent(SliderWithTabIndexBinding); - fixture.detectChanges(); + setValueByClick(sliderInstance, 55, platform.IOS); - const slider = fixture.debugElement.query(By.directive(MatSlider))!.componentInstance; + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(1); + }); + }); - expect(slider.tabIndex) - .withContext('Expected the tabIndex to be set to 0 by default.') - .toBe(0); + describe('slider with direction', () => { + let sliderInstance: MatSlider; + let inputInstance: MatSliderThumb; - fixture.componentInstance.tabIndex = 3; + beforeEach(waitForAsync(() => { + const fixture = createComponent(StandardSlider, [ + { + provide: Directionality, + useValue: {value: 'rtl', change: of()}, + }, + ]); fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + })); - expect(slider.tabIndex).withContext('Expected the tabIndex to have been changed.').toBe(3); + it('works in RTL languages', () => { + setValueByClick(sliderInstance, 30, platform.IOS); + expect(inputInstance.value).toBe(70); }); + }); - it('should detect the native tabindex attribute', () => { - const fixture = createComponent(SliderWithNativeTabindexAttr); + describe('range slider with direction', () => { + let sliderInstance: MatSlider; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; + + beforeEach(waitForAsync(() => { + const fixture = createComponent(StandardRangeSlider, [ + { + provide: Directionality, + useValue: {value: 'rtl', change: of()}, + }, + ]); fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + startInputInstance = sliderInstance._getInput(Thumb.START); + endInputInstance = sliderInstance._getInput(Thumb.END); + })); - const slider = fixture.debugElement.query(By.directive(MatSlider))!.componentInstance; + it('works in RTL languages', () => { + setValueByClick(sliderInstance, 90, platform.IOS); + expect(startInputInstance.value).toBe(10); - expect(slider.tabIndex) - .withContext('Expected the tabIndex to be set to the value of the native attribute.') - .toBe(5); + setValueByClick(sliderInstance, 10, platform.IOS); + expect(endInputInstance.value).toBe(90); }); }); describe('slider with ngModel', () => { let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; let testComponent: SliderWithNgModel; + let inputInstance: MatSliderThumb; - beforeEach(() => { + beforeEach(waitForAsync(() => { fixture = createComponent(SliderWithNgModel); fixture.detectChanges(); - testComponent = fixture.debugElement.componentInstance; - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; - }); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + const sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + })); it('should update the model on mouseup', () => { expect(testComponent.val).toBe(0); - - dispatchMousedownEventSequence(sliderNativeElement, 0.76); + setValueByClick(testComponent.slider, 76, platform.IOS); fixture.detectChanges(); - dispatchSlideEndEvent(sliderNativeElement, 0.76); - expect(testComponent.val).toBe(76); }); it('should update the model on slide', () => { expect(testComponent.val).toBe(0); - - dispatchSlideEventSequence(sliderNativeElement, 0, 0.19); + slideToValue(testComponent.slider, 19, Thumb.END, platform.IOS); fixture.detectChanges(); - expect(testComponent.val).toBe(19); }); - it('should update the model on keydown', () => { - expect(testComponent.val).toBe(0); + it('should be able to reset a slider by setting the model back to undefined', fakeAsync(() => { + expect(inputInstance.value).toBe(0); + testComponent.val = 5; + fixture.detectChanges(); + flush(); + expect(inputInstance.value).toBe(5); + + testComponent.val = undefined; + fixture.detectChanges(); + flush(); + expect(inputInstance.value).toBe(0); + })); + }); + + describe('slider with ngModel', () => { + let fixture: ComponentFixture; + let testComponent: RangeSliderWithNgModel; + + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; - dispatchKeyboardEvent(sliderNativeElement, 'keydown', UP_ARROW); + beforeEach(waitForAsync(() => { + fixture = createComponent(RangeSliderWithNgModel); fixture.detectChanges(); + testComponent = fixture.debugElement.componentInstance; + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + const sliderInstance = sliderDebugElement.componentInstance; + startInputInstance = sliderInstance._getInput(Thumb.START); + endInputInstance = sliderInstance._getInput(Thumb.END); + })); - expect(testComponent.val).toBe(1); + it('should update the start thumb model on mouseup', () => { + expect(testComponent.startVal).toBe(0); + setValueByClick(testComponent.slider, 25, platform.IOS); + fixture.detectChanges(); + expect(testComponent.startVal).toBe(25); }); - it('should be able to reset a slider by setting the model back to undefined', fakeAsync(() => { - expect(testComponent.slider.value).toBe(0); + it('should update the end thumb model on mouseup', () => { + expect(testComponent.endVal).toBe(100); + setValueByClick(testComponent.slider, 75, platform.IOS); + fixture.detectChanges(); + expect(testComponent.endVal).toBe(75); + }); - testComponent.val = 5; + it('should update the start thumb model on slide', () => { + expect(testComponent.startVal).toBe(0); + slideToValue(testComponent.slider, 19, Thumb.START, platform.IOS); + fixture.detectChanges(); + expect(testComponent.startVal).toBe(19); + }); + + it('should update the end thumb model on slide', () => { + expect(testComponent.endVal).toBe(100); + slideToValue(testComponent.slider, 19, Thumb.END, platform.IOS); + fixture.detectChanges(); + expect(testComponent.endVal).toBe(19); + }); + + it('should be able to reset a slider by setting the start thumb model back to undefined', fakeAsync(() => { + expect(startInputInstance.value).toBe(0); + testComponent.startVal = 5; fixture.detectChanges(); flush(); + expect(startInputInstance.value).toBe(5); - expect(testComponent.slider.value).toBe(5); + testComponent.startVal = undefined; + fixture.detectChanges(); + flush(); + expect(startInputInstance.value).toBe(0); + })); - testComponent.val = undefined; + it('should be able to reset a slider by setting the end thumb model back to undefined', fakeAsync(() => { + expect(endInputInstance.value).toBe(100); + testComponent.endVal = 5; fixture.detectChanges(); flush(); + expect(endInputInstance.value).toBe(5); - expect(testComponent.slider.value).toBe(0); + testComponent.endVal = undefined; + fixture.detectChanges(); + flush(); + expect(endInputInstance.value).toBe(0); })); }); describe('slider as a custom form control', () => { let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let sliderInstance: MatSlider; let testComponent: SliderWithFormControl; + let sliderInstance: MatSlider; + let inputInstance: MatSliderThumb; - beforeEach(() => { + beforeEach(waitForAsync(() => { fixture = createComponent(SliderWithFormControl); fixture.detectChanges(); - testComponent = fixture.debugElement.componentInstance; - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.injector.get(MatSlider); - }); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + })); it('should not update the control when the value is updated', () => { expect(testComponent.control.value).toBe(0); - - sliderInstance.value = 11; + inputInstance.value = 11; fixture.detectChanges(); - expect(testComponent.control.value).toBe(0); }); it('should update the control on mouseup', () => { expect(testComponent.control.value).toBe(0); - - dispatchMousedownEventSequence(sliderNativeElement, 0.76); - fixture.detectChanges(); - dispatchSlideEndEvent(sliderNativeElement, 0.76); - + setValueByClick(sliderInstance, 76, platform.IOS); expect(testComponent.control.value).toBe(76); }); it('should update the control on slide', () => { expect(testComponent.control.value).toBe(0); - - dispatchSlideEventSequence(sliderNativeElement, 0, 0.19); - fixture.detectChanges(); - + slideToValue(sliderInstance, 19, Thumb.END, platform.IOS); expect(testComponent.control.value).toBe(19); }); it('should update the value when the control is set', () => { - expect(sliderInstance.value).toBe(0); - + expect(inputInstance.value).toBe(0); testComponent.control.setValue(7); - fixture.detectChanges(); - - expect(sliderInstance.value).toBe(7); + expect(inputInstance.value).toBe(7); }); it('should update the disabled state when control is disabled', () => { expect(sliderInstance.disabled).toBe(false); - testComponent.control.disable(); - fixture.detectChanges(); - expect(sliderInstance.disabled).toBe(true); }); it('should update the disabled state when the control is enabled', () => { sliderInstance.disabled = true; - testComponent.control.enable(); + expect(sliderInstance.disabled).toBe(false); + }); + + it('should have the correct control state initially and after interaction', () => { + let sliderControl = testComponent.control; + + // The control should start off valid, pristine, and untouched. + expect(sliderControl.valid).toBe(true); + expect(sliderControl.pristine).toBe(true); + expect(sliderControl.touched).toBe(false); + + // After changing the value, the control should become dirty (not pristine), + // but remain untouched. + setValueByClick(sliderInstance, 50, platform.IOS); + + expect(sliderControl.valid).toBe(true); + expect(sliderControl.pristine).toBe(false); + expect(sliderControl.touched).toBe(false); + + // If the control has been visited due to interaction, the control should remain + // dirty and now also be touched. + inputInstance.blur(); + fixture.detectChanges(); + + expect(sliderControl.valid).toBe(true); + expect(sliderControl.pristine).toBe(false); + expect(sliderControl.touched).toBe(true); + }); + }); + + describe('slider as a custom form control', () => { + let fixture: ComponentFixture; + let testComponent: RangeSliderWithFormControl; + let sliderInstance: MatSlider; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; + + beforeEach(waitForAsync(() => { + fixture = createComponent(RangeSliderWithFormControl); + fixture.detectChanges(); + testComponent = fixture.debugElement.componentInstance; + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + startInputInstance = sliderInstance._getInput(Thumb.START); + endInputInstance = sliderInstance._getInput(Thumb.END); + })); + + it('should not update the start input control when the value is updated', () => { + expect(testComponent.startInputControl.value).toBe(0); + startInputInstance.value = 11; + fixture.detectChanges(); + expect(testComponent.startInputControl.value).toBe(0); + }); + + it('should not update the end input control when the value is updated', () => { + expect(testComponent.endInputControl.value).toBe(100); + endInputInstance.value = 11; fixture.detectChanges(); + expect(testComponent.endInputControl.value).toBe(100); + }); + + it('should update the start input control on mouseup', () => { + expect(testComponent.startInputControl.value).toBe(0); + setValueByClick(sliderInstance, 20, platform.IOS); + expect(testComponent.startInputControl.value).toBe(20); + }); + + it('should update the end input control on mouseup', () => { + expect(testComponent.endInputControl.value).toBe(100); + setValueByClick(sliderInstance, 80, platform.IOS); + expect(testComponent.endInputControl.value).toBe(80); + }); + + it('should update the start input control on slide', () => { + expect(testComponent.startInputControl.value).toBe(0); + slideToValue(sliderInstance, 20, Thumb.START, platform.IOS); + expect(testComponent.startInputControl.value).toBe(20); + }); + + it('should update the end input control on slide', () => { + expect(testComponent.endInputControl.value).toBe(100); + slideToValue(sliderInstance, 80, Thumb.END, platform.IOS); + expect(testComponent.endInputControl.value).toBe(80); + }); + + it('should update the start input value when the start input control is set', () => { + expect(startInputInstance.value).toBe(0); + testComponent.startInputControl.setValue(10); + expect(startInputInstance.value).toBe(10); + }); + + it('should update the end input value when the end input control is set', () => { + expect(endInputInstance.value).toBe(100); + testComponent.endInputControl.setValue(90); + expect(endInputInstance.value).toBe(90); + }); + it('should update the disabled state if the start input control is disabled', () => { expect(sliderInstance.disabled).toBe(false); + testComponent.startInputControl.disable(); + expect(sliderInstance.disabled).toBe(true); }); - it('should have the correct control state initially and after interaction', () => { - const sliderControl = testComponent.control; + it('should update the disabled state if the end input control is disabled', () => { + expect(sliderInstance.disabled).toBe(false); + testComponent.endInputControl.disable(); + expect(sliderInstance.disabled).toBe(true); + }); + + it('should update the disabled state when both input controls are enabled', () => { + sliderInstance.disabled = true; + testComponent.startInputControl.enable(); + expect(sliderInstance.disabled).toBe(true); + testComponent.endInputControl.enable(); + expect(sliderInstance.disabled).toBe(false); + }); + + it('should have the correct start input control state initially and after interaction', () => { + let sliderControl = testComponent.startInputControl; // The control should start off valid, pristine, and untouched. expect(sliderControl.valid).toBe(true); @@ -1537,9 +1572,33 @@ describe('MatSlider', () => { // After changing the value, the control should become dirty (not pristine), // but remain untouched. - dispatchMousedownEventSequence(sliderNativeElement, 0.5); + setValueByClick(sliderInstance, 25, platform.IOS); + + expect(sliderControl.valid).toBe(true); + expect(sliderControl.pristine).toBe(false); + expect(sliderControl.touched).toBe(false); + + // If the control has been visited due to interaction, the control should remain + // dirty and now also be touched. + startInputInstance.blur(); fixture.detectChanges(); - dispatchSlideEndEvent(sliderNativeElement, 0.5); + + expect(sliderControl.valid).toBe(true); + expect(sliderControl.pristine).toBe(false); + expect(sliderControl.touched).toBe(true); + }); + + it('should have the correct start input control state initially and after interaction', () => { + let sliderControl = testComponent.endInputControl; + + // The control should start off valid, pristine, and untouched. + expect(sliderControl.valid).toBe(true); + expect(sliderControl.pristine).toBe(true); + expect(sliderControl.touched).toBe(false); + + // After changing the value, the control should become dirty (not pristine), + // but remain untouched. + setValueByClick(sliderInstance, 75, platform.IOS); expect(sliderControl.valid).toBe(true); expect(sliderControl.pristine).toBe(false); @@ -1547,7 +1606,7 @@ describe('MatSlider', () => { // If the control has been visited due to interaction, the control should remain // dirty and now also be touched. - sliderInstance._onBlur(); + endInputInstance.blur(); fixture.detectChanges(); expect(sliderControl.valid).toBe(true); @@ -1559,294 +1618,427 @@ describe('MatSlider', () => { describe('slider with a two-way binding', () => { let fixture: ComponentFixture; let testComponent: SliderWithTwoWayBinding; - let sliderNativeElement: HTMLElement; beforeEach(() => { fixture = createComponent(SliderWithTwoWayBinding); fixture.detectChanges(); - testComponent = fixture.componentInstance; - let sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; - sliderNativeElement = sliderDebugElement.nativeElement; }); it('should sync the value binding in both directions', () => { expect(testComponent.value).toBe(0); - expect(testComponent.slider.value).toBe(0); - - dispatchMousedownEventSequence(sliderNativeElement, 0.1); - fixture.detectChanges(); - dispatchSlideEndEvent(sliderNativeElement, 0.1); + expect(testComponent.sliderInput.value).toBe(0); + slideToValue(testComponent.slider, 10, Thumb.END, platform.IOS); expect(testComponent.value).toBe(10); - expect(testComponent.slider.value).toBe(10); + expect(testComponent.sliderInput.value).toBe(10); testComponent.value = 20; fixture.detectChanges(); - expect(testComponent.value).toBe(20); - expect(testComponent.slider.value).toBe(20); + expect(testComponent.sliderInput.value).toBe(20); + }); + }); + + describe('range slider with a two-way binding', () => { + let fixture: ComponentFixture; + let testComponent: RangeSliderWithTwoWayBinding; + + beforeEach(waitForAsync(() => { + fixture = createComponent(RangeSliderWithTwoWayBinding); + fixture.detectChanges(); + testComponent = fixture.componentInstance; + })); + + it('should sync the start value binding in both directions', () => { + expect(testComponent.startValue).toBe(0); + expect(testComponent.sliderInputs.get(0)!.value).toBe(0); + + slideToValue(testComponent.slider, 10, Thumb.START, platform.IOS); + + expect(testComponent.startValue).toBe(10); + expect(testComponent.sliderInputs.get(0)!.value).toBe(10); + + testComponent.startValue = 20; + fixture.detectChanges(); + expect(testComponent.startValue).toBe(20); + expect(testComponent.sliderInputs.get(0)!.value).toBe(20); + }); + + it('should sync the end value binding in both directions', () => { + expect(testComponent.endValue).toBe(100); + expect(testComponent.sliderInputs.get(1)!.value).toBe(100); + + slideToValue(testComponent.slider, 90, Thumb.END, platform.IOS); + expect(testComponent.endValue).toBe(90); + expect(testComponent.sliderInputs.get(1)!.value).toBe(90); + + testComponent.endValue = 80; + fixture.detectChanges(); + expect(testComponent.endValue).toBe(80); + expect(testComponent.sliderInputs.get(1)!.value).toBe(80); }); }); }); -// Disable animations and make the slider an even 100px (+ 8px padding on either side) -// so we get nice round values in tests. -const styles = ` - .mat-slider-horizontal { min-width: 116px !important; } - .mat-slider-vertical { min-height: 116px !important; } - .mat-slider-track-fill { transition: none !important; } -`; +const SLIDER_STYLES = ['.mat-mdc-slider { width: 300px; }']; @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) class StandardSlider {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, +}) +class StandardRangeSlider {} + +@Component({ + template: ` + + + + `, + styles: SLIDER_STYLES, }) class DisabledSlider {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithMinAndMax { - min = 4; - max = 6; - step = 1; -} +class DisabledRangeSlider {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithValue {} +class SliderWithMinAndMax {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithStep { - step = 25; - valueText: string; -} +class RangeSliderWithMinAndMax {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithAutoTickInterval {} +class SliderWithValue {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithSetTickInterval { - tickInterval = 6; -} +class RangeSliderWithValue {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithThumbLabel {} +class SliderWithStep {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithCustomThumbLabelFormatting { - displayWith(value: number) { - if (value >= 1000) { - return value / 1000 + 'k'; - } +class RangeSliderWithStep {} - return value; +@Component({ + template: ` + + + + `, + styles: SLIDER_STYLES, +}) +class DiscreteSliderWithDisplayWith { + displayWith(v: number) { + if (v >= 1000) { + return `$${v / 1000}k`; + } + return `$${v}`; } } @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithOneWayBinding { - val = 50; +class DiscreteRangeSliderWithDisplayWith { + displayWith(v: number) { + if (v >= 1000) { + return `$${v / 1000}k`; + } + return `$${v}`; + } } @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithFormControl { - control = new FormControl(0); +class SliderWithOneWayBinding { + value = 50; } @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithNgModel { - @ViewChild(MatSlider) slider: MatSlider; - val: number | undefined = 0; +class RangeSliderWithOneWayBinding { + startValue = 25; + endValue = 75; } @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithValueSmallerThanMin {} +class SliderWithChangeHandler { + onChange = jasmine.createSpy('onChange'); + onInput = jasmine.createSpy('onChange'); + @ViewChild(MatSlider) slider: MatSlider; +} @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithValueGreaterThanMax {} +class RangeSliderWithChangeHandler { + onStartThumbChange = jasmine.createSpy('onStartThumbChange'); + onStartThumbInput = jasmine.createSpy('onStartThumbInput'); + onEndThumbChange = jasmine.createSpy('onEndThumbChange'); + onEndThumbInput = jasmine.createSpy('onEndThumbInput'); + @ViewChild(MatSlider) slider: MatSlider; +} @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithChangeHandler { - onChange() {} - onInput() {} - +class SliderWithNgModel { @ViewChild(MatSlider) slider: MatSlider; + val: number | undefined = 0; } @Component({ - template: `
`, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithDirAndInvert { - dir = 'ltr'; - invert = false; +class RangeSliderWithNgModel { + @ViewChild(MatSlider) slider: MatSlider; + startVal: number | undefined = 0; + endVal: number | undefined = 100; } @Component({ - template: ``, - styles: [styles], + template: ` + + + `, + styles: SLIDER_STYLES, }) -class VerticalSlider { - invert = false; +class SliderWithFormControl { + control = new FormControl(0); } @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithTabIndexBinding { - tabIndex: number; +class RangeSliderWithFormControl { + startInputControl = new FormControl(0); + endInputControl = new FormControl(100); } @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithNativeTabindexAttr { - tabIndex: number; +class SliderWithTwoWayBinding { + @ViewChild(MatSlider) slider: MatSlider; + @ViewChild(MatSliderThumb) sliderInput: MatSliderThumb; + value = 0; } @Component({ - template: '', - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithTwoWayBinding { +class RangeSliderWithTwoWayBinding { @ViewChild(MatSlider) slider: MatSlider; - value = 0; + @ViewChildren(MatSliderThumb) sliderInputs: QueryList; + startValue = 0; + endValue = 100; } -/** - * Dispatches a mousedown event sequence (consisting of moueseenter, mousedown) from an element. - * Note: The mouse event truncates the position for the event. - * @param sliderElement The mat-slider element from which the event will be dispatched. - * @param percentage The percentage of the slider where the event should occur. Used to find the - * physical location of the pointer. - * @param button Button that should be held down when starting to drag the slider. - */ -function dispatchMousedownEventSequence( - sliderElement: HTMLElement, - percentage: number, - button = 0, -): void { - const trackElement = sliderElement.querySelector('.mat-slider-wrapper')!; - const dimensions = trackElement.getBoundingClientRect(); - const x = dimensions.left + dimensions.width * percentage; - const y = dimensions.top + dimensions.height * percentage; - - dispatchMouseenterEvent(sliderElement); - dispatchEvent(sliderElement, createMouseEvent('mousedown', x, y, undefined, undefined, button)); +/** The pointer event types used by the MDC Slider. */ +const enum PointerEventType { + POINTER_DOWN = 'pointerdown', + POINTER_UP = 'pointerup', + POINTER_MOVE = 'pointermove', } -/** - * Dispatches a slide event sequence (consisting of slidestart, slide, slideend) from an element. - * @param sliderElement The mat-slider element from which the event will be dispatched. - * @param startPercent The percentage of the slider where the slide will begin. - * @param endPercent The percentage of the slider where the slide will end. - */ -function dispatchSlideEventSequence( - sliderElement: HTMLElement, - startPercent: number, - endPercent: number, -): void { - dispatchMouseenterEvent(sliderElement); - dispatchSlideStartEvent(sliderElement, startPercent); - dispatchSlideEvent(sliderElement, startPercent); - dispatchSlideEvent(sliderElement, endPercent); - dispatchSlideEndEvent(sliderElement, endPercent); +/** The touch event types used by the MDC Slider. */ +const enum TouchEventType { + TOUCH_START = 'touchstart', + TOUCH_END = 'touchend', + TOUCH_MOVE = 'touchmove', } -/** - * Dispatches a slide event from an element. - * @param sliderElement The mat-slider element from which the event will be dispatched. - * @param percent The percentage of the slider where the slide will happen. - */ -function dispatchSlideEvent(sliderElement: HTMLElement, percent: number): void { - const trackElement = sliderElement.querySelector('.mat-slider-wrapper')!; - const dimensions = trackElement.getBoundingClientRect(); - const x = dimensions.left + dimensions.width * percent; - const y = dimensions.top + dimensions.height * percent; - dispatchMouseEvent(document, 'mousemove', x, y); +/** Clicks on the MatSlider at the coordinates corresponding to the given value. */ +function setValueByClick(slider: MatSlider, value: number, isIOS: boolean) { + const sliderElement = slider._elementRef.nativeElement; + const {x, y} = getCoordsForValue(slider, value); + + dispatchPointerEvent(sliderElement, 'mouseenter', x, y); + dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_DOWN, x, y, isIOS); + dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_UP, x, y, isIOS); } -/** - * Dispatches a slidestart event from an element. - * @param sliderElement The mat-slider element from which the event will be dispatched. - * @param percent The percentage of the slider where the slide will begin. - */ -function dispatchSlideStartEvent(sliderElement: HTMLElement, percent: number): void { - const trackElement = sliderElement.querySelector('.mat-slider-wrapper')!; - const dimensions = trackElement.getBoundingClientRect(); - const x = dimensions.left + dimensions.width * percent; - const y = dimensions.top + dimensions.height * percent; - dispatchMouseenterEvent(sliderElement); - dispatchMouseEvent(sliderElement, 'mousedown', x, y); +/** Slides the MatSlider's thumb to the given value. */ +function slideToValue(slider: MatSlider, value: number, thumbPosition: Thumb, isIOS: boolean) { + const sliderElement = slider._elementRef.nativeElement; + const {x: startX, y: startY} = getCoordsForValue(slider, slider._getInput(thumbPosition).value); + const {x: endX, y: endY} = getCoordsForValue(slider, value); + + dispatchPointerEvent(sliderElement, 'mouseenter', startX, startY); + dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_DOWN, startX, startY, isIOS); + dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_MOVE, endX, endY, isIOS); + dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_UP, endX, endY, isIOS); } -/** - * Dispatches a slideend event from an element. - * @param sliderElement The mat-slider element from which the event will be dispatched. - * @param percent The percentage of the slider where the slide will end. - */ -function dispatchSlideEndEvent(sliderElement: HTMLElement, percent: number): void { - const trackElement = sliderElement.querySelector('.mat-slider-wrapper')!; - const dimensions = trackElement.getBoundingClientRect(); - const x = dimensions.left + dimensions.width * percent; - const y = dimensions.top + dimensions.height * percent; - dispatchMouseEvent(document, 'mouseup', x, y); +/** Returns the x and y coordinates for the given slider value. */ +function getCoordsForValue(slider: MatSlider, value: number): Point { + const {min, max} = slider; + const percent = (value - min) / (max - min); + + const {top, left, width, height} = slider._elementRef.nativeElement.getBoundingClientRect(); + const x = left + width * percent; + const y = top + height / 2; + + return {x, y}; } -/** - * Dispatches a mouseenter event from an element. - * Note: The mouse event truncates the position for the event. - * @param element The element from which the event will be dispatched. - */ -function dispatchMouseenterEvent(element: HTMLElement): void { - const dimensions = element.getBoundingClientRect(); - const y = dimensions.top; - const x = dimensions.left; - dispatchMouseEvent(element, 'mouseenter', x, y); +/** Dispatch a pointerdown or pointerup event if supported, otherwise dispatch the touch event. */ +function dispatchPointerOrTouchEvent( + node: Node, + type: PointerEventType, + x: number, + y: number, + isIOS: boolean, +) { + if (isIOS) { + dispatchTouchEvent(node, pointerEventTypeToTouchEventType(type), x, y, x, y); + } else { + dispatchPointerEvent(node, type, x, y); + } +} + +/** Returns the touch event equivalent of the given pointer event. */ +function pointerEventTypeToTouchEventType(pointerEventType: PointerEventType) { + switch (pointerEventType) { + case PointerEventType.POINTER_DOWN: + return TouchEventType.TOUCH_START; + case PointerEventType.POINTER_UP: + return TouchEventType.TOUCH_END; + case PointerEventType.POINTER_MOVE: + return TouchEventType.TOUCH_MOVE; + } } diff --git a/src/material/slider/slider.ts b/src/material/slider/slider.ts index 0647aceda20b..517f917cc80b 100644 --- a/src/material/slider/slider.ts +++ b/src/material/slider/slider.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ -import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import { BooleanInput, @@ -14,1007 +13,1315 @@ import { coerceNumberProperty, NumberInput, } from '@angular/cdk/coercion'; +import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; +import {DOCUMENT} from '@angular/common'; import { - DOWN_ARROW, - END, - HOME, - LEFT_ARROW, - PAGE_DOWN, - PAGE_UP, - RIGHT_ARROW, - UP_ARROW, - hasModifierKey, -} from '@angular/cdk/keycodes'; -import { - Attribute, + AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, + ContentChildren, + Directive, ElementRef, EventEmitter, forwardRef, Inject, Input, + NgZone, OnDestroy, + OnInit, Optional, Output, + QueryList, ViewChild, + ViewChildren, ViewEncapsulation, - NgZone, - AfterViewInit, } from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import { - CanColor, - CanDisable, - HasTabIndex, + CanDisableRipple, + MatRipple, + MAT_RIPPLE_GLOBAL_OPTIONS, mixinColor, - mixinDisabled, - mixinTabIndex, + mixinDisableRipple, + RippleAnimationConfig, + RippleGlobalOptions, + RippleRef, + RippleState, } from '@angular/material/core'; import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; -import {normalizePassiveListenerOptions} from '@angular/cdk/platform'; -import {DOCUMENT} from '@angular/common'; +import {SpecificEventListener, EventType} from '@material/base'; +import {MDCSliderAdapter, MDCSliderFoundation, Thumb, TickMark} from '@material/slider'; import {Subscription} from 'rxjs'; +import {GlobalChangeAndInputListener} from './global-change-and-input-listener'; -const activeEventOptions = normalizePassiveListenerOptions({passive: false}); +/** Options used to bind passive event listeners. */ +const passiveEventListenerOptions = normalizePassiveListenerOptions({passive: true}); -/** - * Visually, a 30px separation between tick marks looks best. This is very subjective but it is - * the default separation we chose. - */ -const MIN_AUTO_TICK_SEPARATION = 30; - -/** The thumb gap size for a disabled slider. */ -const DISABLED_THUMB_GAP = 7; - -/** The thumb gap size for a non-active slider at its minimum value. */ -const MIN_VALUE_NONACTIVE_THUMB_GAP = 7; +/** Represents a drag event emitted by the MatSlider component. */ +export interface MatSliderDragEvent { + /** The MatSliderThumb that was interacted with. */ + source: MatSliderThumb; -/** The thumb gap size for an active slider at its minimum value. */ -const MIN_VALUE_ACTIVE_THUMB_GAP = 10; + /** The MatSlider that was interacted with. */ + parent: MatSlider; -/** - * Provider Expression that allows mat-slider to register as a ControlValueAccessor. - * This allows it to support [(ngModel)] and [formControl]. - * @docs-private - */ -export const MAT_SLIDER_VALUE_ACCESSOR: any = { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MatSlider), - multi: true, -}; - -/** A simple change event emitted by the MatSlider component. */ -export class MatSliderChange { - /** The MatSlider that changed. */ - source: MatSlider; - - /** The new value of the source slider. */ - value: number | null; + /** The current value of the slider. */ + value: number; } -// Boilerplate for applying mixins to MatSlider. -/** @docs-private */ -const _MatSliderBase = mixinTabIndex( - mixinColor( - mixinDisabled( - class { - constructor(public _elementRef: ElementRef) {} - }, - ), - 'accent', - ), -); - /** - * Allows users to select from a range of values by moving the slider thumb. It is similar in - * behavior to the native `` element. + * The visual slider thumb. + * + * Handles the slider thumb ripple states (hover, focus, and active), + * and displaying the value tooltip on discrete sliders. + * @docs-private */ @Component({ - selector: 'mat-slider', - exportAs: 'matSlider', - providers: [MAT_SLIDER_VALUE_ACCESSOR], + selector: 'mat-slider-visual-thumb', + templateUrl: './slider-thumb.html', + styleUrls: ['slider-thumb.css'], host: { - '(focus)': '_onFocus()', - '(blur)': '_onBlur()', - '(keydown)': '_onKeydown($event)', - '(keyup)': '_onKeyup()', - '(mouseenter)': '_onMouseenter()', - - // On Safari starting to slide temporarily triggers text selection mode which - // show the wrong cursor. We prevent it by stopping the `selectstart` event. - '(selectstart)': '$event.preventDefault()', - 'class': 'mat-slider mat-focus-indicator', - 'role': 'slider', - '[tabIndex]': 'tabIndex', - '[attr.aria-disabled]': 'disabled', - '[attr.aria-valuemax]': 'max', - '[attr.aria-valuemin]': 'min', - '[attr.aria-valuenow]': 'value', - - // NVDA and Jaws appear to announce the `aria-valuenow` by calculating its percentage based - // on its value between `aria-valuemin` and `aria-valuemax`. Due to how decimals are handled, - // it can cause the slider to read out a very long value like 0.20000068 if the current value - // is 0.2 with a min of 0 and max of 1. We work around the issue by setting `aria-valuetext` - // to the same value that we set on the slider's thumb which will be truncated. - '[attr.aria-valuetext]': 'valueText == null ? displayValue : valueText', - '[attr.aria-orientation]': 'vertical ? "vertical" : "horizontal"', - '[class.mat-slider-disabled]': 'disabled', - '[class.mat-slider-has-ticks]': 'tickInterval', - '[class.mat-slider-horizontal]': '!vertical', - '[class.mat-slider-axis-inverted]': '_shouldInvertAxis()', - // Class binding which is only used by the test harness as there is no other - // way for the harness to detect if mouse coordinates need to be inverted. - '[class.mat-slider-invert-mouse-coords]': '_shouldInvertMouseCoords()', - '[class.mat-slider-sliding]': '_isSliding', - '[class.mat-slider-thumb-label-showing]': 'thumbLabel', - '[class.mat-slider-vertical]': 'vertical', - '[class.mat-slider-min-value]': '_isMinValue()', - '[class.mat-slider-hide-last-tick]': - 'disabled || _isMinValue() && _getThumbGap() && _shouldInvertAxis()', - '[class._mat-animation-noopable]': '_animationMode === "NoopAnimations"', + 'class': 'mdc-slider__thumb mat-mdc-slider-visual-thumb', + + // NOTE: This class is used internally. + // TODO(wagnermaciel): Remove this once it is handled by the mdc foundation (cl/388828896). + '[class.mdc-slider__thumb--short-value]': '_isShortValue()', }, - templateUrl: 'slider.html', - styleUrls: ['slider.css'], - inputs: ['disabled', 'color', 'tabIndex'], - encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, }) -export class MatSlider - extends _MatSliderBase - implements ControlValueAccessor, OnDestroy, CanDisable, CanColor, AfterViewInit, HasTabIndex -{ - /** Whether the slider is inverted. */ - @Input() - get invert(): boolean { - return this._invert; +export class MatSliderVisualThumb implements AfterViewInit, OnDestroy { + /** Whether the slider displays a numeric value label upon pressing the thumb. */ + @Input() discrete: boolean; + + /** Indicates which slider thumb this input corresponds to. */ + @Input() thumbPosition: Thumb; + + /** The display value of the slider thumb. */ + @Input() valueIndicatorText: string; + + /** Whether ripples on the slider thumb should be disabled. */ + @Input() disableRipple: boolean = false; + + /** The MatRipple for this slider thumb. */ + @ViewChild(MatRipple) private readonly _ripple: MatRipple; + + /** The slider thumb knob. */ + @ViewChild('knob') _knob: ElementRef; + + /** The slider thumb value indicator container. */ + @ViewChild('valueIndicatorContainer') + _valueIndicatorContainer: ElementRef; + + /** The slider input corresponding to this slider thumb. */ + private _sliderInput: MatSliderThumb; + + /** The RippleRef for the slider thumbs hover state. */ + private _hoverRippleRef: RippleRef | undefined; + + /** The RippleRef for the slider thumbs focus state. */ + private _focusRippleRef: RippleRef | undefined; + + /** The RippleRef for the slider thumbs active state. */ + private _activeRippleRef: RippleRef | undefined; + + /** Whether the slider thumb is currently being pressed. */ + readonly _isActive = false; + + /** Whether the slider thumb is currently being hovered. */ + private _isHovered: boolean = false; + + constructor( + private readonly _ngZone: NgZone, + @Inject(forwardRef(() => MatSlider)) private readonly _slider: MatSlider, + private readonly _elementRef: ElementRef, + ) {} + + ngAfterViewInit() { + this._ripple.radius = 24; + this._sliderInput = this._slider._getInput(this.thumbPosition); + + // Note that we don't unsubscribe from these, because they're complete on destroy. + this._sliderInput.dragStart.subscribe(event => this._onDragStart(event)); + this._sliderInput.dragEnd.subscribe(event => this._onDragEnd(event)); + + this._sliderInput._focus.subscribe(() => this._onFocus()); + this._sliderInput._blur.subscribe(() => this._onBlur()); + + // These two listeners don't update any data bindings so we bind them + // outside of the NgZone to prevent Angular from needlessly running change detection. + this._ngZone.runOutsideAngular(() => { + this._elementRef.nativeElement.addEventListener('mouseenter', this._onMouseEnter); + this._elementRef.nativeElement.addEventListener('mouseleave', this._onMouseLeave); + }); } - set invert(value: BooleanInput) { - this._invert = coerceBooleanProperty(value); + + ngOnDestroy() { + this._elementRef.nativeElement.removeEventListener('mouseenter', this._onMouseEnter); + this._elementRef.nativeElement.removeEventListener('mouseleave', this._onMouseLeave); } - private _invert = false; - /** The maximum value that the slider can have. */ - @Input() - get max(): number { - return this._max; + /** Used to append a class to indicate when the value indicator text is short. */ + _isShortValue(): boolean { + return this.valueIndicatorText?.length <= 2; } - set max(v: NumberInput) { - this._max = coerceNumberProperty(v, this._max); - this._percent = this._calculatePercentage(this._value); - // Since this also modifies the percentage, we need to let the change detection know. - this._changeDetectorRef.markForCheck(); + private _onMouseEnter = (): void => { + this._isHovered = true; + // We don't want to show the hover ripple on top of the focus ripple. + // This can happen if the user tabs to a thumb and then the user moves their cursor over it. + if (!this._isShowingRipple(this._focusRippleRef)) { + this._showHoverRipple(); + } + }; + + private _onMouseLeave = (): void => { + this._isHovered = false; + this._hoverRippleRef?.fadeOut(); + }; + + private _onFocus(): void { + // We don't want to show the hover ripple on top of the focus ripple. + // Happen when the users cursor is over a thumb and then the user tabs to it. + this._hoverRippleRef?.fadeOut(); + this._showFocusRipple(); } - private _max: number = 100; - /** The minimum value that the slider can have. */ - @Input() - get min(): number { - return this._min; + private _onBlur(): void { + // Happens when the user tabs away while still dragging a thumb. + if (!this._isActive) { + this._focusRippleRef?.fadeOut(); + } + // Happens when the user tabs away from a thumb but their cursor is still over it. + if (this._isHovered) { + this._showHoverRipple(); + } } - set min(v: NumberInput) { - this._min = coerceNumberProperty(v, this._min); - this._percent = this._calculatePercentage(this._value); - // Since this also modifies the percentage, we need to let the change detection know. - this._changeDetectorRef.markForCheck(); + private _onDragStart(event: MatSliderDragEvent): void { + if (event.source._thumbPosition === this.thumbPosition) { + (this as {_isActive: boolean})._isActive = true; + this._showActiveRipple(); + } } - private _min: number = 0; - /** The values at which the thumb will snap. */ - @Input() - get step(): number { - return this._step; + private _onDragEnd(event: MatSliderDragEvent): void { + if (event.source._thumbPosition === this.thumbPosition) { + (this as {_isActive: boolean})._isActive = false; + this._activeRippleRef?.fadeOut(); + // Happens when the user starts dragging a thumb, tabs away, and then stops dragging. + if (!this._sliderInput._isFocused()) { + this._focusRippleRef?.fadeOut(); + } + } } - set step(v: NumberInput) { - this._step = coerceNumberProperty(v, this._step); - if (this._step % 1 !== 0) { - this._roundToDecimal = this._step.toString().split('.').pop()!.length; + /** Handles displaying the hover ripple. */ + private _showHoverRipple(): void { + if (!this._isShowingRipple(this._hoverRippleRef)) { + this._hoverRippleRef = this._showRipple({enterDuration: 0, exitDuration: 0}); + this._hoverRippleRef?.element.classList.add('mat-mdc-slider-hover-ripple'); } + } - // Since this could modify the label, we need to notify the change detection. - this._changeDetectorRef.markForCheck(); + /** Handles displaying the focus ripple. */ + private _showFocusRipple(): void { + // Show the focus ripple event if noop animations are enabled. + if (!this._isShowingRipple(this._focusRippleRef)) { + this._focusRippleRef = this._showRipple({enterDuration: 0, exitDuration: 0}); + this._focusRippleRef?.element.classList.add('mat-mdc-slider-focus-ripple'); + } } - private _step: number = 1; - /** Whether or not to show the thumb label. */ - @Input() - get thumbLabel(): boolean { - return this._thumbLabel; + /** Handles displaying the active ripple. */ + private _showActiveRipple(): void { + if (!this._isShowingRipple(this._activeRippleRef)) { + this._activeRippleRef = this._showRipple({enterDuration: 225, exitDuration: 400}); + this._activeRippleRef?.element.classList.add('mat-mdc-slider-active-ripple'); + } + } + + /** Whether the given rippleRef is currently fading in or visible. */ + private _isShowingRipple(rippleRef?: RippleRef): boolean { + return rippleRef?.state === RippleState.FADING_IN || rippleRef?.state === RippleState.VISIBLE; } - set thumbLabel(value: BooleanInput) { - this._thumbLabel = coerceBooleanProperty(value); + + /** Manually launches the slider thumb ripple using the specified ripple animation config. */ + private _showRipple(animation: RippleAnimationConfig): RippleRef | undefined { + if (this.disableRipple) { + return; + } + return this._ripple.launch({ + animation: this._slider._noopAnimations ? {enterDuration: 0, exitDuration: 0} : animation, + centered: true, + persistent: true, + }); + } + + /** Gets the hosts native HTML element. */ + _getHostElement(): HTMLElement { + return this._elementRef.nativeElement; + } + + /** Gets the native HTML element of the slider thumb knob. */ + _getKnob(): HTMLElement { + return this._knob.nativeElement; } - private _thumbLabel: boolean = false; /** - * How often to show ticks. Relative to the step so that a tick always appears on a step. - * Ex: Tick interval of 4 with a step of 3 will draw a tick every 4 steps (every 12 values). + * Gets the native HTML element of the slider thumb value indicator + * container. */ - @Input() - get tickInterval(): 'auto' | number { - return this._tickInterval; - } - set tickInterval(value: 'auto' | NumberInput) { - if (value === 'auto') { - this._tickInterval = 'auto'; - } else if (typeof value === 'number' || typeof value === 'string') { - this._tickInterval = coerceNumberProperty(value, this._tickInterval as number); - } else { - this._tickInterval = 0; - } + _getValueIndicatorContainer(): HTMLElement { + return this._valueIndicatorContainer.nativeElement; } - private _tickInterval: 'auto' | number = 0; +} - /** Value of the slider. */ +/** + * Directive that adds slider-specific behaviors to an input element inside ``. + * Up to two may be placed inside of a ``. + * + * If one is used, the selector `matSliderThumb` must be used, and the outcome will be a normal + * slider. If two are used, the selectors `matSliderStartThumb` and `matSliderEndThumb` must be + * used, and the outcome will be a range slider with two slider thumbs. + */ +@Directive({ + selector: 'input[matSliderThumb], input[matSliderStartThumb], input[matSliderEndThumb]', + exportAs: 'matSliderThumb', + host: { + 'class': 'mdc-slider__input', + 'type': 'range', + '(blur)': '_onBlur()', + '(focus)': '_focus.emit()', + }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: MatSliderThumb, + multi: true, + }, + ], +}) +export class MatSliderThumb implements AfterViewInit, ControlValueAccessor, OnInit, OnDestroy { + // ** IMPORTANT NOTE ** + // + // The way `value` is implemented for MatSliderThumb doesn't follow typical Angular conventions. + // Normally we would define a private variable `_value` as the source of truth for the value of + // the slider thumb input. The source of truth for the value of the slider inputs has already + // been decided for us by MDC to be the value attribute on the slider thumb inputs. This is + // because the MDC foundation and adapter expect that the value attribute is the source of truth + // for the slider inputs. + // + // Also, note that the value attribute is completely disconnected from the value property. + + /** The current value of this slider input. */ @Input() get value(): number { - // If the value needs to be read and it is still uninitialized, initialize it to the min. - if (this._value === null) { - this.value = this._min; - } - return this._value as number; + return coerceNumberProperty(this._hostElement.getAttribute('value')); } set value(v: NumberInput) { - if (v !== this._value) { - let value = coerceNumberProperty(v, 0); - - // While incrementing by a decimal we can end up with values like 33.300000000000004. - // Truncate it to ensure that it matches the label and to make it easier to work with. - if (this._roundToDecimal && value !== this.min && value !== this.max) { - value = parseFloat(value.toFixed(this._roundToDecimal)); - } - - this._value = value; - this._percent = this._calculatePercentage(this._value); + const value = coerceNumberProperty(v); - // Since this also modifies the percentage, we need to let the change detection know. - this._changeDetectorRef.markForCheck(); + // If the foundation has already been initialized, we need to + // relay any value updates to it so that it can update the UI. + if (this._slider._initialized) { + this._slider._setValue(value, this._thumbPosition); + } else { + // Setup for the MDC foundation. + this._hostElement.setAttribute('value', `${value}`); } } - private _value: number | null = null; /** - * Function that will be used to format the value before it is displayed - * in the thumb label. Can be used to format very large number in order - * for them to fit into the slider thumb. + * Emits when the raw value of the slider changes. This is here primarily + * to facilitate the two-way binding for the `value` input. + * @docs-private */ - @Input() displayWith: (value: number) => string | number; + @Output() readonly valueChange: EventEmitter = new EventEmitter(); - /** Text corresponding to the slider's value. Used primarily for improved accessibility. */ - @Input() valueText: string; + /** Event emitted when the slider thumb starts being dragged. */ + @Output() readonly dragStart: EventEmitter = + new EventEmitter(); - /** Whether the slider is vertical. */ - @Input() - get vertical(): boolean { - return this._vertical; - } - set vertical(value: BooleanInput) { - this._vertical = coerceBooleanProperty(value); - } - private _vertical = false; + /** Event emitted when the slider thumb stops being dragged. */ + @Output() readonly dragEnd: EventEmitter = + new EventEmitter(); - /** Event emitted when the slider value has changed. */ - @Output() readonly change: EventEmitter = new EventEmitter(); + /** Event emitted every time the MatSliderThumb is blurred. */ + @Output() readonly _blur: EventEmitter = new EventEmitter(); - /** Event emitted when the slider thumb moves. */ - @Output() readonly input: EventEmitter = new EventEmitter(); + /** Event emitted every time the MatSliderThumb is focused. */ + @Output() readonly _focus: EventEmitter = new EventEmitter(); /** - * Emits when the raw value of the slider changes. This is here primarily - * to facilitate the two-way binding for the `value` input. - * @docs-private + * Used to determine the disabled state of the MatSlider (ControlValueAccessor). + * For ranged sliders, the disabled state of the MatSlider depends on the combined state of the + * start and end inputs. See MatSlider._updateDisabled. */ - @Output() readonly valueChange: EventEmitter = new EventEmitter(); - - /** The value to be used for display purposes. */ - get displayValue(): string | number { - if (this.displayWith) { - // Value is never null but since setters and getters cannot have - // different types, the value getter is also typed to return null. - return this.displayWith(this.value!); - } + _disabled: boolean = false; - // Note that this could be improved further by rounding something like 0.999 to 1 or - // 0.899 to 0.9, however it is very performance sensitive, because it gets called on - // every change detection cycle. - if (this._roundToDecimal && this.value && this.value % 1 !== 0) { - return this.value.toFixed(this._roundToDecimal); - } + /** + * A callback function that is called when the + * control's value changes in the UI (ControlValueAccessor). + */ + _onChange: (value: any) => void = () => {}; + + /** + * A callback function that is called by the forms API on + * initialization to update the form model on blur (ControlValueAccessor). + */ + private _onTouched: () => void = () => {}; + + /** Indicates which slider thumb this input corresponds to. */ + _thumbPosition: Thumb = this._elementRef.nativeElement.hasAttribute('matSliderStartThumb') + ? Thumb.START + : Thumb.END; + + /** The injected document if available or fallback to the global document reference. */ + private _document: Document; + + /** The host native HTML input element. */ + _hostElement: HTMLInputElement; + + constructor( + @Inject(DOCUMENT) document: any, + @Inject(forwardRef(() => MatSlider)) private readonly _slider: MatSlider, + private readonly _elementRef: ElementRef, + ) { + this._document = document; + this._hostElement = _elementRef.nativeElement; + } - return this.value || 0; + ngOnInit() { + // By calling this in ngOnInit() we guarantee that the sibling sliders initial value by + // has already been set by the time we reach ngAfterViewInit(). + this._initializeInputValueAttribute(); + this._initializeAriaValueText(); } - /** set focus to the host element */ - focus(options?: FocusOptions) { - this._focusHostElement(options); + ngAfterViewInit() { + this._initializeInputState(); + this._initializeInputValueProperty(); + + // Setup for the MDC foundation. + if (this._slider.disabled) { + this._hostElement.disabled = true; + } } - /** blur the host element */ - blur() { - this._blurHostElement(); + ngOnDestroy() { + this.dragStart.complete(); + this.dragEnd.complete(); + this._focus.complete(); + this._blur.complete(); + this.valueChange.complete(); } - /** onTouch function registered via registerOnTouch (ControlValueAccessor). */ - onTouched: () => any = () => {}; + _onBlur(): void { + this._onTouched(); + this._blur.emit(); + } - /** The percentage of the slider that coincides with the value. */ - get percent(): number { - return this._clamp(this._percent); + _emitFakeEvent(type: 'change' | 'input') { + const event = new Event(type) as any; + event._matIsHandled = true; + this._hostElement.dispatchEvent(event); } - private _percent: number = 0; /** - * Whether or not the thumb is sliding and what the user is using to slide it with. - * Used to determine if there should be a transition for the thumb and fill track. + * Sets the model value. Implemented as part of ControlValueAccessor. + * @param value */ - _isSliding: 'keyboard' | 'pointer' | null = null; + writeValue(value: any): void { + this.value = value; + } /** - * Whether or not the slider is active (clicked or sliding). - * Used to shrink and grow the thumb as according to the Material Design spec. + * Registers a callback to be triggered when the value has changed. + * Implemented as part of ControlValueAccessor. + * @param fn Callback to be registered. */ - _isActive: boolean = false; + registerOnChange(fn: any): void { + this._onChange = fn; + } /** - * Whether the axis of the slider is inverted. - * (i.e. whether moving the thumb in the positive x or y direction decreases the slider's value). + * Registers a callback to be triggered when the component is touched. + * Implemented as part of ControlValueAccessor. + * @param fn Callback to be registered. */ - _shouldInvertAxis() { - // Standard non-inverted mode for a vertical slider should be dragging the thumb from bottom to - // top. However from a y-axis standpoint this is inverted. - return this.vertical ? !this.invert : this.invert; - } - - /** Whether the slider is at its minimum value. */ - _isMinValue() { - return this.percent === 0; + registerOnTouched(fn: any): void { + this._onTouched = fn; } /** - * The amount of space to leave between the slider thumb and the track fill & track background - * elements. + * Sets whether the component should be disabled. + * Implemented as part of ControlValueAccessor. + * @param isDisabled */ - _getThumbGap() { - if (this.disabled) { - return DISABLED_THUMB_GAP; - } - if (this._isMinValue() && !this.thumbLabel) { - return this._isActive ? MIN_VALUE_ACTIVE_THUMB_GAP : MIN_VALUE_NONACTIVE_THUMB_GAP; - } - return 0; - } - - /** CSS styles for the track background element. */ - _getTrackBackgroundStyles(): {[key: string]: string} { - const axis = this.vertical ? 'Y' : 'X'; - const scale = this.vertical ? `1, ${1 - this.percent}, 1` : `${1 - this.percent}, 1, 1`; - const sign = this._shouldInvertMouseCoords() ? '-' : ''; - - return { - // scale3d avoids some rendering issues in Chrome. See #12071. - transform: `translate${axis}(${sign}${this._getThumbGap()}px) scale3d(${scale})`, - }; - } - - /** CSS styles for the track fill element. */ - _getTrackFillStyles(): {[key: string]: string} { - const percent = this.percent; - const axis = this.vertical ? 'Y' : 'X'; - const scale = this.vertical ? `1, ${percent}, 1` : `${percent}, 1, 1`; - const sign = this._shouldInvertMouseCoords() ? '' : '-'; - - return { - // scale3d avoids some rendering issues in Chrome. See #12071. - transform: `translate${axis}(${sign}${this._getThumbGap()}px) scale3d(${scale})`, - // iOS Safari has a bug where it won't re-render elements which start of as `scale(0)` until - // something forces a style recalculation on it. Since we'll end up with `scale(0)` when - // the value of the slider is 0, we can easily get into this situation. We force a - // recalculation by changing the element's `display` when it goes from 0 to any other value. - display: percent === 0 ? 'none' : '', - }; - } - - /** CSS styles for the ticks container element. */ - _getTicksContainerStyles(): {[key: string]: string} { - let axis = this.vertical ? 'Y' : 'X'; - // For a horizontal slider in RTL languages we push the ticks container off the left edge - // instead of the right edge to avoid causing a horizontal scrollbar to appear. - let sign = !this.vertical && this._getDirection() == 'rtl' ? '' : '-'; - let offset = (this._tickIntervalPercent / 2) * 100; - return { - 'transform': `translate${axis}(${sign}${offset}%)`, - }; - } - - /** CSS styles for the ticks element. */ - _getTicksStyles(): {[key: string]: string} { - let tickSize = this._tickIntervalPercent * 100; - let backgroundSize = this.vertical ? `2px ${tickSize}%` : `${tickSize}% 2px`; - let axis = this.vertical ? 'Y' : 'X'; - // Depending on the direction we pushed the ticks container, push the ticks the opposite - // direction to re-center them but clip off the end edge. In RTL languages we need to flip the - // ticks 180 degrees so we're really cutting off the end edge abd not the start. - let sign = !this.vertical && this._getDirection() == 'rtl' ? '-' : ''; - let rotate = !this.vertical && this._getDirection() == 'rtl' ? ' rotate(180deg)' : ''; - let styles: {[key: string]: string} = { - 'backgroundSize': backgroundSize, - // Without translateZ ticks sometimes jitter as the slider moves on Chrome & Firefox. - 'transform': `translateZ(0) translate${axis}(${sign}${tickSize / 2}%)${rotate}`, - }; - - if (this._isMinValue() && this._getThumbGap()) { - const shouldInvertAxis = this._shouldInvertAxis(); - let side: string; - - if (this.vertical) { - side = shouldInvertAxis ? 'Bottom' : 'Top'; - } else { - side = shouldInvertAxis ? 'Right' : 'Left'; - } - - styles[`padding${side}`] = `${this._getThumbGap()}px`; - } - - return styles; + setDisabledState(isDisabled: boolean): void { + this._disabled = isDisabled; + this._slider._updateDisabled(); } - _getThumbContainerStyles(): {[key: string]: string} { - const shouldInvertAxis = this._shouldInvertAxis(); - let axis = this.vertical ? 'Y' : 'X'; - // For a horizontal slider in RTL languages we push the thumb container off the left edge - // instead of the right edge to avoid causing a horizontal scrollbar to appear. - let invertOffset = - this._getDirection() == 'rtl' && !this.vertical ? !shouldInvertAxis : shouldInvertAxis; - let offset = (invertOffset ? this.percent : 1 - this.percent) * 100; - return { - 'transform': `translate${axis}(-${offset}%)`, - }; + focus(): void { + this._hostElement.focus(); } - /** The size of a tick interval as a percentage of the size of the track. */ - private _tickIntervalPercent: number = 0; - - /** The dimensions of the slider. */ - private _sliderDimensions: ClientRect | null = null; - - private _controlValueAccessorChangeFn: (value: any) => void = () => {}; - - /** Decimal places to round to, based on the step amount. */ - private _roundToDecimal: number; + blur(): void { + this._hostElement.blur(); + } - /** Subscription to the Directionality change EventEmitter. */ - private _dirChangeSubscription = Subscription.EMPTY; + /** Returns true if this slider input currently has focus. */ + _isFocused(): boolean { + return this._document.activeElement === this._hostElement; + } - /** The value of the slider when the slide start event fires. */ - private _valueOnSlideStart: number | null; + /** + * Sets the min, max, and step properties on the slider thumb input. + * + * Must be called AFTER the sibling slider thumb input is guaranteed to have had its value + * attribute value set. For a range slider, the min and max of the slider thumb input depends on + * the value of its sibling slider thumb inputs value. + * + * Must be called BEFORE the value property is set. In the case where the min and max have not + * yet been set and we are setting the input value property to a value outside of the native + * inputs default min or max. The value property would not be set to our desired value, but + * instead be capped at either the default min or max. + * + */ + _initializeInputState(): void { + const min = this._hostElement.hasAttribute('matSliderEndThumb') + ? this._slider._getInput(Thumb.START).value + : this._slider.min; + const max = this._hostElement.hasAttribute('matSliderStartThumb') + ? this._slider._getInput(Thumb.END).value + : this._slider.max; + this._hostElement.min = `${min}`; + this._hostElement.max = `${max}`; + this._hostElement.step = `${this._slider.step}`; + } - /** Reference to the inner slider wrapper element. */ - @ViewChild('sliderWrapper') private _sliderWrapper: ElementRef; + /** + * Sets the value property on the slider thumb input. + * + * Must be called AFTER the min and max have been set. In the case where the min and max have not + * yet been set and we are setting the input value property to a value outside of the native + * inputs default min or max. The value property would not be set to our desired value, but + * instead be capped at either the default min or max. + */ + private _initializeInputValueProperty(): void { + this._hostElement.value = `${this.value}`; + } /** - * Whether mouse events should be converted to a slider position by calculating their distance - * from the right or bottom edge of the slider as opposed to the top or left. + * Ensures the value attribute is initialized. + * + * Must be called BEFORE the min and max are set. For a range slider, the min and max of the + * slider thumb input depends on the value of its sibling slider thumb inputs value. */ - _shouldInvertMouseCoords() { - const shouldInvertAxis = this._shouldInvertAxis(); - return this._getDirection() == 'rtl' && !this.vertical ? !shouldInvertAxis : shouldInvertAxis; + private _initializeInputValueAttribute(): void { + // Only set the default value if an initial value has not already been provided. + if (!this._hostElement.hasAttribute('value')) { + this.value = this._hostElement.hasAttribute('matSliderEndThumb') + ? this._slider.max + : this._slider.min; + } } - /** The language direction for this slider element. */ - private _getDirection() { - return this._dir && this._dir.value == 'rtl' ? 'rtl' : 'ltr'; + /** + * Initializes the aria-valuetext attribute. + * + * Must be called AFTER the value attribute is set. This is because the slider's parent + * `displayWith` function is used to set the `aria-valuetext` attribute. + */ + private _initializeAriaValueText(): void { + this._hostElement.setAttribute('aria-valuetext', this._slider.displayWith(this.value)); } +} - /** Keeps track of the last pointer event that was captured by the slider. */ - private _lastPointerEvent: MouseEvent | TouchEvent | null; +// Boilerplate for applying mixins to MatSlider. +const _MatSliderMixinBase = mixinColor( + mixinDisableRipple( + class { + constructor(public _elementRef: ElementRef) {} + }, + ), + 'primary', +); - /** Used to subscribe to global move and end events */ - protected _document: Document; +/** + * Allows users to select from a range of values by moving the slider thumb. It is similar in + * behavior to the native `` element. + */ +@Component({ + selector: 'mat-slider', + templateUrl: 'slider.html', + styleUrls: ['slider.css'], + host: { + 'class': 'mat-mdc-slider mdc-slider', + '[class.mdc-slider--range]': '_isRange()', + '[class.mdc-slider--disabled]': 'disabled', + '[class.mdc-slider--discrete]': 'discrete', + '[class.mdc-slider--tick-marks]': 'showTickMarks', + '[class._mat-animation-noopable]': '_noopAnimations', + }, + exportAs: 'matSlider', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + inputs: ['color', 'disableRipple'], +}) +export class MatSlider + extends _MatSliderMixinBase + implements AfterViewInit, CanDisableRipple, OnDestroy +{ + /** The slider thumb(s). */ + @ViewChildren(MatSliderVisualThumb) _thumbs: QueryList; - /** - * Identifier used to attribute a touch event to a particular slider. - * Will be undefined if one of the following conditions is true: - * - The user isn't dragging using a touch device. - * - The browser doesn't support `Touch.identifier`. - * - Dragging hasn't started yet. - */ - private _touchId: number | undefined; + /** The active section of the slider track. */ + @ViewChild('trackActive') _trackActive: ElementRef; - constructor( - elementRef: ElementRef, - private _focusMonitor: FocusMonitor, - private _changeDetectorRef: ChangeDetectorRef, - @Optional() private _dir: Directionality, - @Attribute('tabindex') tabIndex: string, - private _ngZone: NgZone, - @Inject(DOCUMENT) _document: any, - @Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string, - ) { - super(elementRef); - this._document = _document; - this.tabIndex = parseInt(tabIndex) || 0; + /** The sliders hidden range input(s). */ + @ContentChildren(MatSliderThumb, {descendants: false}) + _inputs: QueryList; - _ngZone.runOutsideAngular(() => { - const element = elementRef.nativeElement; - element.addEventListener('mousedown', this._pointerDown, activeEventOptions); - element.addEventListener('touchstart', this._pointerDown, activeEventOptions); - }); + /** Whether the slider is disabled. */ + @Input() + get disabled(): boolean { + return this._disabled; } - - ngAfterViewInit() { - this._focusMonitor.monitor(this._elementRef, true).subscribe((origin: FocusOrigin) => { - this._isActive = !!origin && origin !== 'keyboard'; - this._changeDetectorRef.detectChanges(); - }); - if (this._dir) { - this._dirChangeSubscription = this._dir.change.subscribe(() => { - this._changeDetectorRef.markForCheck(); - }); - } + set disabled(v: BooleanInput) { + this._setDisabled(coerceBooleanProperty(v)); + this._updateInputsDisabledState(); } + private _disabled: boolean = false; - ngOnDestroy() { - const element = this._elementRef.nativeElement; - element.removeEventListener('mousedown', this._pointerDown, activeEventOptions); - element.removeEventListener('touchstart', this._pointerDown, activeEventOptions); - this._lastPointerEvent = null; - this._removeGlobalEvents(); - this._focusMonitor.stopMonitoring(this._elementRef); - this._dirChangeSubscription.unsubscribe(); + /** Whether the slider displays a numeric value label upon pressing the thumb. */ + @Input() + get discrete(): boolean { + return this._discrete; } + set discrete(v: BooleanInput) { + this._discrete = coerceBooleanProperty(v); + } + private _discrete: boolean = false; - _onMouseenter() { - if (this.disabled) { - return; - } + /** Whether the slider displays tick marks along the slider track. */ + @Input() + get showTickMarks(): boolean { + return this._showTickMarks; + } + set showTickMarks(v: BooleanInput) { + this._showTickMarks = coerceBooleanProperty(v); + } + private _showTickMarks: boolean = false; - // We save the dimensions of the slider here so we can use them to update the spacing of the - // ticks and determine where on the slider click and slide events happen. - this._sliderDimensions = this._getSliderDimensions(); - this._updateTickIntervalPercent(); + /** The minimum value that the slider can have. */ + @Input() + get min(): number { + return this._min; } + set min(v: NumberInput) { + this._min = coerceNumberProperty(v, this._min); + this._reinitialize(); + } + private _min: number = 0; - _onFocus() { - // We save the dimensions of the slider here so we can use them to update the spacing of the - // ticks and determine where on the slider click and slide events happen. - this._sliderDimensions = this._getSliderDimensions(); - this._updateTickIntervalPercent(); + /** The maximum value that the slider can have. */ + @Input() + get max(): number { + return this._max; + } + set max(v: NumberInput) { + this._max = coerceNumberProperty(v, this._max); + this._reinitialize(); } + private _max: number = 100; - _onBlur() { - this.onTouched(); + /** The values at which the thumb will snap. */ + @Input() + get step(): number { + return this._step; } + set step(v: NumberInput) { + this._step = coerceNumberProperty(v, this._step); + this._reinitialize(); + } + private _step: number = 1; - _onKeydown(event: KeyboardEvent) { - if ( - this.disabled || - hasModifierKey(event) || - (this._isSliding && this._isSliding !== 'keyboard') - ) { - return; - } + /** + * Function that will be used to format the value before it is displayed + * in the thumb label. Can be used to format very large number in order + * for them to fit into the slider thumb. + */ + @Input() displayWith: (value: number) => string = (value: number) => `${value}`; - const oldValue = this.value; + /** Instance of the MDC slider foundation for this slider. */ + private _foundation = new MDCSliderFoundation(new SliderAdapter(this)); - switch (event.keyCode) { - case PAGE_UP: - this._increment(10); - break; - case PAGE_DOWN: - this._increment(-10); - break; - case END: - this.value = this.max; - break; - case HOME: - this.value = this.min; - break; - case LEFT_ARROW: - // NOTE: For a sighted user it would make more sense that when they press an arrow key on an - // inverted slider the thumb moves in that direction. However for a blind user, nothing - // about the slider indicates that it is inverted. They will expect left to be decrement, - // regardless of how it appears on the screen. For speakers ofRTL languages, they probably - // expect left to mean increment. Therefore we flip the meaning of the side arrow keys for - // RTL. For inverted sliders we prefer a good a11y experience to having it "look right" for - // sighted users, therefore we do not swap the meaning. - this._increment(this._getDirection() == 'rtl' ? 1 : -1); - break; - case UP_ARROW: - this._increment(1); - break; - case RIGHT_ARROW: - // See comment on LEFT_ARROW about the conditions under which we flip the meaning. - this._increment(this._getDirection() == 'rtl' ? -1 : 1); - break; - case DOWN_ARROW: - this._increment(-1); - break; - default: - // Return if the key is not one that we explicitly handle to avoid calling preventDefault on - // it. - return; - } + /** Whether the foundation has been initialized. */ + _initialized: boolean = false; - if (oldValue != this.value) { - this._emitInputEvent(); - this._emitChangeEvent(); - } + /** The injected document if available or fallback to the global document reference. */ + _document: Document; - this._isSliding = 'keyboard'; - event.preventDefault(); - } + /** + * The defaultView of the injected document if + * available or fallback to global window reference. + */ + _window: Window; - _onKeyup() { - if (this._isSliding === 'keyboard') { - this._isSliding = null; - } - } + /** Used to keep track of & render the active & inactive tick marks on the slider track. */ + _tickMarks: TickMark[]; - /** Called when the user has put their pointer down on the slider. */ - private _pointerDown = (event: TouchEvent | MouseEvent) => { - // Don't do anything if the slider is disabled or the - // user is using anything other than the main mouse button. - if (this.disabled || this._isSliding || (!isTouchEvent(event) && event.button !== 0)) { - return; - } + /** The display value of the start thumb. */ + _startValueIndicatorText: string; - this._ngZone.run(() => { - this._touchId = isTouchEvent(event) - ? getTouchIdForSlider(event, this._elementRef.nativeElement) - : undefined; - const pointerPosition = getPointerPositionOnPage(event, this._touchId); - - if (pointerPosition) { - const oldValue = this.value; - this._isSliding = 'pointer'; - this._lastPointerEvent = event; - this._focusHostElement(); - this._onMouseenter(); // Simulate mouseenter in case this is a mobile device. - this._bindGlobalEvents(event); - this._focusHostElement(); - this._updateValueFromPosition(pointerPosition); - this._valueOnSlideStart = oldValue; - - // Despite the fact that we explicitly bind active events, in some cases the browser - // still dispatches non-cancelable events which cause this call to throw an error. - // There doesn't appear to be a good way of avoiding them. See #23820. - if (event.cancelable) { - event.preventDefault(); - } + /** The display value of the end thumb. */ + _endValueIndicatorText: string; - // Emit a change and input event if the value changed. - if (oldValue != this.value) { - this._emitInputEvent(); - } - } - }); - }; + /** Whether animations have been disabled. */ + _noopAnimations: boolean; /** - * Called when the user has moved their pointer after - * starting to drag. Bound on the document level. + * Whether the browser supports pointer events. + * + * We exclude iOS to mirror the MDC Foundation. The MDC Foundation cannot use pointer events on + * iOS because of this open bug - https://bugs.webkit.org/show_bug.cgi?id=220196. */ - private _pointerMove = (event: TouchEvent | MouseEvent) => { - if (this._isSliding === 'pointer') { - const pointerPosition = getPointerPositionOnPage(event, this._touchId); - - if (pointerPosition) { - // Prevent the slide from selecting anything else. - if (event.cancelable) { - event.preventDefault(); - } - const oldValue = this.value; - this._lastPointerEvent = event; - this._updateValueFromPosition(pointerPosition); + private _SUPPORTS_POINTER_EVENTS = + typeof PointerEvent !== 'undefined' && !!PointerEvent && !this._platform.IOS; - // Native range elements always emit `input` events when the value changed while sliding. - if (oldValue != this.value) { - this._emitInputEvent(); - } - } - } - }; + /** Subscription to changes to the directionality (LTR / RTL) context for the application. */ + private _dirChangeSubscription: Subscription; - /** Called when the user has lifted their pointer. Bound on the document level. */ - private _pointerUp = (event: TouchEvent | MouseEvent) => { - if (this._isSliding === 'pointer') { - if ( - !isTouchEvent(event) || - typeof this._touchId !== 'number' || - // Note that we use `changedTouches`, rather than `touches` because it - // seems like in most cases `touches` is empty for `touchend` events. - findMatchingTouch(event.changedTouches, this._touchId) - ) { - if (event.cancelable) { - event.preventDefault(); - } - this._removeGlobalEvents(); - this._isSliding = null; - this._touchId = undefined; + /** Observer used to monitor size changes in the slider. */ + private _resizeObserver: ResizeObserver | null; - if (this._valueOnSlideStart != this.value && !this.disabled) { - this._emitChangeEvent(); - } + /** Timeout used to debounce resize listeners. */ + private _resizeTimer: number; - this._valueOnSlideStart = this._lastPointerEvent = null; - } + /** Cached dimensions of the host element. */ + private _cachedHostRect: DOMRect | null; + + constructor( + readonly _ngZone: NgZone, + readonly _cdr: ChangeDetectorRef, + elementRef: ElementRef, + private readonly _platform: Platform, + readonly _globalChangeAndInputListener: GlobalChangeAndInputListener<'input' | 'change'>, + @Inject(DOCUMENT) document: any, + @Optional() private _dir: Directionality, + @Optional() + @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) + readonly _globalRippleOptions?: RippleGlobalOptions, + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string, + ) { + super(elementRef); + this._document = document; + this._window = this._document.defaultView || window; + this._noopAnimations = animationMode === 'NoopAnimations'; + this._dirChangeSubscription = this._dir.change.subscribe(() => this._onDirChange()); + this._attachUISyncEventListener(); + } + + ngAfterViewInit() { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + _validateThumbs(this._isRange(), this._getThumb(Thumb.START), this._getThumb(Thumb.END)); + _validateInputs( + this._isRange(), + this._getInputElement(Thumb.START), + this._getInputElement(Thumb.END), + ); } - }; + if (this._platform.isBrowser) { + this._foundation.init(); + this._foundation.layout(); + this._initialized = true; + this._observeHostResize(); + } + // The MDC foundation requires access to the view and content children of the MatSlider. In + // order to access the view and content children of MatSlider we need to wait until change + // detection runs and materializes them. That is why we call init() and layout() in + // ngAfterViewInit(). + // + // The MDC foundation then uses the information it gathers from the DOM to compute an initial + // value for the tickMarks array. It then tries to update the component data, but because it is + // updating the component data AFTER change detection already ran, we will get a changed after + // checked error. Because of this, we need to force change detection to update the UI with the + // new state. + this._cdr.detectChanges(); + } - /** Called when the window has lost focus. */ - private _windowBlur = () => { - // If the window is blurred while dragging we need to stop dragging because the - // browser won't dispatch the `mouseup` and `touchend` events anymore. - if (this._lastPointerEvent) { - this._pointerUp(this._lastPointerEvent); + ngOnDestroy() { + if (this._platform.isBrowser) { + this._foundation.destroy(); } - }; + this._dirChangeSubscription.unsubscribe(); + this._resizeObserver?.disconnect(); + this._resizeObserver = null; + clearTimeout(this._resizeTimer); + this._removeUISyncEventListener(); + } - /** Use defaultView of injected document if available or fallback to global window reference */ - private _getWindow(): Window { - return this._document.defaultView || window; + /** Returns true if the language direction for this slider element is right to left. */ + _isRTL() { + return this._dir && this._dir.value === 'rtl'; } /** - * Binds our global move and end events. They're bound at the document level and only while - * dragging so that the user doesn't have to keep their pointer exactly over the slider - * as they're swiping across the screen. + * Attaches an event listener that keeps sync the slider UI and the foundation in sync. + * + * Because the MDC Foundation stores the value of the bounding client rect when layout is called, + * we need to keep calling layout to avoid the position of the slider getting out of sync with + * what the foundation has stored. If we don't do this, the foundation will not be able to + * correctly calculate the slider value on click/slide. */ - private _bindGlobalEvents(triggerEvent: TouchEvent | MouseEvent) { - // Note that we bind the events to the `document`, because it allows us to capture - // drag cancel events where the user's pointer is outside the browser window. - const document = this._document; - const isTouch = isTouchEvent(triggerEvent); - const moveEventName = isTouch ? 'touchmove' : 'mousemove'; - const endEventName = isTouch ? 'touchend' : 'mouseup'; - document.addEventListener(moveEventName, this._pointerMove, activeEventOptions); - document.addEventListener(endEventName, this._pointerUp, activeEventOptions); - - if (isTouch) { - document.addEventListener('touchcancel', this._pointerUp, activeEventOptions); + _attachUISyncEventListener(): void { + // Implementation detail: It may seem weird that we are using "mouseenter" instead of + // "mousedown" as the default for when a browser does not support pointer events. While we + // would prefer to use "mousedown" as the default, for some reason it does not work (the + // callback is never triggered). + if (this._SUPPORTS_POINTER_EVENTS) { + this._elementRef.nativeElement.addEventListener('pointerdown', this._layout); + } else { + this._elementRef.nativeElement.addEventListener('mouseenter', this._layout); + this._elementRef.nativeElement.addEventListener( + 'touchstart', + this._layout, + passiveEventListenerOptions, + ); } + } - const window = this._getWindow(); - - if (typeof window !== 'undefined' && window) { - window.addEventListener('blur', this._windowBlur); + /** Removes the event listener that keeps sync the slider UI and the foundation in sync. */ + _removeUISyncEventListener(): void { + if (this._SUPPORTS_POINTER_EVENTS) { + this._elementRef.nativeElement.removeEventListener('pointerdown', this._layout); + } else { + this._elementRef.nativeElement.removeEventListener('mouseenter', this._layout); + this._elementRef.nativeElement.removeEventListener( + 'touchstart', + this._layout, + passiveEventListenerOptions, + ); } } - /** Removes any global event listeners that we may have added. */ - private _removeGlobalEvents() { - const document = this._document; - document.removeEventListener('mousemove', this._pointerMove, activeEventOptions); - document.removeEventListener('mouseup', this._pointerUp, activeEventOptions); - document.removeEventListener('touchmove', this._pointerMove, activeEventOptions); - document.removeEventListener('touchend', this._pointerUp, activeEventOptions); - document.removeEventListener('touchcancel', this._pointerUp, activeEventOptions); - - const window = this._getWindow(); + /** Wrapper function for calling layout (needed for adding & removing an event listener). */ + private _layout = () => this._foundation.layout(); - if (typeof window !== 'undefined' && window) { - window.removeEventListener('blur', this._windowBlur); + /** + * Reinitializes the slider foundation and input state(s). + * + * The MDC Foundation does not support changing some slider attributes after it has been + * initialized (e.g. min, max, and step). To continue supporting this feature, we need to + * destroy the foundation and re-initialize everything whenever we make these changes. + */ + private _reinitialize(): void { + if (this._initialized) { + this._foundation.destroy(); + if (this._isRange()) { + this._getInput(Thumb.START)._initializeInputState(); + } + this._getInput(Thumb.END)._initializeInputState(); + this._foundation.init(); + this._foundation.layout(); } } - /** Increments the slider by the given number of steps (negative number decrements). */ - private _increment(numSteps: number) { - // Pre-clamp the current value since it's allowed to be - // out of bounds when assigned programmatically. - const clampedValue = this._clamp(this.value || 0, this.min, this.max); - this.value = this._clamp(clampedValue + this.step * numSteps, this.min, this.max); + /** Handles updating the slider foundation after a dir change. */ + private _onDirChange(): void { + this._ngZone.runOutsideAngular(() => { + // We need to call layout() a few milliseconds after the dir change callback + // because we need to wait until the bounding client rect of the slider has updated. + setTimeout(() => this._foundation.layout(), 10); + }); } - /** Calculate the new value from the new physical location. The value will always be snapped. */ - private _updateValueFromPosition(pos: {x: number; y: number}) { - if (!this._sliderDimensions) { - return; - } + /** Sets the value of a slider thumb. */ + _setValue(value: number, thumbPosition: Thumb): void { + thumbPosition === Thumb.START + ? this._foundation.setValueStart(value) + : this._foundation.setValue(value); + } - let offset = this.vertical ? this._sliderDimensions.top : this._sliderDimensions.left; - let size = this.vertical ? this._sliderDimensions.height : this._sliderDimensions.width; - let posComponent = this.vertical ? pos.y : pos.x; + /** Sets the disabled state of the MatSlider. */ + private _setDisabled(value: boolean) { + this._disabled = value; - // The exact value is calculated from the event and used to find the closest snap value. - let percent = this._clamp((posComponent - offset) / size); + // If we want to disable the slider after the foundation has been initialized, + // we need to inform the foundation by calling `setDisabled`. Also, we can't call + // this before initializing the foundation because it will throw errors. + if (this._initialized) { + this._foundation.setDisabled(value); + } + } - if (this._shouldInvertMouseCoords()) { - percent = 1 - percent; + /** Sets the disabled state of the individual slider thumb(s) (ControlValueAccessor). */ + private _updateInputsDisabledState() { + if (this._initialized) { + this._getInput(Thumb.END)._disabled = true; + if (this._isRange()) { + this._getInput(Thumb.START)._disabled = true; + } } + } - // Since the steps may not divide cleanly into the max value, if the user - // slid to 0 or 100 percent, we jump to the min/max value. This approach - // is slightly more intuitive than using `Math.ceil` below, because it - // follows the user's pointer closer. - if (percent === 0) { - this.value = this.min; - } else if (percent === 1) { - this.value = this.max; - } else { - const exactValue = this._calculateValue(percent); + /** Whether this is a ranged slider. */ + _isRange(): boolean { + return this._inputs.length === 2; + } - // This calculation finds the closest step by finding the closest - // whole number divisible by the step relative to the min. - const closestValue = Math.round((exactValue - this.min) / this.step) * this.step + this.min; + /** Sets the disabled state based on the disabled state of the inputs (ControlValueAccessor). */ + _updateDisabled(): void { + const disabled = this._inputs?.some(input => input._disabled) || false; + this._setDisabled(disabled); + } - // The value needs to snap to the min and max. - this.value = this._clamp(closestValue, this.min, this.max); - } + /** Gets the slider thumb input of the given thumb position. */ + _getInput(thumbPosition: Thumb): MatSliderThumb { + return thumbPosition === Thumb.END ? this._inputs?.last! : this._inputs?.first!; } - /** Emits a change event if the current value is different from the last emitted value. */ - private _emitChangeEvent() { - this._controlValueAccessorChangeFn(this.value); - this.valueChange.emit(this.value); - this.change.emit(this._createChangeEvent()); + /** Gets the slider thumb HTML input element of the given thumb position. */ + _getInputElement(thumbPosition: Thumb): HTMLInputElement { + return this._getInput(thumbPosition)?._hostElement; } - /** Emits an input event when the current value is different from the last emitted value. */ - private _emitInputEvent() { - this.input.emit(this._createChangeEvent()); + _getThumb(thumbPosition: Thumb): MatSliderVisualThumb { + return thumbPosition === Thumb.END ? this._thumbs?.last! : this._thumbs?.first!; } - /** Updates the amount of space between ticks as a percentage of the width of the slider. */ - private _updateTickIntervalPercent() { - if (!this.tickInterval || !this._sliderDimensions) { - return; - } + /** Gets the slider thumb HTML element of the given thumb position. */ + _getThumbElement(thumbPosition: Thumb): HTMLElement { + return this._getThumb(thumbPosition)?._getHostElement(); + } - let tickIntervalPercent: number; - if (this.tickInterval == 'auto') { - let trackSize = this.vertical ? this._sliderDimensions.height : this._sliderDimensions.width; - let pixelsPerStep = (trackSize * this.step) / (this.max - this.min); - let stepsPerTick = Math.ceil(MIN_AUTO_TICK_SEPARATION / pixelsPerStep); - let pixelsPerTick = stepsPerTick * this.step; - tickIntervalPercent = pixelsPerTick / trackSize; - } else { - tickIntervalPercent = (this.tickInterval * this.step) / (this.max - this.min); - } - this._tickIntervalPercent = isSafeNumber(tickIntervalPercent) ? tickIntervalPercent : 0; + /** Gets the slider knob HTML element of the given thumb position. */ + _getKnobElement(thumbPosition: Thumb): HTMLElement { + return this._getThumb(thumbPosition)?._getKnob(); } - /** Creates a slider change object from the specified value. */ - private _createChangeEvent(value = this.value): MatSliderChange { - let event = new MatSliderChange(); + /** + * Gets the slider value indicator container HTML element of the given thumb + * position. + */ + _getValueIndicatorContainerElement(thumbPosition: Thumb): HTMLElement { + return this._getThumb(thumbPosition)._getValueIndicatorContainer(); + } - event.source = this; - event.value = value; + /** + * Sets the value indicator text of the given thumb position using the given value. + * + * Uses the `displayWith` function if one has been provided. Otherwise, it just uses the + * numeric value as a string. + */ + _setValueIndicatorText(value: number, thumbPosition: Thumb) { + thumbPosition === Thumb.START + ? (this._startValueIndicatorText = this.displayWith(value)) + : (this._endValueIndicatorText = this.displayWith(value)); + this._cdr.markForCheck(); + } - return event; + /** Gets the value indicator text for the given thumb position. */ + _getValueIndicatorText(thumbPosition: Thumb): string { + return thumbPosition === Thumb.START + ? this._startValueIndicatorText + : this._endValueIndicatorText; } - /** Calculates the percentage of the slider that a value is. */ - private _calculatePercentage(value: number | null) { - const percentage = ((value || 0) - this.min) / (this.max - this.min); - return isSafeNumber(percentage) ? percentage : 0; + /** Determines the class name for a HTML element. */ + _getTickMarkClass(tickMark: TickMark): string { + return tickMark === TickMark.ACTIVE + ? 'mdc-slider__tick-mark--active' + : 'mdc-slider__tick-mark--inactive'; } - /** Calculates the value a percentage of the slider corresponds to. */ - private _calculateValue(percentage: number) { - return this.min + percentage * (this.max - this.min); + /** Whether the slider thumb ripples should be disabled. */ + _isRippleDisabled(): boolean { + return this.disabled || this.disableRipple || !!this._globalRippleOptions?.disabled; } - /** Return a number between two numbers. */ - private _clamp(value: number, min = 0, max = 1) { - return Math.max(min, Math.min(value, max)); + /** Gets the dimensions of the host element. */ + _getHostDimensions() { + return this._cachedHostRect || this._elementRef.nativeElement.getBoundingClientRect(); } - /** - * Get the bounding client rect of the slider track element. - * The track is used rather than the native element to ignore the extra space that the thumb can - * take up. - */ - private _getSliderDimensions() { - return this._sliderWrapper ? this._sliderWrapper.nativeElement.getBoundingClientRect() : null; + /** Starts observing and updating the slider if the host changes its size. */ + private _observeHostResize() { + if (typeof ResizeObserver === 'undefined' || !ResizeObserver) { + return; + } + + // MDC only updates the slider when the window is resized which + // doesn't capture changes of the container itself. We use a resize + // observer to ensure that the layout is correct (see #24590 and #25286). + this._ngZone.runOutsideAngular(() => { + this._resizeObserver = new ResizeObserver(entries => { + // Triggering a layout while the user is dragging can throw off the alignment. + if (this._isActive()) { + return; + } + + clearTimeout(this._resizeTimer); + this._resizeTimer = setTimeout(() => { + // The `layout` call is going to call `getBoundingClientRect` to update the dimensions + // of the host. Since the `ResizeObserver` already calculated them, we can save some + // work by returning them instead of having to check the DOM again. + if (!this._isActive()) { + this._cachedHostRect = entries[0]?.contentRect; + this._layout(); + this._cachedHostRect = null; + } + }, 50); + }); + this._resizeObserver.observe(this._elementRef.nativeElement); + }); } - /** - * Focuses the native element. - * Currently only used to allow a blur event to fire but will be used with keyboard input later. - */ - private _focusHostElement(options?: FocusOptions) { - this._elementRef.nativeElement.focus(options); + /** Whether any of the thumbs are currently active. */ + private _isActive(): boolean { + return this._getThumb(Thumb.START)._isActive || this._getThumb(Thumb.END)._isActive; } +} + +/** The MDCSliderAdapter implementation. */ +class SliderAdapter implements MDCSliderAdapter { + /** The global event listener subscription used to handle events on the slider inputs. */ + private _globalEventSubscriptions = new Subscription(); - /** Blurs the native element. */ - private _blurHostElement() { - this._elementRef.nativeElement.blur(); + /** The MDC Foundations handler function for start input change events. */ + private _startInputChangeEventHandler: SpecificEventListener; + + /** The MDC Foundations handler function for end input change events. */ + private _endInputChangeEventHandler: SpecificEventListener; + + constructor(private readonly _delegate: MatSlider) { + this._globalEventSubscriptions.add(this._subscribeToSliderInputEvents('change')); + this._globalEventSubscriptions.add(this._subscribeToSliderInputEvents('input')); } /** - * Sets the model value. Implemented as part of ControlValueAccessor. - * @param value + * Handles "change" and "input" events on the slider inputs. + * + * Exposes a callback to allow the MDC Foundations "change" event handler to be called for "real" + * events. + * + * ** IMPORTANT NOTE ** + * + * We block all "real" change and input events and emit fake events from #emitChangeEvent and + * #emitInputEvent, instead. We do this because interacting with the MDC slider won't trigger all + * of the correct change and input events, but it will call #emitChangeEvent and #emitInputEvent + * at the correct times. This allows users to listen for these events directly on the slider + * input as they would with a native range input. */ - writeValue(value: any) { - this.value = value; + private _subscribeToSliderInputEvents(type: 'change' | 'input') { + return this._delegate._globalChangeAndInputListener.listen(type, (event: Event) => { + const thumbPosition = this._getInputThumbPosition(event.target); + + // Do nothing if the event isn't from a thumb input. + if (thumbPosition === null) { + return; + } + + // Do nothing if the event is "fake". + if ((event as any)._matIsHandled) { + return; + } + + // Prevent "real" events from reaching end users. + event.stopImmediatePropagation(); + + // Relay "real" change events to the MDC Foundation. + if (type === 'change') { + this._callChangeEventHandler(event, thumbPosition); + } + }); } - /** - * Registers a callback to be triggered when the value has changed. - * Implemented as part of ControlValueAccessor. - * @param fn Callback to be registered. - */ - registerOnChange(fn: (value: any) => void) { - this._controlValueAccessorChangeFn = fn; + /** Calls the MDC Foundations change event handler for the specified thumb position. */ + private _callChangeEventHandler(event: Event, thumbPosition: Thumb) { + if (thumbPosition === Thumb.START) { + this._startInputChangeEventHandler(event); + } else { + this._endInputChangeEventHandler(event); + } } - /** - * Registers a callback to be triggered when the component is touched. - * Implemented as part of ControlValueAccessor. - * @param fn Callback to be registered. - */ - registerOnTouched(fn: any) { - this.onTouched = fn; + /** Save the event handler so it can be used in our global change event listener subscription. */ + private _saveChangeEventHandler(thumbPosition: Thumb, handler: SpecificEventListener) { + if (thumbPosition === Thumb.START) { + this._startInputChangeEventHandler = handler; + } else { + this._endInputChangeEventHandler = handler; + } } /** - * Sets whether the component should be disabled. - * Implemented as part of ControlValueAccessor. - * @param isDisabled + * Returns the thumb position of the given event target. + * Returns null if the given event target does not correspond to a slider thumb input. */ - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; + private _getInputThumbPosition(target: EventTarget | null): Thumb | null { + if (target === this._delegate._getInputElement(Thumb.END)) { + return Thumb.END; + } + if (this._delegate._isRange() && target === this._delegate._getInputElement(Thumb.START)) { + return Thumb.START; + } + return null; } -} -/** Checks if number is safe for calculation */ -function isSafeNumber(value: number) { - return !isNaN(value) && isFinite(value); -} + // We manually assign functions instead of using prototype methods because + // MDC clobbers the values otherwise. + // See https://github.com/material-components/material-components-web/pull/6256 -/** Returns whether an event is a touch event. */ -function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent { - // This function is called for every pixel that the user has dragged so we need it to be - // as fast as possible. Since we only bind mouse events and touch events, we can assume - // that if the event's name starts with `t`, it's a touch event. - return event.type[0] === 't'; -} + hasClass = (className: string): boolean => { + return this._delegate._elementRef.nativeElement.classList.contains(className); + }; + addClass = (className: string): void => { + this._delegate._elementRef.nativeElement.classList.add(className); + }; + removeClass = (className: string): void => { + this._delegate._elementRef.nativeElement.classList.remove(className); + }; + getAttribute = (attribute: string): string | null => { + return this._delegate._elementRef.nativeElement.getAttribute(attribute); + }; + addThumbClass = (className: string, thumbPosition: Thumb): void => { + this._delegate._getThumbElement(thumbPosition).classList.add(className); + }; + removeThumbClass = (className: string, thumbPosition: Thumb): void => { + this._delegate._getThumbElement(thumbPosition).classList.remove(className); + }; + getInputValue = (thumbPosition: Thumb): string => { + return this._delegate._getInputElement(thumbPosition).value; + }; + setInputValue = (value: string, thumbPosition: Thumb): void => { + this._delegate._getInputElement(thumbPosition).value = value; + }; + getInputAttribute = (attribute: string, thumbPosition: Thumb): string | null => { + return this._delegate._getInputElement(thumbPosition).getAttribute(attribute); + }; + setInputAttribute = (attribute: string, value: string, thumbPosition: Thumb): void => { + const input = this._delegate._getInputElement(thumbPosition); -/** Gets the coordinates of a touch or mouse event relative to the viewport. */ -function getPointerPositionOnPage(event: MouseEvent | TouchEvent, id: number | undefined) { - let point: {clientX: number; clientY: number} | undefined; + // TODO(wagnermaciel): remove this check once this component is + // added to the internal allowlist for calling setAttribute. - if (isTouchEvent(event)) { - // The `identifier` could be undefined if the browser doesn't support `TouchEvent.identifier`. - // If that's the case, attribute the first touch to all active sliders. This should still cover - // the most common case while only breaking multi-touch. - if (typeof id === 'number') { - point = findMatchingTouch(event.touches, id) || findMatchingTouch(event.changedTouches, id); + // Explicitly check the attribute we are setting to prevent xss. + switch (attribute) { + case 'aria-valuetext': + input.setAttribute('aria-valuetext', value); + break; + case 'disabled': + input.setAttribute('disabled', value); + break; + case 'min': + input.setAttribute('min', value); + break; + case 'max': + input.setAttribute('max', value); + break; + case 'value': + input.setAttribute('value', value); + break; + case 'step': + input.setAttribute('step', value); + break; + default: + throw Error(`Tried to set invalid attribute ${attribute} on the mdc-slider.`); + } + }; + removeInputAttribute = (attribute: string, thumbPosition: Thumb): void => { + this._delegate._getInputElement(thumbPosition).removeAttribute(attribute); + }; + focusInput = (thumbPosition: Thumb): void => { + this._delegate._getInputElement(thumbPosition).focus(); + }; + isInputFocused = (thumbPosition: Thumb): boolean => { + return this._delegate._getInput(thumbPosition)._isFocused(); + }; + getThumbKnobWidth = (thumbPosition: Thumb): number => { + return this._delegate._getKnobElement(thumbPosition).getBoundingClientRect().width; + }; + getThumbBoundingClientRect = (thumbPosition: Thumb): DOMRect => { + return this._delegate._getThumbElement(thumbPosition).getBoundingClientRect(); + }; + getBoundingClientRect = (): DOMRect => { + return this._delegate._getHostDimensions(); + }; + getValueIndicatorContainerWidth = (thumbPosition: Thumb): number => { + return this._delegate._getValueIndicatorContainerElement(thumbPosition).getBoundingClientRect() + .width; + }; + isRTL = (): boolean => { + return this._delegate._isRTL(); + }; + setThumbStyleProperty = (propertyName: string, value: string, thumbPosition: Thumb): void => { + this._delegate._getThumbElement(thumbPosition).style.setProperty(propertyName, value); + }; + removeThumbStyleProperty = (propertyName: string, thumbPosition: Thumb): void => { + this._delegate._getThumbElement(thumbPosition).style.removeProperty(propertyName); + }; + setTrackActiveStyleProperty = (propertyName: string, value: string): void => { + this._delegate._trackActive.nativeElement.style.setProperty(propertyName, value); + }; + removeTrackActiveStyleProperty = (propertyName: string): void => { + this._delegate._trackActive.nativeElement.style.removeProperty(propertyName); + }; + setValueIndicatorText = (value: number, thumbPosition: Thumb): void => { + this._delegate._setValueIndicatorText(value, thumbPosition); + }; + getValueToAriaValueTextFn = (): ((value: number) => string) | null => { + return this._delegate.displayWith; + }; + updateTickMarks = (tickMarks: TickMark[]): void => { + this._delegate._tickMarks = tickMarks; + this._delegate._cdr.markForCheck(); + }; + setPointerCapture = (pointerId: number): void => { + this._delegate._elementRef.nativeElement.setPointerCapture(pointerId); + }; + emitChangeEvent = (value: number, thumbPosition: Thumb): void => { + // We block all real slider input change events and emit fake change events from here, instead. + // We do this because the mdc implementation of the slider does not trigger real change events + // on pointer up (only on left or right arrow key down). + // + // By stopping real change events from reaching users, and dispatching fake change events + // (which we allow to reach the user) the slider inputs change events are triggered at the + // appropriate times. This allows users to listen for change events directly on the slider + // input as they would with a native range input. + const input = this._delegate._getInput(thumbPosition); + input._emitFakeEvent('change'); + input._onChange(value); + input.valueChange.emit(value); + }; + emitInputEvent = (value: number, thumbPosition: Thumb): void => { + this._delegate._getInput(thumbPosition)._emitFakeEvent('input'); + }; + emitDragStartEvent = (value: number, thumbPosition: Thumb): void => { + const input = this._delegate._getInput(thumbPosition); + input.dragStart.emit({source: input, parent: this._delegate, value}); + }; + emitDragEndEvent = (value: number, thumbPosition: Thumb): void => { + const input = this._delegate._getInput(thumbPosition); + input.dragEnd.emit({source: input, parent: this._delegate, value}); + }; + registerEventHandler = ( + evtType: K, + handler: SpecificEventListener, + ): void => { + this._delegate._elementRef.nativeElement.addEventListener(evtType, handler); + }; + deregisterEventHandler = ( + evtType: K, + handler: SpecificEventListener, + ): void => { + this._delegate._elementRef.nativeElement.removeEventListener(evtType, handler); + }; + registerThumbEventHandler = ( + thumbPosition: Thumb, + evtType: K, + handler: SpecificEventListener, + ): void => { + this._delegate._getThumbElement(thumbPosition).addEventListener(evtType, handler); + }; + deregisterThumbEventHandler = ( + thumbPosition: Thumb, + evtType: K, + handler: SpecificEventListener, + ): void => { + this._delegate._getThumbElement(thumbPosition)?.removeEventListener(evtType, handler); + }; + registerInputEventHandler = ( + thumbPosition: Thumb, + evtType: K, + handler: SpecificEventListener, + ): void => { + if (evtType === 'change') { + this._saveChangeEventHandler(thumbPosition, handler as SpecificEventListener); } else { - // `touches` will be empty for start/end events so we have to fall back to `changedTouches`. - point = event.touches[0] || event.changedTouches[0]; + this._delegate._getInputElement(thumbPosition)?.addEventListener(evtType, handler); } - } else { - point = event; - } - - return point ? {x: point.clientX, y: point.clientY} : undefined; + }; + deregisterInputEventHandler = ( + thumbPosition: Thumb, + evtType: K, + handler: SpecificEventListener, + ): void => { + if (evtType === 'change') { + this._globalEventSubscriptions.unsubscribe(); + } else { + this._delegate._getInputElement(thumbPosition)?.removeEventListener(evtType, handler); + } + }; + registerBodyEventHandler = ( + evtType: K, + handler: SpecificEventListener, + ): void => { + this._delegate._document.body.addEventListener(evtType, handler); + }; + deregisterBodyEventHandler = ( + evtType: K, + handler: SpecificEventListener, + ): void => { + this._delegate._document.body.removeEventListener(evtType, handler); + }; + registerWindowEventHandler = ( + evtType: K, + handler: SpecificEventListener, + ): void => { + this._delegate._window.addEventListener(evtType, handler); + }; + deregisterWindowEventHandler = ( + evtType: K, + handler: SpecificEventListener, + ): void => { + this._delegate._window.removeEventListener(evtType, handler); + }; } -/** Finds a `Touch` with a specific ID in a `TouchList`. */ -function findMatchingTouch(touches: TouchList, id: number): Touch | undefined { - for (let i = 0; i < touches.length; i++) { - if (touches[i].identifier === id) { - return touches[i]; - } +/** Ensures that there is not an invalid configuration for the slider thumb inputs. */ +function _validateInputs( + isRange: boolean, + startInputElement: HTMLInputElement, + endInputElement: HTMLInputElement, +): void { + const startValid = !isRange || startInputElement.hasAttribute('matSliderStartThumb'); + const endValid = endInputElement.hasAttribute(isRange ? 'matSliderEndThumb' : 'matSliderThumb'); + + if (!startValid || !endValid) { + _throwInvalidInputConfigurationError(); } +} - return undefined; +/** Validates that the slider has the correct set of thumbs. */ +function _validateThumbs( + isRange: boolean, + start: MatSliderVisualThumb | undefined, + end: MatSliderVisualThumb | undefined, +): void { + if (!end && (!isRange || !start)) { + _throwInvalidInputConfigurationError(); + } } -/** Gets the unique ID of a touch that matches a specific slider. */ -function getTouchIdForSlider(event: TouchEvent, sliderHost: HTMLElement): number | undefined { - for (let i = 0; i < event.touches.length; i++) { - const target = event.touches[i].target as HTMLElement; +function _throwInvalidInputConfigurationError(): void { + throw Error(`Invalid slider thumb input configuration! - if (sliderHost === target || sliderHost.contains(target)) { - return event.touches[i].identifier; - } - } + Valid configurations are as follows: + + + + + + or - return undefined; + + + + + `); } diff --git a/src/material/slider/testing/BUILD.bazel b/src/material/slider/testing/BUILD.bazel index a0bcc1f00d09..358bf5846b6e 100644 --- a/src/material/slider/testing/BUILD.bazel +++ b/src/material/slider/testing/BUILD.bazel @@ -11,30 +11,29 @@ ts_library( deps = [ "//src/cdk/coercion", "//src/cdk/testing", + "//src/material/slider", ], ) -filegroup( - name = "source-files", - srcs = glob(["**/*.ts"]), -) - ng_test_library( name = "unit_tests_lib", - srcs = glob( - ["**/*.spec.ts"], - ), + srcs = glob(["**/*.spec.ts"]), deps = [ ":testing", "//src/cdk/testing", "//src/cdk/testing/testbed", "//src/material/slider", - "@npm//@angular/forms", - "@npm//@angular/platform-browser", ], ) ng_web_test_suite( name = "unit_tests", - deps = [":unit_tests_lib"], + deps = [ + ":unit_tests_lib", + ], +) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), ) diff --git a/src/material/slider/testing/public-api.ts b/src/material/slider/testing/public-api.ts index 608327c182fe..8c467ce19baf 100644 --- a/src/material/slider/testing/public-api.ts +++ b/src/material/slider/testing/public-api.ts @@ -7,4 +7,5 @@ */ export * from './slider-harness'; +export * from './slider-thumb-harness'; export * from './slider-harness-filters'; diff --git a/src/material/slider/testing/slider-harness-filters.ts b/src/material/slider/testing/slider-harness-filters.ts index 93d079d9ca10..27f07a3dea05 100644 --- a/src/material/slider/testing/slider-harness-filters.ts +++ b/src/material/slider/testing/slider-harness-filters.ts @@ -7,5 +7,20 @@ */ import {BaseHarnessFilters} from '@angular/cdk/testing'; +/** Possible positions of a slider thumb. */ +export const enum ThumbPosition { + START, + END, +} + /** A set of criteria that can be used to filter a list of `MatSliderHarness` instances. */ -export interface SliderHarnessFilters extends BaseHarnessFilters {} +export interface SliderHarnessFilters extends BaseHarnessFilters { + /** Filters out only range/non-range sliders. */ + isRange?: boolean; +} + +/** A set of criteria that can be used to filter a list of `MatSliderThumbHarness` instances. */ +export interface SliderThumbHarnessFilters extends BaseHarnessFilters { + /** Filters out slider thumbs with a particular position. */ + position?: ThumbPosition; +} diff --git a/src/material/slider/testing/slider-harness.spec.ts b/src/material/slider/testing/slider-harness.spec.ts index 01f568077b5f..3e023396646e 100644 --- a/src/material/slider/testing/slider-harness.spec.ts +++ b/src/material/slider/testing/slider-harness.spec.ts @@ -1,11 +1,21 @@ -import {HarnessLoader} from '@angular/cdk/testing'; -import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +/** + * @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.io/license + */ + import {Component} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {HarnessLoader, parallel} from '@angular/cdk/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; import {MatSliderModule} from '@angular/material/slider'; -import {MatSliderHarness} from '@angular/material/slider/testing/slider-harness'; +import {MatSliderHarness} from './slider-harness'; +import {MatSliderThumbHarness} from './slider-thumb-harness'; +import {ThumbPosition} from './slider-harness-filters'; -describe('Non-MDC-based MatSliderHarness', () => { +describe('MDC-based MatSliderHarness', () => { let fixture: ComponentFixture; let loader: HarnessLoader; @@ -22,168 +32,172 @@ describe('Non-MDC-based MatSliderHarness', () => { it('should load all slider harnesses', async () => { const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(sliders.length).toBe(3); + expect(sliders.length).toBe(2); }); - it('should load slider harness by id', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness.with({selector: '#my-slider'})); - expect(sliders.length).toBe(1); - }); - - it('should get id of slider', async () => { + it('should get whether is a range slider', async () => { const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await sliders[0].getId()).toBe(null); - expect(await sliders[1].getId()).toBe('my-slider'); - expect(await sliders[2].getId()).toBe(null); + expect(await parallel(() => sliders.map(slider => slider.isRange()))).toEqual([false, true]); }); - it('should get value of slider', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await sliders[0].getValue()).toBe(50); - expect(await sliders[1].getValue()).toBe(0); - expect(await sliders[2].getValue()).toBe(225); + it('should get whether a slider is disabled', async () => { + const slider = await loader.getHarness(MatSliderHarness); + expect(await slider.isDisabled()).toBe(false); + fixture.componentInstance.singleSliderDisabled = true; + expect(await slider.isDisabled()).toBe(true); }); - it('should get percentage of slider', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await sliders[0].getPercentage()).toBe(0.5); - expect(await sliders[1].getPercentage()).toBe(0); - expect(await sliders[2].getPercentage()).toBe(0.5); + it('should get the min/max values of a single-thumb slider', async () => { + const slider = await loader.getHarness(MatSliderHarness); + const [min, max] = await parallel(() => [slider.getMinValue(), slider.getMaxValue()]); + expect(min).toBe(0); + expect(max).toBe(100); }); - it('should get max value of slider', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await sliders[0].getMaxValue()).toBe(100); - expect(await sliders[1].getMaxValue()).toBe(100); - expect(await sliders[2].getMaxValue()).toBe(250); + it('should get the min/max values of a range slider', async () => { + const slider = await loader.getHarness(MatSliderHarness.with({isRange: true})); + const [min, max] = await parallel(() => [slider.getMinValue(), slider.getMaxValue()]); + expect(min).toBe(fixture.componentInstance.rangeSliderMin); + expect(max).toBe(fixture.componentInstance.rangeSliderMax); }); - it('should get min value of slider', async () => { + it('should get the thumbs within a slider', async () => { const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await sliders[0].getMinValue()).toBe(0); - expect(await sliders[1].getMinValue()).toBe(0); - expect(await sliders[2].getMinValue()).toBe(200); + expect(await sliders[0].getEndThumb()).toBeTruthy(); + expect(await sliders[1].getStartThumb()).toBeTruthy(); + expect(await sliders[1].getEndThumb()).toBeTruthy(); }); - it('should get display value of slider', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await sliders[0].getDisplayValue()).toBe(null); - expect(await sliders[1].getDisplayValue()).toBe('Null'); - expect(await sliders[2].getDisplayValue()).toBe('#225'); + it('should throw when trying to get the start thumb from a single point slider', async () => { + const slider = await loader.getHarness(MatSliderHarness.with({isRange: false})); + await expectAsync(slider.getStartThumb()).toBeRejectedWithError( + '`getStartThumb` is only applicable for range sliders. ' + + 'Did you mean to use `getEndThumb`?', + ); }); - it('should get orientation of slider', async () => { + it('should get the step of a slider', async () => { const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await sliders[0].getOrientation()).toBe('horizontal'); - expect(await sliders[1].getOrientation()).toBe('horizontal'); - expect(await sliders[2].getOrientation()).toBe('vertical'); + expect( + await parallel(() => { + return sliders.map(slider => slider.getStep()); + }), + ).toEqual([1, fixture.componentInstance.rangeSliderStep]); }); - it('should be able to focus slider', async () => { - // the first slider is disabled. - const slider = (await loader.getAllHarnesses(MatSliderHarness))[1]; - expect(await slider.isFocused()).toBe(false); - await slider.focus(); - expect(await slider.isFocused()).toBe(true); + it('should get the position of a slider thumb in a range slider', async () => { + const slider = await loader.getHarness(MatSliderHarness.with({selector: '#range'})); + const [start, end] = await parallel(() => [slider.getStartThumb(), slider.getEndThumb()]); + expect(await start.getPosition()).toBe(ThumbPosition.START); + expect(await end.getPosition()).toBe(ThumbPosition.END); }); - it('should be able to blur slider', async () => { - // the first slider is disabled. - const slider = (await loader.getAllHarnesses(MatSliderHarness))[1]; - expect(await slider.isFocused()).toBe(false); - await slider.focus(); - expect(await slider.isFocused()).toBe(true); - await slider.blur(); - expect(await slider.isFocused()).toBe(false); + it('should get the position of a slider thumb in a non-range slider', async () => { + const thumb = await loader.getHarness(MatSliderThumbHarness.with({ancestor: '#single'})); + expect(await thumb.getPosition()).toBe(ThumbPosition.END); }); - it('should be able to set value of slider', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await sliders[1].getValue()).toBe(0); - expect(await sliders[2].getValue()).toBe(225); - - await sliders[1].setValue(33); - await sliders[2].setValue(300); + it('should get and set the value of a slider thumb', async () => { + const slider = await loader.getHarness(MatSliderHarness); + const thumb = await slider.getEndThumb(); + expect(await thumb.getValue()).toBe(0); + await thumb.setValue(73); + expect(await thumb.getValue()).toBe(73); + }); - expect(await sliders[1].getValue()).toBe(33); - // value should be clamped to the maximum. - expect(await sliders[2].getValue()).toBe(250); + it('should dispatch input and change events when setting the value', async () => { + const slider = await loader.getHarness(MatSliderHarness); + const thumb = await slider.getEndThumb(); + const changeSpy = spyOn(fixture.componentInstance, 'changeListener'); + const inputSpy = spyOn(fixture.componentInstance, 'inputListener'); + await thumb.setValue(73); + expect(changeSpy).toHaveBeenCalledTimes(1); + expect(inputSpy).toHaveBeenCalledTimes(1); + expect(await thumb.getValue()).toBe(73); }); - it('should be able to set value of slider in rtl', async () => { + it('should get the value of a thumb as a percentage', async () => { const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await sliders[1].getValue()).toBe(0); - expect(await sliders[2].getValue()).toBe(225); - - // should not retrieve incorrect values in case slider is inverted - // due to RTL page layout. - fixture.componentInstance.dir = 'rtl'; - fixture.detectChanges(); - - await sliders[1].setValue(80); - expect(await sliders[1].getValue()).toBe(80); + expect(await (await sliders[0].getEndThumb()).getPercentage()).toBe(0); + expect(await (await sliders[1].getStartThumb()).getPercentage()).toBe(0.4); + expect(await (await sliders[1].getEndThumb()).getPercentage()).toBe(0.5); }); - it('should get disabled state of slider', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await sliders[0].isDisabled()).toBe(true); - expect(await sliders[1].isDisabled()).toBe(false); - expect(await sliders[2].isDisabled()).toBe(false); + it('should get the display value of a slider thumb', async () => { + const slider = await loader.getHarness(MatSliderHarness); + const thumb = await slider.getEndThumb(); + fixture.componentInstance.displayFn = value => `#${value}`; + await thumb.setValue(73); + expect(await thumb.getDisplayValue()).toBe('#73'); }); - it('should be able to set value of inverted slider', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await sliders[1].getValue()).toBe(0); - expect(await sliders[2].getValue()).toBe(225); + it('should get the min/max values of a slider thumb', async () => { + const instance = fixture.componentInstance; + const slider = await loader.getHarness(MatSliderHarness.with({selector: '#range'})); + const [start, end] = await parallel(() => [slider.getStartThumb(), slider.getEndThumb()]); - fixture.componentInstance.invertSliders = true; - fixture.detectChanges(); + expect(await start.getMinValue()).toBe(instance.rangeSliderMin); + expect(await start.getMaxValue()).toBe(instance.rangeSliderEndValue); + expect(await end.getMinValue()).toBe(instance.rangeSliderStartValue); + expect(await end.getMaxValue()).toBe(instance.rangeSliderMax); + }); - await sliders[1].setValue(75); - await sliders[2].setValue(210); + it('should get the disabled state of a slider thumb', async () => { + const slider = await loader.getHarness(MatSliderHarness); + const thumb = await slider.getEndThumb(); - expect(await sliders[1].getValue()).toBe(75); - expect(await sliders[2].getValue()).toBe(210); + expect(await thumb.isDisabled()).toBe(false); + fixture.componentInstance.singleSliderDisabled = true; + expect(await thumb.isDisabled()).toBe(true); }); - it('should be able to set value of inverted slider in rtl', async () => { - const sliders = await loader.getAllHarnesses(MatSliderHarness); - expect(await sliders[1].getValue()).toBe(0); - expect(await sliders[2].getValue()).toBe(225); + it('should get the name of a slider thumb', async () => { + const slider = await loader.getHarness(MatSliderHarness); + expect(await (await slider.getEndThumb()).getName()).toBe('price'); + }); - fixture.componentInstance.invertSliders = true; - fixture.componentInstance.dir = 'rtl'; - fixture.detectChanges(); + it('should get the id of a slider thumb', async () => { + const slider = await loader.getHarness(MatSliderHarness); + expect(await (await slider.getEndThumb()).getId()).toBe('price-input'); + }); - await sliders[1].setValue(75); - await sliders[2].setValue(210); + it('should be able to focus and blur a slider thumb', async () => { + const slider = await loader.getHarness(MatSliderHarness); + const thumb = await slider.getEndThumb(); - expect(await sliders[1].getValue()).toBe(75); - expect(await sliders[2].getValue()).toBe(210); + expect(await thumb.isFocused()).toBe(false); + await thumb.focus(); + expect(await thumb.isFocused()).toBe(true); + await thumb.blur(); + expect(await thumb.isFocused()).toBe(false); }); }); @Component({ template: ` - -
- -
- + + + + + + + - `, + `, }) class SliderHarnessTest { - sliderId = 'my-slider'; - invertSliders = false; - dir = 'ltr'; - - displayFn(value: number | null) { - if (!value) { - return 'Null'; - } - return `#${value}`; - } + singleSliderDisabled = false; + rangeSliderMin = 100; + rangeSliderMax = 500; + rangeSliderStep = 50; + rangeSliderStartValue = 200; + rangeSliderEndValue = 350; + displayFn = (value: number) => value + ''; + inputListener() {} + changeListener() {} } diff --git a/src/material/slider/testing/slider-harness.ts b/src/material/slider/testing/slider-harness.ts index 67228145d64c..f4bfdef83de4 100644 --- a/src/material/slider/testing/slider-harness.ts +++ b/src/material/slider/testing/slider-harness.ts @@ -6,130 +6,80 @@ * found in the LICENSE file at https://angular.io/license */ -import {ComponentHarness, HarnessPredicate, parallel} from '@angular/cdk/testing'; -import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; -import {SliderHarnessFilters} from './slider-harness-filters'; - -/** Harness for interacting with a standard mat-slider in tests. */ +import { + ComponentHarness, + ComponentHarnessConstructor, + HarnessPredicate, +} from '@angular/cdk/testing'; +import {coerceNumberProperty} from '@angular/cdk/coercion'; +import {SliderHarnessFilters, ThumbPosition} from './slider-harness-filters'; +import {MatSliderThumbHarness} from './slider-thumb-harness'; + +/** Harness for interacting with a MDC mat-slider in tests. */ export class MatSliderHarness extends ComponentHarness { - /** The selector for the host element of a `MatSlider` instance. */ - static hostSelector = '.mat-slider'; + static hostSelector = '.mat-mdc-slider'; /** - * Gets a `HarnessPredicate` that can be used to search for a `MatSliderHarness` that meets - * certain criteria. - * @param options Options for filtering which slider instances are considered a match. + * Gets a `HarnessPredicate` that can be used to search for a slider 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(options: SliderHarnessFilters = {}): HarnessPredicate { - return new HarnessPredicate(MatSliderHarness, options); + static with( + this: ComponentHarnessConstructor, + options: SliderHarnessFilters = {}, + ): HarnessPredicate { + return new HarnessPredicate(this, options).addOption( + 'isRange', + options.isRange, + async (harness, value) => { + return (await harness.isRange()) === value; + }, + ); } - private _textLabel = this.locatorFor('.mat-slider-thumb-label-text'); - private _wrapper = this.locatorFor('.mat-slider-wrapper'); + /** Gets the start thumb of the slider (only applicable for range sliders). */ + async getStartThumb(): Promise { + if (!(await this.isRange())) { + throw Error( + '`getStartThumb` is only applicable for range sliders. ' + + 'Did you mean to use `getEndThumb`?', + ); + } + return this.locatorFor(MatSliderThumbHarness.with({position: ThumbPosition.START}))(); + } - /** Gets the slider's id. */ - async getId(): Promise { - const id = await (await this.host()).getAttribute('id'); - // In case no id has been specified, the "id" property always returns - // an empty string. To make this method more explicit, we return null. - return id !== '' ? id : null; + /** Gets the thumb (for single point sliders), or the end thumb (for range sliders). */ + async getEndThumb(): Promise { + return this.locatorFor(MatSliderThumbHarness.with({position: ThumbPosition.END}))(); } - /** - * Gets the current display value of the slider. Returns a null promise if the thumb label is - * disabled. - */ - async getDisplayValue(): Promise { - const [host, textLabel] = await parallel(() => [this.host(), this._textLabel()]); - if (await host.hasClass('mat-slider-thumb-label-showing')) { - return textLabel.text(); - } - return null; + /** Gets whether the slider is a range slider. */ + async isRange(): Promise { + return await (await this.host()).hasClass('mdc-slider--range'); } - /** Gets the current percentage value of the slider. */ - async getPercentage(): Promise { - return this._calculatePercentage(await this.getValue()); + /** Gets whether the slider is disabled. */ + async isDisabled(): Promise { + return await (await this.host()).hasClass('mdc-slider--disabled'); } - /** Gets the current value of the slider. */ - async getValue(): Promise { - return coerceNumberProperty(await (await this.host()).getAttribute('aria-valuenow')); + /** Gets the value step increments of the slider. */ + async getStep(): Promise { + // The same step value is forwarded to both thumbs. + const startHost = await (await this.getEndThumb()).host(); + return coerceNumberProperty(await startHost.getProperty('step')); } /** Gets the maximum value of the slider. */ async getMaxValue(): Promise { - return coerceNumberProperty(await (await this.host()).getAttribute('aria-valuemax')); + return (await this.getEndThumb()).getMaxValue(); } /** Gets the minimum value of the slider. */ async getMinValue(): Promise { - return coerceNumberProperty(await (await this.host()).getAttribute('aria-valuemin')); - } - - /** Whether the slider is disabled. */ - async isDisabled(): Promise { - const disabled = (await this.host()).getAttribute('aria-disabled'); - return coerceBooleanProperty(await disabled); - } - - /** Gets the orientation of the slider. */ - async getOrientation(): Promise<'horizontal' | 'vertical'> { - // "aria-orientation" will always be set to either "horizontal" or "vertical". - return (await this.host()).getAttribute('aria-orientation') as any; - } - - /** - * Sets the value of the slider by clicking on the slider track. - * - * Note that in rare cases the value cannot be set to the exact specified value. This - * can happen if not every value of the slider maps to a single pixel that could be - * clicked using mouse interaction. In such cases consider using the keyboard to - * select the given value or expand the slider's size for a better user experience. - */ - async setValue(value: number): Promise { - const [sliderEl, wrapperEl, orientation] = await parallel(() => [ - this.host(), - this._wrapper(), - this.getOrientation(), - ]); - let percentage = await this._calculatePercentage(value); - const {height, width} = await wrapperEl.getDimensions(); - const isVertical = orientation === 'vertical'; - - // In case the slider is inverted in LTR mode or not inverted in RTL mode, - // we need to invert the percentage so that the proper value is set. - if (await sliderEl.hasClass('mat-slider-invert-mouse-coords')) { - percentage = 1 - percentage; - } - - // We need to round the new coordinates because creating fake DOM - // events will cause the coordinates to be rounded down. - const relativeX = isVertical ? 0 : Math.round(width * percentage); - const relativeY = isVertical ? Math.round(height * percentage) : 0; - - await wrapperEl.click(relativeX, relativeY); - } - - /** Focuses the slider. */ - async focus(): Promise { - return (await this.host()).focus(); - } - - /** Blurs the slider. */ - async blur(): Promise { - return (await this.host()).blur(); - } - - /** Whether the slider is focused. */ - async isFocused(): Promise { - return (await this.host()).isFocused(); - } - - /** Calculates the percentage of the given value. */ - private async _calculatePercentage(value: number) { - const [min, max] = await parallel(() => [this.getMinValue(), this.getMaxValue()]); - return (value - min) / (max - min); + const startThumb = (await this.isRange()) + ? await this.getStartThumb() + : await this.getEndThumb(); + return startThumb.getMinValue(); } } diff --git a/src/material-experimental/mdc-slider/testing/slider-thumb-harness.ts b/src/material/slider/testing/slider-thumb-harness.ts similarity index 100% rename from src/material-experimental/mdc-slider/testing/slider-thumb-harness.ts rename to src/material/slider/testing/slider-thumb-harness.ts diff --git a/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.ts b/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.ts index c00c81b74d5a..4e571e9263dc 100644 --- a/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.ts +++ b/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.ts @@ -11,7 +11,7 @@ import {MatChipsModule} from '@angular/material/chips'; import {MatMenuModule} from '@angular/material-experimental/mdc-menu'; import {MatRadioModule} from '@angular/material/radio'; import {MatSlideToggleModule} from '@angular/material/slide-toggle'; -import {MatSliderModule} from '@angular/material-experimental/mdc-slider'; +import {MatSliderModule} from '@angular/material/slider'; import {MatTabsModule} from '@angular/material-experimental/mdc-tabs'; import {MatTableModule} from '@angular/material-experimental/mdc-table'; import {MatDialog, MatDialogModule} from '@angular/material/dialog'; diff --git a/src/universal-app/kitchen-sink/kitchen-sink.ts b/src/universal-app/kitchen-sink/kitchen-sink.ts index 502b9533d450..99ab068d5eb1 100644 --- a/src/universal-app/kitchen-sink/kitchen-sink.ts +++ b/src/universal-app/kitchen-sink/kitchen-sink.ts @@ -25,7 +25,7 @@ import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; import {MatLegacyRadioModule} from '@angular/material/legacy-radio'; import {MatLegacySelectModule} from '@angular/material/legacy-select'; import {MatSidenavModule} from '@angular/material/sidenav'; -import {MatSliderModule} from '@angular/material/slider'; +import {MatLegacySliderModule} from '@angular/material/legacy-slider'; import {MatLegacySlideToggleModule} from '@angular/material/legacy-slide-toggle'; import {MatSnackBarModule, MatSnackBar} from '@angular/material/snack-bar'; import {MatTabsModule} from '@angular/material/tabs'; @@ -123,7 +123,7 @@ export class KitchenSink { MatRippleModule, MatLegacySelectModule, MatSidenavModule, - MatSliderModule, + MatLegacySliderModule, MatLegacySlideToggleModule, MatSnackBarModule, MatTabsModule, diff --git a/tools/public_api_guard/material/legacy-slider-testing.md b/tools/public_api_guard/material/legacy-slider-testing.md new file mode 100644 index 000000000000..3a0edd603f8e --- /dev/null +++ b/tools/public_api_guard/material/legacy-slider-testing.md @@ -0,0 +1,35 @@ +## API Report File for "components-srcs" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseHarnessFilters } from '@angular/cdk/testing'; +import { ComponentHarness } from '@angular/cdk/testing'; +import { HarnessPredicate } from '@angular/cdk/testing'; + +// @public +export class MatLegacySliderHarness extends ComponentHarness { + blur(): Promise; + focus(): Promise; + getDisplayValue(): Promise; + getId(): Promise; + getMaxValue(): Promise; + getMinValue(): Promise; + getOrientation(): Promise<'horizontal' | 'vertical'>; + getPercentage(): Promise; + getValue(): Promise; + static hostSelector: string; + isDisabled(): Promise; + isFocused(): Promise; + setValue(value: number): Promise; + static with(options?: SliderHarnessFilters): HarnessPredicate; +} + +// @public +export interface SliderHarnessFilters extends BaseHarnessFilters { +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/tools/public_api_guard/material/legacy-slider.md b/tools/public_api_guard/material/legacy-slider.md new file mode 100644 index 000000000000..2abc81305401 --- /dev/null +++ b/tools/public_api_guard/material/legacy-slider.md @@ -0,0 +1,126 @@ +## API Report File for "components-srcs" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { _AbstractConstructor } from '@angular/material/core'; +import { AfterViewInit } from '@angular/core'; +import { BooleanInput } from '@angular/cdk/coercion'; +import { CanColor } from '@angular/material/core'; +import { CanDisable } from '@angular/material/core'; +import { ChangeDetectorRef } from '@angular/core'; +import { _Constructor } from '@angular/material/core'; +import { ControlValueAccessor } from '@angular/forms'; +import { Directionality } from '@angular/cdk/bidi'; +import { ElementRef } from '@angular/core'; +import { EventEmitter } from '@angular/core'; +import { FocusMonitor } from '@angular/cdk/a11y'; +import { HasTabIndex } from '@angular/material/core'; +import * as i0 from '@angular/core'; +import * as i2 from '@angular/common'; +import * as i3 from '@angular/material/core'; +import { NgZone } from '@angular/core'; +import { NumberInput } from '@angular/cdk/coercion'; +import { OnDestroy } from '@angular/core'; + +// @public +export const MAT_SLIDER_VALUE_ACCESSOR: any; + +// @public +export class MatLegacySlider extends _MatSliderBase implements ControlValueAccessor, OnDestroy, CanDisable, CanColor, AfterViewInit, HasTabIndex { + constructor(elementRef: ElementRef, _focusMonitor: FocusMonitor, _changeDetectorRef: ChangeDetectorRef, _dir: Directionality, tabIndex: string, _ngZone: NgZone, _document: any, _animationMode?: string | undefined); + // (undocumented) + _animationMode?: string | undefined; + blur(): void; + readonly change: EventEmitter; + get displayValue(): string | number; + displayWith: (value: number) => string | number; + protected _document: Document; + focus(options?: FocusOptions): void; + // (undocumented) + _getThumbContainerStyles(): { + [key: string]: string; + }; + _getThumbGap(): 7 | 10 | 0; + _getTicksContainerStyles(): { + [key: string]: string; + }; + _getTicksStyles(): { + [key: string]: string; + }; + _getTrackBackgroundStyles(): { + [key: string]: string; + }; + _getTrackFillStyles(): { + [key: string]: string; + }; + readonly input: EventEmitter; + get invert(): boolean; + set invert(value: BooleanInput); + _isActive: boolean; + _isMinValue(): boolean; + _isSliding: 'keyboard' | 'pointer' | null; + get max(): number; + set max(v: NumberInput); + get min(): number; + set min(v: NumberInput); + // (undocumented) + ngAfterViewInit(): void; + // (undocumented) + ngOnDestroy(): void; + // (undocumented) + _onBlur(): void; + // (undocumented) + _onFocus(): void; + // (undocumented) + _onKeydown(event: KeyboardEvent): void; + // (undocumented) + _onKeyup(): void; + // (undocumented) + _onMouseenter(): void; + onTouched: () => any; + get percent(): number; + registerOnChange(fn: (value: any) => void): void; + registerOnTouched(fn: any): void; + setDisabledState(isDisabled: boolean): void; + _shouldInvertAxis(): boolean; + _shouldInvertMouseCoords(): boolean; + get step(): number; + set step(v: NumberInput); + get thumbLabel(): boolean; + set thumbLabel(value: BooleanInput); + get tickInterval(): 'auto' | number; + set tickInterval(value: 'auto' | NumberInput); + get value(): number; + set value(v: NumberInput); + readonly valueChange: EventEmitter; + valueText: string; + get vertical(): boolean; + set vertical(value: BooleanInput); + writeValue(value: any): void; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public +export class MatLegacySliderChange { + source: MatLegacySlider; + value: number | null; +} + +// @public (undocumented) +export class MatLegacySliderModule { + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵinj: i0.ɵɵInjectorDeclaration; + // (undocumented) + static ɵmod: i0.ɵɵNgModuleDeclaration; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/tools/public_api_guard/material/slider-testing.md b/tools/public_api_guard/material/slider-testing.md index 42182cd983fb..b12014a4c42a 100644 --- a/tools/public_api_guard/material/slider-testing.md +++ b/tools/public_api_guard/material/slider-testing.md @@ -6,28 +6,59 @@ import { BaseHarnessFilters } from '@angular/cdk/testing'; import { ComponentHarness } from '@angular/cdk/testing'; +import { ComponentHarnessConstructor } from '@angular/cdk/testing'; import { HarnessPredicate } from '@angular/cdk/testing'; // @public export class MatSliderHarness extends ComponentHarness { + getEndThumb(): Promise; + getMaxValue(): Promise; + getMinValue(): Promise; + getStartThumb(): Promise; + getStep(): Promise; + // (undocumented) + static hostSelector: string; + isDisabled(): Promise; + isRange(): Promise; + static with(this: ComponentHarnessConstructor, options?: SliderHarnessFilters): HarnessPredicate; +} + +// @public +export class MatSliderThumbHarness extends ComponentHarness { blur(): Promise; focus(): Promise; - getDisplayValue(): Promise; - getId(): Promise; + getDisplayValue(): Promise; + getId(): Promise; getMaxValue(): Promise; getMinValue(): Promise; - getOrientation(): Promise<'horizontal' | 'vertical'>; + getName(): Promise; getPercentage(): Promise; + getPosition(): Promise; getValue(): Promise; + // (undocumented) static hostSelector: string; isDisabled(): Promise; isFocused(): Promise; - setValue(value: number): Promise; - static with(options?: SliderHarnessFilters): HarnessPredicate; + setValue(newValue: number): Promise; + static with(this: ComponentHarnessConstructor, options?: SliderThumbHarnessFilters): HarnessPredicate; } // @public export interface SliderHarnessFilters extends BaseHarnessFilters { + isRange?: boolean; +} + +// @public +export interface SliderThumbHarnessFilters extends BaseHarnessFilters { + position?: ThumbPosition; +} + +// @public +export const enum ThumbPosition { + // (undocumented) + END = 1, + // (undocumented) + START = 0 } // (No @packageDocumentation comment for this package) diff --git a/tools/public_api_guard/material/slider.md b/tools/public_api_guard/material/slider.md index 13e64fe331cb..1c1c197d23ac 100644 --- a/tools/public_api_guard/material/slider.md +++ b/tools/public_api_guard/material/slider.md @@ -8,59 +8,60 @@ import { _AbstractConstructor } from '@angular/material/core'; import { AfterViewInit } from '@angular/core'; import { BooleanInput } from '@angular/cdk/coercion'; import { CanColor } from '@angular/material/core'; -import { CanDisable } from '@angular/material/core'; +import { CanDisableRipple } from '@angular/material/core'; import { ChangeDetectorRef } from '@angular/core'; import { _Constructor } from '@angular/material/core'; import { ControlValueAccessor } from '@angular/forms'; import { Directionality } from '@angular/cdk/bidi'; import { ElementRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; -import { FocusMonitor } from '@angular/cdk/a11y'; -import { HasTabIndex } from '@angular/material/core'; import * as i0 from '@angular/core'; -import * as i2 from '@angular/common'; -import * as i3 from '@angular/material/core'; +import * as i2 from '@angular/material/core'; +import * as i3 from '@angular/common'; import { NgZone } from '@angular/core'; import { NumberInput } from '@angular/cdk/coercion'; import { OnDestroy } from '@angular/core'; +import { OnInit } from '@angular/core'; +import { Platform } from '@angular/cdk/platform'; +import { QueryList } from '@angular/core'; +import { RippleGlobalOptions } from '@angular/material/core'; +import { SpecificEventListener } from '@material/base'; +import { Subscription } from 'rxjs'; +import { Thumb } from '@material/slider'; +import { TickMark } from '@material/slider'; // @public -export const MAT_SLIDER_VALUE_ACCESSOR: any; - -// @public -export class MatSlider extends _MatSliderBase implements ControlValueAccessor, OnDestroy, CanDisable, CanColor, AfterViewInit, HasTabIndex { - constructor(elementRef: ElementRef, _focusMonitor: FocusMonitor, _changeDetectorRef: ChangeDetectorRef, _dir: Directionality, tabIndex: string, _ngZone: NgZone, _document: any, _animationMode?: string | undefined); +export class MatSlider extends _MatSliderMixinBase implements AfterViewInit, CanDisableRipple, OnDestroy { + constructor(_ngZone: NgZone, _cdr: ChangeDetectorRef, elementRef: ElementRef, _platform: Platform, _globalChangeAndInputListener: GlobalChangeAndInputListener<'input' | 'change'>, document: any, _dir: Directionality, _globalRippleOptions?: RippleGlobalOptions | undefined, animationMode?: string); + _attachUISyncEventListener(): void; // (undocumented) - _animationMode?: string | undefined; - blur(): void; - readonly change: EventEmitter; - get displayValue(): string | number; - displayWith: (value: number) => string | number; - protected _document: Document; - focus(options?: FocusOptions): void; - // (undocumented) - _getThumbContainerStyles(): { - [key: string]: string; - }; - _getThumbGap(): 7 | 10 | 0; - _getTicksContainerStyles(): { - [key: string]: string; - }; - _getTicksStyles(): { - [key: string]: string; - }; - _getTrackBackgroundStyles(): { - [key: string]: string; - }; - _getTrackFillStyles(): { - [key: string]: string; - }; - readonly input: EventEmitter; - get invert(): boolean; - set invert(value: BooleanInput); - _isActive: boolean; - _isMinValue(): boolean; - _isSliding: 'keyboard' | 'pointer' | null; + readonly _cdr: ChangeDetectorRef; + get disabled(): boolean; + set disabled(v: BooleanInput); + get discrete(): boolean; + set discrete(v: BooleanInput); + displayWith: (value: number) => string; + _document: Document; + _endValueIndicatorText: string; + _getHostDimensions(): DOMRect; + _getInput(thumbPosition: Thumb): MatSliderThumb; + _getInputElement(thumbPosition: Thumb): HTMLInputElement; + _getKnobElement(thumbPosition: Thumb): HTMLElement; + // (undocumented) + _getThumb(thumbPosition: Thumb): MatSliderVisualThumb; + _getThumbElement(thumbPosition: Thumb): HTMLElement; + _getTickMarkClass(tickMark: TickMark): string; + _getValueIndicatorContainerElement(thumbPosition: Thumb): HTMLElement; + _getValueIndicatorText(thumbPosition: Thumb): string; + // (undocumented) + readonly _globalChangeAndInputListener: GlobalChangeAndInputListener<'input' | 'change'>; + // (undocumented) + readonly _globalRippleOptions?: RippleGlobalOptions | undefined; + _initialized: boolean; + _inputs: QueryList; + _isRange(): boolean; + _isRippleDisabled(): boolean; + _isRTL(): boolean; get max(): number; set max(v: NumberInput); get min(): number; @@ -70,55 +71,106 @@ export class MatSlider extends _MatSliderBase implements ControlValueAccessor, O // (undocumented) ngOnDestroy(): void; // (undocumented) - _onBlur(): void; + readonly _ngZone: NgZone; + _noopAnimations: boolean; + _removeUISyncEventListener(): void; + _setValue(value: number, thumbPosition: Thumb): void; + _setValueIndicatorText(value: number, thumbPosition: Thumb): void; + get showTickMarks(): boolean; + set showTickMarks(v: BooleanInput); + _startValueIndicatorText: string; + get step(): number; + set step(v: NumberInput); + _thumbs: QueryList; + _tickMarks: TickMark[]; + _trackActive: ElementRef; + _updateDisabled(): void; + _window: Window; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public +export interface MatSliderDragEvent { + parent: MatSlider; + source: MatSliderThumb; + value: number; +} + +// @public (undocumented) +export class MatSliderModule { + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵinj: i0.ɵɵInjectorDeclaration; // (undocumented) - _onFocus(): void; + static ɵmod: i0.ɵɵNgModuleDeclaration; +} + +// @public +export class MatSliderThumb implements AfterViewInit, ControlValueAccessor, OnInit, OnDestroy { + constructor(document: any, _slider: MatSlider, _elementRef: ElementRef); // (undocumented) - _onKeydown(event: KeyboardEvent): void; + blur(): void; + readonly _blur: EventEmitter; + _disabled: boolean; + readonly dragEnd: EventEmitter; + readonly dragStart: EventEmitter; + // (undocumented) + _emitFakeEvent(type: 'change' | 'input'): void; // (undocumented) - _onKeyup(): void; + focus(): void; + readonly _focus: EventEmitter; + _hostElement: HTMLInputElement; + _initializeInputState(): void; + _isFocused(): boolean; // (undocumented) - _onMouseenter(): void; - onTouched: () => any; - get percent(): number; - registerOnChange(fn: (value: any) => void): void; + ngAfterViewInit(): void; + // (undocumented) + ngOnDestroy(): void; + // (undocumented) + ngOnInit(): void; + // (undocumented) + _onBlur(): void; + _onChange: (value: any) => void; + registerOnChange(fn: any): void; registerOnTouched(fn: any): void; setDisabledState(isDisabled: boolean): void; - _shouldInvertAxis(): boolean; - _shouldInvertMouseCoords(): boolean; - get step(): number; - set step(v: NumberInput); - get thumbLabel(): boolean; - set thumbLabel(value: BooleanInput); - get tickInterval(): 'auto' | number; - set tickInterval(value: 'auto' | NumberInput); + _thumbPosition: Thumb; get value(): number; set value(v: NumberInput); - readonly valueChange: EventEmitter; - valueText: string; - get vertical(): boolean; - set vertical(value: BooleanInput); + readonly valueChange: EventEmitter; writeValue(value: any): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public -export class MatSliderChange { - source: MatSlider; - value: number | null; -} - -// @public (undocumented) -export class MatSliderModule { +class MatSliderVisualThumb implements AfterViewInit, OnDestroy { + constructor(_ngZone: NgZone, _slider: MatSlider, _elementRef: ElementRef); + disableRipple: boolean; + discrete: boolean; + _getHostElement(): HTMLElement; + _getKnob(): HTMLElement; + _getValueIndicatorContainer(): HTMLElement; + readonly _isActive = false; + _isShortValue(): boolean; + _knob: ElementRef; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + ngAfterViewInit(): void; // (undocumented) - static ɵinj: i0.ɵɵInjectorDeclaration; + ngOnDestroy(): void; + thumbPosition: Thumb; + _valueIndicatorContainer: ElementRef; + valueIndicatorText: string; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // (No @packageDocumentation comment for this package)