From aad4260ce8d73ccbf9ccd7ec2f6298c033943c40 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Mon, 12 Aug 2019 17:59:23 +0200 Subject: [PATCH 1/2] feat(material-experimental): initial boilerplate for mdc-slider prototype --- .../mdc-slider/BUILD.bazel | 57 +++++++++++++++++++ .../mdc-slider/README.md | 1 + .../mdc-slider/_mdc-slider.scss | 13 +++++ src/material-experimental/mdc-slider/index.ts | 9 +++ .../mdc-slider/module.ts | 20 +++++++ .../mdc-slider/public-api.ts | 10 ++++ .../mdc-slider/slider.e2e.spec.ts | 1 + .../mdc-slider/slider.html | 1 + .../mdc-slider/slider.scss | 1 + .../mdc-slider/slider.ts | 25 ++++++++ .../mdc-slider/tsconfig-build.json | 15 +++++ 11 files changed, 153 insertions(+) create mode 100644 src/material-experimental/mdc-slider/BUILD.bazel create mode 100644 src/material-experimental/mdc-slider/README.md create mode 100644 src/material-experimental/mdc-slider/_mdc-slider.scss create mode 100644 src/material-experimental/mdc-slider/index.ts create mode 100644 src/material-experimental/mdc-slider/module.ts create mode 100644 src/material-experimental/mdc-slider/public-api.ts create mode 100644 src/material-experimental/mdc-slider/slider.e2e.spec.ts create mode 100644 src/material-experimental/mdc-slider/slider.html create mode 100644 src/material-experimental/mdc-slider/slider.scss create mode 100644 src/material-experimental/mdc-slider/slider.ts create mode 100644 src/material-experimental/mdc-slider/tsconfig-build.json diff --git a/src/material-experimental/mdc-slider/BUILD.bazel b/src/material-experimental/mdc-slider/BUILD.bazel new file mode 100644 index 000000000000..6b02da5c7bf4 --- /dev/null +++ b/src/material-experimental/mdc-slider/BUILD.bazel @@ -0,0 +1,57 @@ +package(default_visibility = ["//visibility:public"]) + +load("@io_bazel_rules_sass//:defs.bzl", "sass_binary", "sass_library") +load("//tools:defaults.bzl", "ng_e2e_test_library", "ng_module", "ng_web_test_suite") +load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") + +ng_module( + name = "mdc-slider", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + assets = [":slider_scss"] + glob(["**/*.html"]), + module_name = "@angular/material-experimental/mdc-slider", + deps = [ + "//src/material/core", + ], +) + +sass_library( + name = "slider_scss_lib", + srcs = glob(["**/_*.scss"]), + deps = [ + "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", + "//src/material/core:core_scss_lib", + ], +) + +sass_binary( + name = "slider_scss", + src = "slider.scss", +) + +ng_web_test_suite( + name = "unit_tests", + static_files = ["@npm//:node_modules/@material/slider/dist/mdc.slider.js"], + deps = [ + # TODO: add slider tests target here. + "//src/material-experimental:mdc_require_config.js", + ], +) + +ng_e2e_test_library( + name = "e2e_test_sources", + srcs = glob(["**/*.e2e.spec.ts"]), + deps = [ + "//src/cdk/private/testing/e2e", + ], +) + +e2e_test_suite( + name = "e2e_tests", + deps = [ + ":e2e_test_sources", + "//src/cdk/private/testing/e2e", + ], +) diff --git a/src/material-experimental/mdc-slider/README.md b/src/material-experimental/mdc-slider/README.md new file mode 100644 index 000000000000..e06c65305e48 --- /dev/null +++ b/src/material-experimental/mdc-slider/README.md @@ -0,0 +1 @@ +This is a placeholder for the MDC-based implementation of slider. diff --git a/src/material-experimental/mdc-slider/_mdc-slider.scss b/src/material-experimental/mdc-slider/_mdc-slider.scss new file mode 100644 index 000000000000..be5e0b8e21c9 --- /dev/null +++ b/src/material-experimental/mdc-slider/_mdc-slider.scss @@ -0,0 +1,13 @@ +@import '../mdc-helpers/mdc-helpers'; + +@mixin mat-slider-theme-mdc($theme) { + @include mat-using-mdc-theme($theme) { + // TODO: MDC theme styles here. + } +} + +@mixin mat-slider-typography-mdc($config) { + @include mat-using-mdc-typography($config) { + // TODO: MDC typography styles here. + } +} diff --git a/src/material-experimental/mdc-slider/index.ts b/src/material-experimental/mdc-slider/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/material-experimental/mdc-slider/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; diff --git a/src/material-experimental/mdc-slider/module.ts b/src/material-experimental/mdc-slider/module.ts new file mode 100644 index 000000000000..3c8571c94867 --- /dev/null +++ b/src/material-experimental/mdc-slider/module.ts @@ -0,0 +1,20 @@ +/** + * @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 {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {MatCommonModule} from '@angular/material/core'; +import {MatSlider} from './slider'; + +@NgModule({ + imports: [MatCommonModule, CommonModule], + exports: [MatSlider, MatCommonModule], + declarations: [MatSlider], +}) +export class MatSliderModule { +} diff --git a/src/material-experimental/mdc-slider/public-api.ts b/src/material-experimental/mdc-slider/public-api.ts new file mode 100644 index 000000000000..e6307b9bf1c3 --- /dev/null +++ b/src/material-experimental/mdc-slider/public-api.ts @@ -0,0 +1,10 @@ +/** + * @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 + */ + +export * from './slider'; +export * from './module'; diff --git a/src/material-experimental/mdc-slider/slider.e2e.spec.ts b/src/material-experimental/mdc-slider/slider.e2e.spec.ts new file mode 100644 index 000000000000..84fece5af776 --- /dev/null +++ b/src/material-experimental/mdc-slider/slider.e2e.spec.ts @@ -0,0 +1 @@ +// TODO: copy tests from existing mat-slider, update as necessary to fix. diff --git a/src/material-experimental/mdc-slider/slider.html b/src/material-experimental/mdc-slider/slider.html new file mode 100644 index 000000000000..9c75ddb6f477 --- /dev/null +++ b/src/material-experimental/mdc-slider/slider.html @@ -0,0 +1 @@ + diff --git a/src/material-experimental/mdc-slider/slider.scss b/src/material-experimental/mdc-slider/slider.scss new file mode 100644 index 000000000000..f61450904c9a --- /dev/null +++ b/src/material-experimental/mdc-slider/slider.scss @@ -0,0 +1 @@ +// TODO: MDC core styles here. diff --git a/src/material-experimental/mdc-slider/slider.ts b/src/material-experimental/mdc-slider/slider.ts new file mode 100644 index 000000000000..6b87ff51c63d --- /dev/null +++ b/src/material-experimental/mdc-slider/slider.ts @@ -0,0 +1,25 @@ +/** + * @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 {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; + +@Component({ + moduleId: module.id, + selector: 'mat-slider', + templateUrl: 'slider.html', + styleUrls: ['slider.css'], + host: { + 'class': 'mat-mdc-slider', + }, + exportAs: 'matSlider', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MatSlider { + // TODO: set up MDC foundation class. +} diff --git a/src/material-experimental/mdc-slider/tsconfig-build.json b/src/material-experimental/mdc-slider/tsconfig-build.json new file mode 100644 index 000000000000..12c1b10199ff --- /dev/null +++ b/src/material-experimental/mdc-slider/tsconfig-build.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig-build", + "files": [ + "public-api.ts", + "../typings.d.ts" + ], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/material-experimental/mdc-slider", + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true + } +} From f1266fa6240067178a513003c05b31384b499f4f Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Thu, 29 Aug 2019 20:41:20 +0200 Subject: [PATCH 2/2] prototype(slider): create prototype slider based on MDC web --- .github/CODEOWNERS | 2 +- packages.bzl | 1 + src/dev-app/BUILD.bazel | 1 + src/dev-app/dev-app/dev-app-layout.ts | 1 + src/dev-app/dev-app/routes.ts | 1 + src/dev-app/mdc-slider/BUILD.bazel | 17 + .../mdc-slider/mdc-slider-demo-module.ts | 26 + src/dev-app/mdc-slider/mdc-slider-demo.html | 53 + src/dev-app/mdc-slider/mdc-slider-demo.ts | 23 + src/dev-app/system-config.js | 1 + src/dev-app/theme.scss | 4 + src/e2e-app/BUILD.bazel | 1 + .../mdc-helpers/_mdc-helpers.scss | 2 + .../mdc-slider/BUILD.bazel | 38 +- .../mdc-slider/_mdc-slider.scss | 15 +- .../mdc-slider/harness/BUILD.bazel | 9 +- .../mdc-slider/slider.e2e.spec.ts | 1 - .../mdc-slider/slider.html | 14 +- .../mdc-slider/slider.scss | 41 +- .../mdc-slider/slider.spec.ts | 1441 +++++++++++++++++ .../mdc-slider/slider.ts | 499 +++++- test/karma-system-config.js | 2 + 22 files changed, 2172 insertions(+), 21 deletions(-) create mode 100644 src/dev-app/mdc-slider/BUILD.bazel create mode 100644 src/dev-app/mdc-slider/mdc-slider-demo-module.ts create mode 100644 src/dev-app/mdc-slider/mdc-slider-demo.html create mode 100644 src/dev-app/mdc-slider/mdc-slider-demo.ts create mode 100644 src/material-experimental/mdc-slider/slider.spec.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5fb4301ace66..1a7c3934049a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -101,7 +101,6 @@ # Note to implementer: please repossess /src/material-experimental/mdc-radio/** @mmalerba /src/material-experimental/mdc-slide-toggle/** @crisbeto -# Note to implementer: please repossess /src/material-experimental/mdc-slider/** @devversion /src/material-experimental/mdc-tabs/** @crisbeto /src/material-experimental/mdc-sidenav/** @crisbeto @@ -161,6 +160,7 @@ # Note to implementer: please repossess /src/dev-app/mdc-radio/** @mmalerba /src/dev-app/mdc-slide-toggle/** @crisbeto +/src/dev-app/mdc-slider/** @devversion /src/dev-app/mdc-tabs/** @crisbeto /src/dev-app/menu/** @crisbeto /src/dev-app/overlay/** @jelbourn @crisbeto diff --git a/packages.bzl b/packages.bzl index 05d1a51fa0f5..e898b620181b 100644 --- a/packages.bzl +++ b/packages.bzl @@ -96,6 +96,7 @@ MATERIAL_EXPERIMENTAL_PACKAGES = [ "mdc-menu", "mdc-radio", "mdc-slide-toggle", + "mdc-slider", "popover-edit", ] diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 9dc6e77dcf3b..e42b19002426 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -48,6 +48,7 @@ ng_module( "//src/dev-app/mdc-menu", "//src/dev-app/mdc-radio", "//src/dev-app/mdc-slide-toggle", + "//src/dev-app/mdc-slider", "//src/dev-app/mdc-tabs", "//src/dev-app/menu", "//src/dev-app/paginator", diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 303810a7bd80..9660cdf6627c 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -78,6 +78,7 @@ export class DevAppLayout { {name: 'MDC Radio', route: '/mdc-radio'}, {name: 'MDC Tabs', route: '/mdc-tabs'}, {name: 'MDC Slide Toggle', route: '/mdc-slide-toggle'}, + {name: 'MDC Slider', route: '/mdc-slider'}, ]; diff --git a/src/dev-app/dev-app/routes.ts b/src/dev-app/dev-app/routes.ts index fa69bc52d078..a47c03867c4a 100644 --- a/src/dev-app/dev-app/routes.ts +++ b/src/dev-app/dev-app/routes.ts @@ -62,6 +62,7 @@ export const DEV_APP_ROUTES: Routes = [ path: 'mdc-slide-toggle', loadChildren: 'mdc-slide-toggle/mdc-slide-toggle-demo-module#MdcSlideToggleDemoModule' }, + {path: 'mdc-slider', loadChildren: 'mdc-slider/mdc-slider-demo-module#MdcSliderDemoModule'}, {path: 'mdc-tabs', loadChildren: 'mdc-tabs/mdc-tabs-demo-module#MdcTabsDemoModule'}, {path: 'menu', loadChildren: 'menu/menu-demo-module#MenuDemoModule'}, {path: 'paginator', loadChildren: 'paginator/paginator-demo-module#PaginatorDemoModule'}, diff --git a/src/dev-app/mdc-slider/BUILD.bazel b/src/dev-app/mdc-slider/BUILD.bazel new file mode 100644 index 000000000000..71a368ad0c9e --- /dev/null +++ b/src/dev-app/mdc-slider/BUILD.bazel @@ -0,0 +1,17 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ng_module") + +ng_module( + name = "mdc-slider", + srcs = glob(["**/*.ts"]), + assets = [ + "mdc-slider-demo.html", + ], + deps = [ + "//src/material-experimental/mdc-slider", + "//src/material/tabs", + "@npm//@angular/forms", + "@npm//@angular/router", + ], +) diff --git a/src/dev-app/mdc-slider/mdc-slider-demo-module.ts b/src/dev-app/mdc-slider/mdc-slider-demo-module.ts new file mode 100644 index 000000000000..2cb5e12dd01f --- /dev/null +++ b/src/dev-app/mdc-slider/mdc-slider-demo-module.ts @@ -0,0 +1,26 @@ +/** + * @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 {NgModule} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {MatSliderModule} from '@angular/material-experimental/mdc-slider'; +import {MatTabsModule} from '@angular/material/tabs'; +import {RouterModule} from '@angular/router'; +import {MdcSliderDemo} from './mdc-slider-demo'; + +@NgModule({ + imports: [ + FormsModule, + MatSliderModule, + MatTabsModule, + RouterModule.forChild([{path: '', component: MdcSliderDemo}]), + ], + declarations: [MdcSliderDemo], +}) +export class MdcSliderDemoModule { +} diff --git a/src/dev-app/mdc-slider/mdc-slider-demo.html b/src/dev-app/mdc-slider/mdc-slider-demo.html new file mode 100644 index 000000000000..bb8e3de17870 --- /dev/null +++ b/src/dev-app/mdc-slider/mdc-slider-demo.html @@ -0,0 +1,53 @@ +

Default Slider

+Label +{{slidey.value}} + +

Colors

+ + + + +

Slider with Min and Max

+ + + +{{slider2.value}} + + +

Disabled Slider

+ + + + +

Slider with set value

+ + +

Slider with step defined

+ +{{slider5.value}} + +

Slider with set tick interval

+ + + +

Slider with Thumb Label

+ + +

Slider with one-way binding

+ + + +

Slider with two-way binding

+ + + +

Set/lost focus to show thumblabel programmatically

+ + + + + + + + + diff --git a/src/dev-app/mdc-slider/mdc-slider-demo.ts b/src/dev-app/mdc-slider/mdc-slider-demo.ts new file mode 100644 index 000000000000..90d54a71843f --- /dev/null +++ b/src/dev-app/mdc-slider/mdc-slider-demo.ts @@ -0,0 +1,23 @@ +/** + * @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'; + + +@Component({ + moduleId: module.id, + selector: 'mdc-slider-demo', + templateUrl: 'mdc-slider-demo.html', +}) +export class MdcSliderDemo { + demo: number; + val: number = 50; + min: number = 0; + max: number = 100; + disabledValue = 0; +} diff --git a/src/dev-app/system-config.js b/src/dev-app/system-config.js index b63c346991c8..8b76753a6239 100644 --- a/src/dev-app/system-config.js +++ b/src/dev-app/system-config.js @@ -67,6 +67,7 @@ const MATERIAL_EXPERIMENTAL_PACKAGES = [ 'mdc-menu', 'mdc-radio', 'mdc-slide-toggle', + 'mdc-slider', 'popover-edit', ]; diff --git a/src/dev-app/theme.scss b/src/dev-app/theme.scss index caea3ce31050..e57892f42391 100644 --- a/src/dev-app/theme.scss +++ b/src/dev-app/theme.scss @@ -7,6 +7,7 @@ @import '../material-experimental/mdc-menu/mdc-menu'; @import '../material-experimental/mdc-radio/mdc-radio'; @import '../material-experimental/mdc-slide-toggle/mdc-slide-toggle'; +@import '../material-experimental/mdc-slider/mdc-slider'; @import '../material-experimental/mdc-tabs/mdc-tabs'; @import '../material-experimental/popover-edit/popover-edit'; @@ -25,6 +26,7 @@ @include mat-menu-typography-mdc(mat-typography-config()); @include mat-radio-typography-mdc(mat-typography-config()); @include mat-slide-toggle-typography-mdc(mat-typography-config()); +@include mat-slider-typography-mdc(mat-typography-config()); @include mat-tabs-typography-mdc(mat-typography-config()); // Define the default theme (same as the example above). @@ -43,6 +45,7 @@ $candy-app-theme: mat-light-theme($candy-app-primary, $candy-app-accent); @include mat-menu-theme-mdc($candy-app-theme); @include mat-radio-theme-mdc($candy-app-theme); @include mat-slide-toggle-theme-mdc($candy-app-theme); +@include mat-slider-theme-mdc($candy-app-theme); @include mat-tabs-theme-mdc($candy-app-theme); @include mat-edit-theme($candy-app-theme); @include mat-edit-typography(mat-typography-config()); @@ -67,6 +70,7 @@ $dark-theme: mat-dark-theme($dark-primary, $dark-accent, $dark-warn); @include mat-menu-theme-mdc($dark-theme); @include mat-radio-theme-mdc($dark-theme); @include mat-slide-toggle-theme-mdc($dark-theme); + @include mat-slider-theme-mdc($dark-theme); @include mat-tabs-theme-mdc($dark-theme); @include mat-edit-theme($dark-theme); } diff --git a/src/e2e-app/BUILD.bazel b/src/e2e-app/BUILD.bazel index 69e9adac4264..b8606a31f4af 100644 --- a/src/e2e-app/BUILD.bazel +++ b/src/e2e-app/BUILD.bazel @@ -8,6 +8,7 @@ load("//:packages.bzl", "ANGULAR_LIBRARY_UMDS") exports_files([ "protractor.conf.js", "start-devserver.js", + "devserver-configure.js", ]) ng_module( diff --git a/src/material-experimental/mdc-helpers/_mdc-helpers.scss b/src/material-experimental/mdc-helpers/_mdc-helpers.scss index 7ec68c9fe6fe..d5991fce2e0e 100644 --- a/src/material-experimental/mdc-helpers/_mdc-helpers.scss +++ b/src/material-experimental/mdc-helpers/_mdc-helpers.scss @@ -11,6 +11,8 @@ // A set of standard queries to use with MDC's queryable mixins. $mat-base-styles-query: mdc-feature-without(mdc-feature-any(color, typography)); +$mat-base-styles-without-animation-query: + mdc-feature-all($mat-base-styles-query, mdc-feature-without(animation)); $mat-theme-styles-query: color; $mat-typography-styles-query: typography; diff --git a/src/material-experimental/mdc-slider/BUILD.bazel b/src/material-experimental/mdc-slider/BUILD.bazel index 6b02da5c7bf4..520a026372d4 100644 --- a/src/material-experimental/mdc-slider/BUILD.bazel +++ b/src/material-experimental/mdc-slider/BUILD.bazel @@ -1,7 +1,7 @@ package(default_visibility = ["//visibility:public"]) load("@io_bazel_rules_sass//:defs.bzl", "sass_binary", "sass_library") -load("//tools:defaults.bzl", "ng_e2e_test_library", "ng_module", "ng_web_test_suite") +load("//tools:defaults.bzl", "ng_e2e_test_library", "ng_module", "ng_test_library", "ng_web_test_suite") load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") ng_module( @@ -14,29 +14,59 @@ ng_module( module_name = "@angular/material-experimental/mdc-slider", deps = [ "//src/material/core", + "@npm//@angular/forms", + "@npm//@material/slider", ], ) sass_library( - name = "slider_scss_lib", + name = "mdc_slider_scss_lib", srcs = glob(["**/_*.scss"]), deps = [ "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", - "//src/material/core:core_scss_lib", ], ) sass_binary( name = "slider_scss", src = "slider.scss", + include_paths = [ + "external/npm/node_modules", + ], + deps = [ + "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", + "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib", + ], +) + +########### +# 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", + "@npm//@angular/forms", + "@npm//@angular/platform-browser", + ], ) ng_web_test_suite( name = "unit_tests", static_files = ["@npm//:node_modules/@material/slider/dist/mdc.slider.js"], deps = [ - # TODO: add slider tests target here. + ":slider_tests_lib", "//src/material-experimental:mdc_require_config.js", + "//src/material-experimental/mdc-slider/harness:tests_lib", ], ) diff --git a/src/material-experimental/mdc-slider/_mdc-slider.scss b/src/material-experimental/mdc-slider/_mdc-slider.scss index be5e0b8e21c9..34cf4cc1ace7 100644 --- a/src/material-experimental/mdc-slider/_mdc-slider.scss +++ b/src/material-experimental/mdc-slider/_mdc-slider.scss @@ -1,13 +1,24 @@ @import '../mdc-helpers/mdc-helpers'; +@import '@material/slider/mixins'; @mixin mat-slider-theme-mdc($theme) { @include mat-using-mdc-theme($theme) { - // TODO: MDC theme styles here. + @include mdc-slider-core-styles($query: $mat-theme-styles-query); + + .mat-mdc-slider { + &.mat-primary { + @include mdc-slider-color-accessible(primary, $mat-theme-styles-query); + } + + &.mat-warn { + @include mdc-slider-color-accessible(error, $mat-theme-styles-query); + } + } } } @mixin mat-slider-typography-mdc($config) { @include mat-using-mdc-typography($config) { - // TODO: MDC typography styles here. + @include mdc-slider-core-styles($query: $mat-typography-styles-query); } } diff --git a/src/material-experimental/mdc-slider/harness/BUILD.bazel b/src/material-experimental/mdc-slider/harness/BUILD.bazel index 7284f64ca7eb..fd28fde6bc19 100644 --- a/src/material-experimental/mdc-slider/harness/BUILD.bazel +++ b/src/material-experimental/mdc-slider/harness/BUILD.bazel @@ -1,6 +1,6 @@ package(default_visibility = ["//visibility:public"]) -load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library") +load("//tools:defaults.bzl", "ng_test_library", "ts_library") ts_library( name = "harness", @@ -15,7 +15,7 @@ ts_library( ) ng_test_library( - name = "harness_tests", + name = "tests_lib", srcs = glob(["**/*.spec.ts"]), deps = [ ":harness", @@ -24,8 +24,3 @@ ng_test_library( "//src/material/slider", ], ) - -ng_web_test_suite( - name = "tests", - deps = [":harness_tests"], -) diff --git a/src/material-experimental/mdc-slider/slider.e2e.spec.ts b/src/material-experimental/mdc-slider/slider.e2e.spec.ts index 84fece5af776..e69de29bb2d1 100644 --- a/src/material-experimental/mdc-slider/slider.e2e.spec.ts +++ b/src/material-experimental/mdc-slider/slider.e2e.spec.ts @@ -1 +0,0 @@ -// TODO: copy tests from existing mat-slider, update as necessary to fix. diff --git a/src/material-experimental/mdc-slider/slider.html b/src/material-experimental/mdc-slider/slider.html index 9c75ddb6f477..34e6d790725c 100644 --- a/src/material-experimental/mdc-slider/slider.html +++ b/src/material-experimental/mdc-slider/slider.html @@ -1 +1,13 @@ - +
+
+
+
+
+
+ {{displayValue}} +
+ + + +
+
diff --git a/src/material-experimental/mdc-slider/slider.scss b/src/material-experimental/mdc-slider/slider.scss index f61450904c9a..f73a5e56079d 100644 --- a/src/material-experimental/mdc-slider/slider.scss +++ b/src/material-experimental/mdc-slider/slider.scss @@ -1 +1,40 @@ -// TODO: MDC core styles here. +@import '@material/slider/mixins'; +@import '../mdc-helpers/mdc-helpers'; + +$mat-slider-min-size: 128px !default; +$mat-slider-horizontal-margin: 8px !default; + +@include mdc-slider-core-styles($query: $mat-base-styles-without-animation-query); + +// 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); +} + +// In order to make it possible for developers to disable animations for a slider, +// we only activate the MDC slider animation styles if animations are enabled. +.mat-mdc-slider:not(._mat-animation-noopable) { + @include mdc-slider-core-styles($query: animation); +} + +// Sliders without a thumb label (aka non-discrete) currently cannot have ticks +// enabled. This breaks backwards compatibility with the standard Angular Material +// slider, so we manually ensure that ticks can be rendered. +.mat-slider-has-ticks:not(.mat-slider-disabled) .mdc-slider__track-marker-container { + visibility: visible; +} diff --git a/src/material-experimental/mdc-slider/slider.spec.ts b/src/material-experimental/mdc-slider/slider.spec.ts new file mode 100644 index 000000000000..9814d2a6e45d --- /dev/null +++ b/src/material-experimental/mdc-slider/slider.spec.ts @@ -0,0 +1,1441 @@ +import {BidiModule} from '@angular/cdk/bidi'; +import { + BACKSPACE, + DOWN_ARROW, + END, + HOME, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + UP_ARROW, +} from '@angular/cdk/keycodes'; +import {Platform} from '@angular/cdk/platform'; +import { + createKeyboardEvent, + createMouseEvent, + dispatchEvent, + dispatchFakeEvent, + dispatchKeyboardEvent, + dispatchMouseEvent, +} from '@angular/cdk/testing'; +import {Component, DebugElement, Type, ViewChild} from '@angular/core'; +import {ComponentFixture, fakeAsync, flush, TestBed, tick} from '@angular/core/testing'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {By} from '@angular/platform-browser'; +import {MatSlider, MatSliderModule} from './index'; + +describe('MatMdcSlider', () => { + const platform = new Platform(); + + function createComponent(component: Type): ComponentFixture { + TestBed.configureTestingModule({ + imports: [MatSliderModule, ReactiveFormsModule, FormsModule, BidiModule], + declarations: [component], + }).compileComponents(); + + return TestBed.createComponent(component); + } + + describe('standard slider', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatSlider; + + beforeEach(() => { + fixture = createComponent(StandardSlider); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + }); + + 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); + }); + + // TODO(devversion): MDC slider updates values with right mouse button. + // tslint:disable-next-line + xit('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 not change value without emitting a change event', () => { + const onChangeSpy = jasmine.createSpy('slider onChange'); + + sliderInstance.change.subscribe(onChangeSpy); + sliderInstance.value = 50; + fixture.detectChanges(); + + dispatchSlideEventSequence(sliderNativeElement, 0, 0.1); + + expect(onChangeSpy).toHaveBeenCalledTimes(1); + }); + + 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); + }); + + }); + + describe('disabled slider', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatSlider; + + beforeEach(() => { + fixture = createComponent(DisabledSlider); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + }); + + 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 disable tabbing to the slider', () => { + expect(sliderNativeElement.hasAttribute('tabindex')).toBe(false); + // The "tabIndex" property returns an incorrect value in Edge 17. + // See: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/4365703/ + if (!platform.EDGE) { + expect(sliderNativeElement.tabIndex).toBe(-1); + } + }); + }); + + describe('slider with set min and max', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatSlider; + let thumbContainerEl: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = createComponent(SliderWithMinAndMax); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MatSlider); + thumbContainerEl = sliderNativeElement + .querySelector('.mdc-slider__thumb-container'); + + // Flush the "requestAnimationFrame" timer that performs the initial + // rendering of the MDC slider. + flushRequestAnimationFrame(); + })); + + 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. + let 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. + let value = Math.round(4 + (0.62 * (6 - 4))); + expect(sliderInstance.value).toBe(value); + }); + + it('should snap the fill to the nearest value on mousedown', fakeAsync(() => { + dispatchMousedownEventSequence(sliderNativeElement, 0.68); + fixture.detectChanges(); + flushRequestAnimationFrame(); + + // The closest snap is halfway on the slider. + expect(thumbContainerEl.style.transform).toContain('translateX(50px)'); + })); + + it('should snap the fill to the nearest value on slide', fakeAsync(() => { + dispatchSlideEventSequence(sliderNativeElement, 0, 0.74); + fixture.detectChanges(); + flushRequestAnimationFrame(); + + // The closest snap is at the halfway point on the slider. + expect(thumbContainerEl.style.transform).toContain('translateX(50px)'); + })); + }); + + describe('slider with set value', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatSlider; + + beforeEach(() => { + fixture = createComponent(SliderWithValue); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MatSlider); + }); + + 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: MatSlider; + let thumbContainerEl: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = createComponent(SliderWithStep); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MatSlider); + thumbContainerEl = sliderNativeElement + .querySelector('.mdc-slider__thumb-container'); + + // Flush the "requestAnimationFrame" timer that performs the initial + // rendering of the MDC slider. + flushRequestAnimationFrame(); + })); + + 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 set the correct step value on keydown', () => { + expect(sliderInstance.value).toBe(0); + + dispatchKeyboardEvent(sliderNativeElement, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(25); + }); + + it('should snap the fill to a step on mousedown', fakeAsync(() => { + dispatchMousedownEventSequence(sliderNativeElement, 0.66); + fixture.detectChanges(); + flushRequestAnimationFrame(); + + // The closest step is at 75% of the slider. + expect(thumbContainerEl.style.transform).toContain('translateX(75px)'); + })); + + 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', fakeAsync(() => { + dispatchSlideEventSequence(sliderNativeElement, 0, 0.88); + fixture.detectChanges(); + flushRequestAnimationFrame(); + + // The closest snap is at the end of the slider. + expect(thumbContainerEl.style.transform).toContain('translateX(100px)'); + })); + + 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'); + }); + + // TODO(devversion): MDC slider does not support decimal steps. + // tslint:disable-next-line + xit('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); + }); + }); + + describe('slider with set tick interval', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let ticksContainerElement: HTMLElement; + + beforeEach(() => { + fixture = createComponent(SliderWithSetTickInterval); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + ticksContainerElement = + sliderNativeElement.querySelector('.mdc-slider__track-marker-container'); + }); + + it('should set the correct tick separation', () => { + const step = 3; + const tickInterval = fixture.componentInstance.tickInterval; + // Since the step value is set to "3", a slider with a maximum of 100 will have + // (100/3) visual steps. Of those visual steps, only each 6th (tickInterval) visual + // step will have a tick on the track. Resulting in ((100/3)/6) ticks on the track. + const sizeOfTick = (100 / step) / tickInterval; + // Similarly this equals to 18% of a 100px track as every 18th (3 * 6) + // pixel will be a tick. + const ticksPerTrackPercentage = (tickInterval * step); + // iOS evaluates the "background" expression for the ticks to the exact number, + // Firefox, Edge, Safari evaluate to a percentage value, and Chrome evaluates to + // a rounded five-digit decimal number. + const expectationRegex = new RegExp( + `(${sizeOfTick}|${ticksPerTrackPercentage}%|${sizeOfTick.toFixed(5)})`); + expect(ticksContainerElement.style.background) + .toMatch(expectationRegex); + }); + + it('should be able to reset the tick interval after it has been set', () => { + expect(sliderNativeElement.classList) + .toContain('mat-slider-has-ticks', 'Expected element to have ticks initially.'); + + 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: MatSlider; + let thumbLabelTextElement: Element; + + beforeEach(() => { + fixture = createComponent(SliderWithThumbLabel); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + thumbLabelTextElement = sliderNativeElement.querySelector('.mdc-slider__pin-value-marker')!; + }); + + 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 sliderNativeElement: HTMLElement; + let thumbLabelTextElement: Element; + + beforeEach(() => { + fixture = createComponent(SliderWithCustomThumbLabelFormatting); + fixture.detectChanges(); + + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + thumbLabelTextElement = sliderNativeElement.querySelector('.mdc-slider__pin-value-marker')!; + }); + + it('should invoke the passed-in `displayWith` function with the value', () => { + spyOn(fixture.componentInstance, 'displayWith').and.callThrough(); + + dispatchMousedownEventSequence(sliderNativeElement, 0); + fixture.detectChanges(); + + expect(fixture.componentInstance.displayWith).toHaveBeenCalledWith(1); + }); + + // TODO(devversion): MDC does not refresh value pin if value changes programmatically. + // tslint:disable-next-line + xit('should format the thumb label based on the passed-in `displayWith` function if value ' + + 'is updated through binding', () => { + fixture.componentInstance.value = 200000; + fixture.detectChanges(); + + expect(thumbLabelTextElement.textContent).toBe('200k'); + }); + + it('should format the thumb label based on the passed-in `displayWith` function', () => { + dispatchMousedownEventSequence(sliderNativeElement, 1); + fixture.detectChanges(); + + expect(thumbLabelTextElement.textContent).toBe('100k'); + }); + }); + + describe('slider with value property binding', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatSlider; + let testComponent: SliderWithOneWayBinding; + let thumbContainerEl: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = createComponent(SliderWithOneWayBinding); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MatSlider); + thumbContainerEl = sliderNativeElement + .querySelector('.mdc-slider__thumb-container') as HTMLElement; + + // Flush the "requestAnimationFrame" timer that performs the initial + // rendering of the MDC slider. + flushRequestAnimationFrame(); + })); + + it('should initialize based on bound value', () => { + expect(sliderInstance.value).toBe(50); + expect(thumbContainerEl.style.transform).toContain('translateX(50px)'); + }); + + it('should update when bound value changes', fakeAsync(() => { + testComponent.val = 75; + fixture.detectChanges(); + flushRequestAnimationFrame(); + + expect(sliderInstance.value).toBe(75); + expect(thumbContainerEl.style.transform).toContain('translateX(75px)'); + })); + }); + + 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 thumbContainerEl: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = createComponent(SliderWithValueSmallerThanMin); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + thumbContainerEl = sliderNativeElement + .querySelector('.mdc-slider__thumb-container'); + + // Flush the "requestAnimationFrame" timer that performs the initial + // rendering of the MDC slider. + flushRequestAnimationFrame(); + })); + + 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(thumbContainerEl.style.transform).toContain('translateX(0px)'); + }); + }); + + 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 thumbContainerEl: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = createComponent(SliderWithValueGreaterThanMax); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + thumbContainerEl = sliderNativeElement + .querySelector('.mdc-slider__thumb-container'); + + // Flush the "requestAnimationFrame" timer that performs the initial + // rendering of the MDC slider. + flushRequestAnimationFrame(); + })); + + 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(thumbContainerEl.style.transform).toContain('translateX(100px)'); + }); + }); + + 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(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + }); + + it('should emit change on mousedown', () => { + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchMousedownEventSequence(sliderNativeElement, 0.2); + fixture.detectChanges(); + + 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); + }); + + // TODO(devversion): MDC slider always emits change event on mouseup (regardless of value) + // Bug tracked with: https://github.com/material-components/material-components-web/issues/5018 + // tslint:disable-next-line + xit('should not emit multiple changes for 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).toHaveBeenCalledTimes(1); + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + + testComponent.value = 0; + fixture.detectChanges(); + + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + + dispatchMousedownEventSequence(sliderNativeElement, 0.2); + fixture.detectChanges(); + + 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(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + }); + + it('should emit an input event while sliding', () => { + expect(testComponent.onChange).not.toHaveBeenCalled(); + + dispatchSliderMouseEvent(sliderNativeElement, 'mousedown', 0); + dispatchSliderMouseEvent(sliderNativeElement, 'mousemove', 0.5); + dispatchSliderMouseEvent(sliderNativeElement, 'mousemove', 1); + dispatchSliderMouseEvent(sliderNativeElement, 'mouseup', 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(); + + // The `onInput` event should be emitted once due to a single click. + expect(testComponent.onInput).toHaveBeenCalledTimes(1); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + }); + + }); + + describe('slider with auto ticks', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let ticksContainerElement: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = createComponent(SliderWithAutoTickInterval); + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + ticksContainerElement = + sliderNativeElement.querySelector('.mdc-slider__track-marker-container'); + + flushRequestAnimationFrame(); + })); + + it('should set the correct tick separation', () => { + expect(ticksContainerElement.style.background).toContain('30px'); + }); + }); + + describe('keyboard support', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let testComponent: SliderWithChangeHandler; + let sliderInstance: MatSlider; + + 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; + sliderInstance = sliderDebugElement.injector.get(MatSlider); + }); + + 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', () => { + fixture.componentInstance.value = 100; + fixture.detectChanges(); + + 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', () => { + fixture.componentInstance.value = 100; + fixture.detectChanges(); + + 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); + }); + + // TODO(devversion): MDC increments the slider by "4" on page up. The standard + // Material slider increments by "10". + it('should increment slider by 4 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(4); + }); + + // TODO(devversion): MDC decrements the slider by "4" on page up. The standard + // Material slider decrements by "10". + it('should decrement slider by 4 on page down pressed', () => { + fixture.componentInstance.value = 100; + fixture.detectChanges(); + + 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(96); + }); + + 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', () => { + fixture.componentInstance.value = 100; + fixture.detectChanges(); + + 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`, () => { + fixture.componentInstance.value = 50; + fixture.detectChanges(); + + 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); + }); + + // TODO: MDC slider does not respect modifier keys. + // tslint:disable-next-line + xit('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); + Object.defineProperty(event, 'altKey', {get: () => 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', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MatSlider; + let testComponent: SliderWithDir; + + beforeEach(() => { + fixture = createComponent(SliderWithDir); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.injector.get(MatSlider); + sliderNativeElement = sliderDebugElement.nativeElement; + }); + + it('works in RTL languages', fakeAsync(() => { + testComponent.dir = 'rtl'; + fixture.detectChanges(); + flushRequestAnimationFrame(); + + dispatchMousedownEventSequence(sliderNativeElement, 0.3); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(70); + })); + + it('should re-render slider with updated style upon directionality change', fakeAsync(() => { + testComponent.dir = 'rtl'; + fixture.detectChanges(); + flushRequestAnimationFrame(); + + const thumbContainerEl = sliderNativeElement + .querySelector('.mdc-slider__thumb-container'); + + expect(thumbContainerEl.style.transform).toContain('translateX(100px)'); + + testComponent.dir = 'ltr'; + fixture.detectChanges(); + flushRequestAnimationFrame(); + + expect(thumbContainerEl.style.transform).toContain('translateX(0px)'); + })); + + it('should decrement RTL slider by 1 on right arrow pressed', () => { + testComponent.dir = 'rtl'; + testComponent.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); + }); + }); + + describe('tabindex', () => { + + it('should allow setting the tabIndex through binding', () => { + const fixture = createComponent(SliderWithTabIndexBinding); + fixture.detectChanges(); + + const sliderNativeEl = fixture.debugElement.query(By.directive(MatSlider)).nativeElement; + expect(sliderNativeEl.tabIndex).toBe(0, 'Expected the tabIndex to be set to 0 by default.'); + + fixture.componentInstance.tabIndex = 3; + fixture.detectChanges(); + + expect(sliderNativeEl.tabIndex).toBe(3, 'Expected the tabIndex to have been changed.'); + }); + + it('should detect the native tabindex attribute', () => { + const fixture = createComponent(SliderWithNativeTabindexAttr); + fixture.detectChanges(); + + const slider = fixture.debugElement.query(By.directive(MatSlider)).componentInstance; + + expect(slider.tabIndex) + .toBe(5, 'Expected the tabIndex to be set to the value of the native attribute.'); + }); + }); + + 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(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + }); + + it('should update the model on mousedown', () => { + expect(testComponent.val).toBe(0); + + dispatchMousedownEventSequence(sliderNativeElement, 0.76); + fixture.detectChanges(); + + 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: MatSlider; + let testComponent: SliderWithFormControl; + + beforeEach(() => { + fixture = createComponent(SliderWithFormControl); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MatSlider); + }); + + 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 mousedown', () => { + expect(testComponent.control.value).toBe(0); + + dispatchMousedownEventSequence(sliderNativeElement, 0.76); + fixture.detectChanges(); + + 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', () => { + 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. + dispatchMousedownEventSequence(sliderNativeElement, 0.5); + fixture.detectChanges(); + + 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. + dispatchFakeEvent(sliderNativeElement, '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; + 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); + dispatchMouseEvent(sliderNativeElement, 'mouseup'); + fixture.detectChanges(); + + 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); + }); + }); + +}); + +function flushRequestAnimationFrame() { + // Flush the "requestAnimationFrame" timer that performs the rendering of + // the MDC slider. Zone uses 16ms for "requestAnimationFrame". + tick(16); +} + +// Disable animations and make the slider an even 100px, so that we get nice +// round values in tests. +const styles = ` + .mat-mdc-slider { min-width: 100px !important; } +`; + +@Component({ + template: ``, + styles: [styles], +}) +class StandardSlider { } + +@Component({ + template: ``, + styles: [styles], +}) +class DisabledSlider { } + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithMinAndMax { + min = 4; + max = 6; +} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithValue { } + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithStep { + step = 25; + max = 100; +} + +@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 { + value = 0; + + displayWith(value: number | null) { + if (!value) { + return 0; + } + + 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(MatSlider, {static: false}) slider: MatSlider; + val: number | undefined = 0; +} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithValueSmallerThanMin { } + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithValueGreaterThanMax { } + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithChangeHandler { + value = 0; + onChange() { } + onInput() { } + + @ViewChild(MatSlider, {static: false}) slider: MatSlider; +} + +@Component({ + template: `
`, + styles: [styles], +}) +class SliderWithDir { + value = 0; + dir = 'ltr'; +} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithTabIndexBinding { + tabIndex: number; +} + +@Component({ + template: ``, + styles: [styles], +}) +class SliderWithNativeTabindexAttr { + tabIndex: number; +} + +@Component({ + template: '', + styles: [styles], +}) +class SliderWithTwoWayBinding { + @ViewChild(MatSlider, {static: false}) slider: MatSlider; + value = 0; +} + +/** + * Dispatches a mousedown event sequence (consisting of mousedown, mouseup) 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 { + dispatchSliderMouseEvent(sliderElement, 'mousedown', percentage, button); + dispatchSliderMouseEvent(sliderElement, 'mouseup', percentage, 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 { + dispatchSliderMouseEvent(sliderElement, 'mousedown', startPercent); + dispatchSliderMouseEvent(sliderElement, 'mousemove', startPercent); + dispatchSliderMouseEvent(sliderElement, 'mousemove', endPercent); + dispatchSliderMouseEvent(sliderElement, 'mouseup', endPercent); +} + +/** + * Dispatches a mouse event from an element at a given position based on the percentage. + * @param sliderElement The mat-slider element from which the event will be dispatched. + * @param type Type of the mouse event that should be dispatched. + * @param percent The percentage of the slider where the event will happen. + * @param button Button that should be held for this event. + */ +function dispatchSliderMouseEvent(sliderElement: HTMLElement, type: string, percent: number, + button = 0): void { + let trackElement = sliderElement.querySelector('.mdc-slider__track-container')!; + let dimensions = trackElement.getBoundingClientRect(); + let x = dimensions.left + (dimensions.width * percent); + let y = dimensions.top + (dimensions.height * percent); + + dispatchEvent(sliderElement, createMouseEvent(type, x, y, 0)); +} diff --git a/src/material-experimental/mdc-slider/slider.ts b/src/material-experimental/mdc-slider/slider.ts index 6b87ff51c63d..7396823d33ad 100644 --- a/src/material-experimental/mdc-slider/slider.ts +++ b/src/material-experimental/mdc-slider/slider.ts @@ -6,7 +6,70 @@ * found in the LICENSE file at https://angular.io/license */ -import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; +import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; +import {normalizePassiveListenerOptions} from '@angular/cdk/platform'; +import { + AfterViewInit, + Attribute, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + forwardRef, + Inject, + Input, + NgZone, + OnChanges, + OnDestroy, + Optional, + Output, + SimpleChanges, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; +import {ThemePalette} from '@angular/material/core'; +import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; +import {MDCSliderAdapter, MDCSliderFoundation} from '@material/slider'; +import {Subscription} from 'rxjs'; + +/** + * 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; + +/** + * Size of a tick marker for a slider. The size of a tick is based on the Material + * Design guidelines and the MDC slider implementation. + * TODO(devversion): ideally MDC would expose the tick marker size as constant + */ +const TICK_MARKER_SIZE = 2; + +/** Options to pass to the slider interaction listeners. */ +const listenerOptions = normalizePassiveListenerOptions({passive: true}); + +/** + * 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; +} @Component({ moduleId: module.id, @@ -14,12 +77,440 @@ import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/co templateUrl: 'slider.html', styleUrls: ['slider.css'], host: { - 'class': 'mat-mdc-slider', + // The standard Angular Material slider has the capability to dynamically toggle + // whether tick markers should show or not. Therefore we need to make sure that + // the MDC slider foundation is able to render tick markers. We dynamically toggle + // them based on the specified "tickInterval" input. + 'class': 'mat-mdc-slider mdc-slider mdc-slider--display-markers', + 'role': 'slider', + 'aria-orientation': 'horizontal', + // The tabindex if the slider turns disabled is managed by the MDC foundation which + // dynamically updates and restores the "tabindex" attribute. + '[attr.tabindex]': 'tabIndex || 0', + '[class.mdc-slider--discrete]': 'thumbLabel', + '[class.mat-slider-has-ticks]': 'tickInterval !== 0', + '[class.mat-slider-thumb-label-showing]': 'thumbLabel', + '[class.mat-slider-disabled]': 'disabled', + '[class.mat-primary]': 'color == "primary"', + '[class.mat-accent]': 'color == "accent"', + '[class.mat-warn]': 'color == "warn"', + '[class._mat-animation-noopable]': '_animationMode === "NoopAnimations"', + '(blur)': '_markAsTouched()', }, exportAs: 'matSlider', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, + providers: [MAT_SLIDER_VALUE_ACCESSOR], }) -export class MatSlider { - // TODO: set up MDC foundation class. +export class MatSlider implements AfterViewInit, OnChanges, OnDestroy, ControlValueAccessor { + /** 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(); + + /** Tabindex for the slider. */ + @Input() tabIndex: number = 0; + + /** The color palette for this slider. */ + @Input() color: ThemePalette = 'accent'; + + /** + * 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; + + /** The minimum value that the slider can have. */ + @Input() + get min(): number { + return this._min; + } + set min(value: number) { + this._min = coerceNumberProperty(value); + } + private _min = 0; + + /** The maximum value that the slider can have. */ + @Input() + get max(): number { + return this._max; + } + set max(value: number) { + this._max = coerceNumberProperty(value); + } + private _max = 100; + + /** Value of the slider. */ + @Input() + get value(): number|null { + // If the value needs to be read and it is still uninitialized, initialize + // it to the current minimum value. + if (this._value === null) { + this.value = this.min; + } + return this._value; + } + set value(value: number|null) { + this._value = coerceNumberProperty(value); + } + private _value: number|null = null; + + /** The values at which the thumb will snap. */ + @Input() + get step(): number { + return this._step; + } + set step(v: number) { + this._step = coerceNumberProperty(v, this._step); + } + private _step: number = 1; + + /** + * 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() { + return this._tickInterval; + } + set tickInterval(value: number|'auto') { + if (value === 'auto') { + this._tickInterval = 'auto'; + } else if (typeof value === 'number' || typeof value === 'string') { + this._tickInterval = coerceNumberProperty(value, this._tickInterval); + } else { + this._tickInterval = 0; + } + } + private _tickInterval: number|'auto' = 0; + + /** Whether or not to show the thumb label. */ + @Input() + get thumbLabel(): boolean { + return this._thumbLabel; + } + set thumbLabel(value: boolean) { + this._thumbLabel = coerceBooleanProperty(value); + } + private _thumbLabel: boolean = false; + + /** Whether the slider is disabled. */ + @Input() + get disabled(): boolean { + return this._disabled; + } + set disabled(disabled) { + this._disabled = coerceBooleanProperty(disabled); + } + private _disabled = false; + + /** Adapter for the MDC slider foundation. */ + private _sliderAdapter: MDCSliderAdapter = { + hasClass: (className) => this._elementRef.nativeElement.classList.contains(className), + addClass: (className) => this._elementRef.nativeElement.classList.add(className), + removeClass: (className) => this._elementRef.nativeElement.classList.remove(className), + getAttribute: (name) => this._elementRef.nativeElement.getAttribute(name), + setAttribute: (name, value) => this._elementRef.nativeElement.setAttribute(name, value), + removeAttribute: (name) => this._elementRef.nativeElement.removeAttribute(name), + computeBoundingRect: () => this._elementRef.nativeElement.getBoundingClientRect(), + getTabIndex: () => this._elementRef.nativeElement.tabIndex, + registerInteractionHandler: (evtType, handler) => + // Interaction event handlers (which handle keyboard interaction) cannot be passive + // as they will prevent the default behavior. Additionally we can't run these event + // handlers outside of the Angular zone because we rely on the events to cause the + // component tree to be re-checked. + this._elementRef.nativeElement.addEventListener(evtType, handler), + deregisterInteractionHandler: (evtType, handler) => + this._elementRef.nativeElement.removeEventListener(evtType, handler), + registerThumbContainerInteractionHandler: + (evtType, handler) => { + // The thumb container interaction handlers are currently just used for transition + // events which don't need to run in the Angular zone. + this._ngZone.runOutsideAngular(() => { + this._thumbContainer.nativeElement.addEventListener(evtType, handler, listenerOptions); + }); + }, + deregisterThumbContainerInteractionHandler: + (evtType, handler) => { + this._thumbContainer.nativeElement.removeEventListener(evtType, handler, listenerOptions); + }, + registerBodyInteractionHandler: (evtType, handler) => + // Body event handlers (which handle thumb sliding) cannot be passive as they will + // prevent the default behavior. Additionally we can't run these event handlers + // outside of the Angular zone because we rely on the events to cause the component + // tree to be re-checked. + document.body.addEventListener(evtType, handler), + deregisterBodyInteractionHandler: (evtType, handler) => + document.body.removeEventListener(evtType, handler), + registerResizeHandler: + (handler) => { + // The resize handler is currently responsible for detecting slider dimension + // changes and therefore doesn't cause a value change that needs to be propagated. + this._ngZone.runOutsideAngular(() => { + window.addEventListener('resize', handler, listenerOptions); + }); + }, + deregisterResizeHandler: (handler) => + window.removeEventListener('resize', handler, listenerOptions), + notifyInput: + () => { + const newValue = this._foundation.getValue(); + // MDC currently fires the input event multiple times. + // TODO(devversion): remove this check once the input notifications are fixed. + if (newValue !== this.value) { + this.value = newValue; + this.input.emit(this._createChangeEvent(newValue)); + } + }, + notifyChange: + () => { + // TODO(devversion): bug in MDC where only the "change" event is emitted if a keypress + // updated the value. Material and native range sliders also emit an input event. + // Usually we sync the "value" in the "input" event, but as a workaround we now sync + // the value in the "change" event. + this.value = this._foundation.getValue(); + this._emitChangeEvent(this.value!); + }, + setThumbContainerStyleProperty: + (propertyName, value) => { + this._thumbContainer.nativeElement.style.setProperty(propertyName, value); + }, + setTrackStyleProperty: + (propertyName, value) => { + this._track.nativeElement.style.setProperty(propertyName, value); + }, + setMarkerValue: + () => { + // Mark the component for check as the thumb label needs to be re-rendered. + this._changeDetectorRef.markForCheck(); + }, + setTrackMarkers: + (step, max, min) => { + this._trackMarker.nativeElement.style.setProperty( + 'background', this._getTrackMarkersBackground(min, max, step)); + }, + isRTL: () => this._dir && this._dir.value === 'rtl', + }; + + /** Instance of the MDC slider foundation for this slider. */ + private _foundation = new MDCSliderFoundation(this._sliderAdapter); + + /** Whether the MDC foundation has been initialized. */ + private _isInitialized = false; + + /** Function that notifies the control value accessor about a value change. */ + private _controlValueAccessorChangeFn: (value: number) => void = () => {}; + + /** Subscription to the Directionality change EventEmitter. */ + private _dirChangeSubscription = Subscription.EMPTY; + + /** Function that marks the slider as touched. Registered via "registerOnTouch". */ + _markAsTouched: () => any = () => {}; + + @ViewChild('thumbContainer', {static: false}) _thumbContainer: ElementRef; + @ViewChild('track', {static: false}) _track: ElementRef; + @ViewChild('pinValueMarker', {static: false}) _pinValueMarker: ElementRef; + @ViewChild('trackMarker', {static: false}) _trackMarker: ElementRef; + + constructor( + private _elementRef: ElementRef, private _changeDetectorRef: ChangeDetectorRef, + private _ngZone: NgZone, @Optional() private _dir: Directionality, + @Attribute('tabindex') tabIndex: string, + @Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string) { + this.tabIndex = parseInt(tabIndex) || 0; + + if (this._dir) { + this._dirChangeSubscription = this._dir.change.subscribe(() => { + // In case the directionality changes, we need to refresh the rendered MDC slider. + // Note that we need to wait until the page actually updated as otherwise the + // client rectangle wouldn't reflect the new directionality. + // TODO(devversion): ideally the MDC slider would just compute dimensions similarly + // to the standard Material slider on "mouseenter". + this._ngZone.runOutsideAngular(() => setTimeout(() => this._foundation.layout())); + }); + } + } + + ngAfterViewInit() { + this._isInitialized = true; + this._foundation.init(); + + // The standard Angular Material slider is always using discrete values. We always + // want to enable discrete values and support ticks, but want to still provide + // non-discrete slider visual looks if thumb label is disabled. + // TODO(devversion): check if we can get a public API for this. + // Tracked with: https://github.com/material-components/material-components-web/issues/5020 + (this._foundation as any).isDiscrete_ = true; + + this._syncStep(); + this._syncValue(); + this._syncMax(); + this._syncMin(); + this._syncDisabled(); + } + + ngOnChanges(changes: SimpleChanges) { + if (!this._isInitialized) { + return; + } + + if (changes['step']) { + this._syncStep(); + } + if (changes['max']) { + this._syncMax(); + } + if (changes['min']) { + this._syncMin(); + } + if (changes['disabled']) { + this._syncDisabled(); + } + if (changes['value']) { + this._syncValue(); + } + if (changes['tickInterval']) { + this._refreshTrackMarkers(); + } + } + + ngOnDestroy() { + this._dirChangeSubscription.unsubscribe(); + this._foundation.destroy(); + } + + /** Focuses the slider. */ + focus(options?: FocusOptions) { + this._elementRef.nativeElement.focus(options); + } + + /** Blurs the slider. */ + blur() { + this._elementRef.nativeElement.blur(); + } + + /** Gets the display text of the current value. */ + get displayValue() { + if (this.displayWith) { + return this.displayWith(this.value!).toString(); + } + return this.value!.toString() || '0'; + } + + /** Creates a slider change object from the specified value. */ + private _createChangeEvent(newValue: number): MatSliderChange { + const event = new MatSliderChange(); + event.source = this; + event.value = newValue; + return event; + } + + /** Emits a change event and notifies the control value accessor. */ + private _emitChangeEvent(newValue: number) { + this._controlValueAccessorChangeFn(newValue); + this.valueChange.emit(newValue); + this.change.emit(this._createChangeEvent(newValue)); + } + + /** Computes the CSS background value for the track markers (aka ticks). */ + private _getTrackMarkersBackground(min: number, max: number, step: number) { + if (!this.tickInterval) { + return ''; + } + + const markerWidth = `${TICK_MARKER_SIZE}px`; + const markerBackground = + `linear-gradient(to right, currentColor ${markerWidth}, transparent 0)`; + + if (this.tickInterval === 'auto') { + const trackSize = this._elementRef.nativeElement.getBoundingClientRect().width; + const pixelsPerStep = trackSize * step / (max - min); + const stepsPerTick = Math.ceil(MIN_AUTO_TICK_SEPARATION / pixelsPerStep); + const pixelsPerTick = stepsPerTick * step; + return `${markerBackground} 0 center / ${pixelsPerTick}px 100% repeat-x`; + } + + // keep calculation in css for better rounding/subpixel behavior + const markerAmount = `(((${max} - ${min}) / ${step}) / ${this.tickInterval})`; + const markerBkgdLayout = + `0 center / calc((100% - ${markerWidth}) / ${markerAmount}) 100% repeat-x`; + return `${markerBackground} ${markerBkgdLayout}`; + } + + /** Method that ensures that track markers are refreshed. */ + private _refreshTrackMarkers() { + this._foundation.setupTrackMarker(); + } + + /** Syncs the "step" input value with the MDC foundation. */ + private _syncStep() { + this._foundation.setStep(this.step); + } + + /** Syncs the "max" input value with the MDC foundation. */ + private _syncMax() { + this._foundation.setMax(this.max); + } + + /** Syncs the "min" input value with the MDC foundation. */ + private _syncMin() { + this._foundation.setMin(this.min); + } + + /** Syncs the "value" input binding with the MDC foundation. */ + private _syncValue() { + this._foundation.setValue(this.value!); + } + + /** Syncs the "disabled" input value with the MDC foundation. */ + private _syncDisabled() { + this._foundation.setDisabled(this.disabled); + } + + /** + * 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) { + 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._markAsTouched = fn; + } + + /** + * Sets whether the component should be disabled. + * Implemented as part of ControlValueAccessor. + * @param isDisabled + */ + setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + this._syncDisabled(); + } + + /** + * Sets the model value. + * Implemented as part of ControlValueAccessor. + * @param value + */ + writeValue(value: any) { + this.value = value; + this._syncValue(); + } } diff --git a/test/karma-system-config.js b/test/karma-system-config.js index d810b21e1f0a..bc5646dce737 100644 --- a/test/karma-system-config.js +++ b/test/karma-system-config.js @@ -149,6 +149,8 @@ System.config({ 'dist/packages/material-experimental/mdc-radio/index.js', '@angular/material-experimental/mdc-slide-toggle': 'dist/packages/material-experimental/mdc-slide-toggle/index.js', + '@angular/material-experimental/mdc-slider': + 'dist/packages/material-experimental/mdc-slider/index.js', '@angular/material-experimental/mdc-tabs': 'dist/packages/material-experimental/mdc-tabs/index.js', '@angular/material-experimental/popover-edit':