diff --git a/scripts/check-mdc-exports-config.ts b/scripts/check-mdc-exports-config.ts index 5e66c55a612c..aa7559c5e717 100644 --- a/scripts/check-mdc-exports-config.ts +++ b/scripts/check-mdc-exports-config.ts @@ -1,6 +1,6 @@ export const config = { // The MDC sidenav hasn't been implemented yet. - skippedPackages: ['mdc-sidenav'], + skippedPackages: ['mdc-sidenav', 'mdc-slider'], skippedExports: { 'mdc-chips': [ // These components haven't been implemented for MDC due to a different accessibility pattern. diff --git a/src/cdk/testing/testbed/fake-events/dispatch-events.ts b/src/cdk/testing/testbed/fake-events/dispatch-events.ts index ee4759ac0679..4063386d1215 100644 --- a/src/cdk/testing/testbed/fake-events/dispatch-events.ts +++ b/src/cdk/testing/testbed/fake-events/dispatch-events.ts @@ -65,6 +65,7 @@ export function dispatchPointerEvent(node: Node, type: string, clientX = 0, clie * Shorthand to dispatch a touch event on the specified coordinates. * @docs-private */ -export function dispatchTouchEvent(node: Node, type: string, x = 0, y = 0) { - return dispatchEvent(node, createTouchEvent(type, x, y)); +export function dispatchTouchEvent(node: Node, type: string, pageX = 0, pageY = 0, clientX = 0, + clientY = 0) { + return dispatchEvent(node, createTouchEvent(type, pageX, pageY, clientX, clientY)); } diff --git a/src/cdk/testing/testbed/fake-events/event-objects.ts b/src/cdk/testing/testbed/fake-events/event-objects.ts index f211df2920f6..a22cb64be43c 100644 --- a/src/cdk/testing/testbed/fake-events/event-objects.ts +++ b/src/cdk/testing/testbed/fake-events/event-objects.ts @@ -79,11 +79,11 @@ export function createPointerEvent(type: string, clientX = 0, clientY = 0, * Creates a browser TouchEvent with the specified pointer coordinates. * @docs-private */ -export function createTouchEvent(type: string, pageX = 0, pageY = 0) { +export function createTouchEvent(type: string, pageX = 0, pageY = 0, clientX = 0, clientY = 0) { // In favor of creating events that work for most of the browsers, the event is created // as a basic UI Event. The necessary details for the event will be set manually. const event = document.createEvent('UIEvent'); - const touchDetails = {pageX, pageY}; + const touchDetails = {pageX, pageY, clientX, clientY}; // TS3.6 removes the initUIEvent method and suggests porting to "new UIEvent()". (event as any).initUIEvent(type, true, true, window, 0); diff --git a/src/dev-app/mdc-slider/mdc-slider-demo.html b/src/dev-app/mdc-slider/mdc-slider-demo.html index bb8e3de17870..68db534874f7 100644 --- a/src/dev-app/mdc-slider/mdc-slider-demo.html +++ b/src/dev-app/mdc-slider/mdc-slider-demo.html @@ -1,53 +1,88 @@

Default Slider

-Label +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

- + + + - + + + + +

Range slider

+ + + + diff --git a/src/dev-app/mdc-slider/mdc-slider-demo.ts b/src/dev-app/mdc-slider/mdc-slider-demo.ts index fab41d01fc41..2ccd963c7744 100644 --- a/src/dev-app/mdc-slider/mdc-slider-demo.ts +++ b/src/dev-app/mdc-slider/mdc-slider-demo.ts @@ -12,6 +12,7 @@ import {Component} from '@angular/core'; @Component({ selector: 'mdc-slider-demo', templateUrl: 'mdc-slider-demo.html', + styles: ['.mat-mdc-slider { width: 300px; }'], }) export class MdcSliderDemo { demo: number; diff --git a/src/dev-app/theme.scss b/src/dev-app/theme.scss index 55444d37b6f3..8ae0901816f4 100644 --- a/src/dev-app/theme.scss +++ b/src/dev-app/theme.scss @@ -32,8 +32,6 @@ $candy-app-theme: mat.define-light-theme(( @include experimental.all-mdc-component-themes($candy-app-theme); @include experimental.column-resize-theme($candy-app-theme); @include experimental.popover-edit-theme($candy-app-theme); -// We add this in manually for now, because it isn't included in `all-mdc-component-themes`. -@include mdc-slider-theme.theme($candy-app-theme); .demo-strong-focus { // Include base styles for strong focus indicators. diff --git a/src/e2e-app/mdc-slider/mdc-slider-e2e.ts b/src/e2e-app/mdc-slider/mdc-slider-e2e.ts index ec5fe2d2b71d..fe9f07e698dd 100644 --- a/src/e2e-app/mdc-slider/mdc-slider-e2e.ts +++ b/src/e2e-app/mdc-slider/mdc-slider-e2e.ts @@ -10,7 +10,20 @@ import {Component} from '@angular/core'; @Component({ selector: 'mdc-slider-e2e', - template: ``, + template: ` + + + + + + + + + + + + + `, }) export class MdcSliderE2e { } diff --git a/src/material-experimental/mdc-helpers/BUILD.bazel b/src/material-experimental/mdc-helpers/BUILD.bazel index b62d5232f6d9..8a84ac110801 100644 --- a/src/material-experimental/mdc-helpers/BUILD.bazel +++ b/src/material-experimental/mdc-helpers/BUILD.bazel @@ -23,6 +23,7 @@ npm_sass_library( "@npm//@material/list", "@npm//@material/menu-surface", "@npm//@material/radio", + "@npm//@material/slider", "@npm//@material/snackbar", "@npm//@material/switch", "@npm//@material/tab", diff --git a/src/material-experimental/mdc-slider/BUILD.bazel b/src/material-experimental/mdc-slider/BUILD.bazel index 5d1fe42b70c2..55653eb3964f 100644 --- a/src/material-experimental/mdc-slider/BUILD.bazel +++ b/src/material-experimental/mdc-slider/BUILD.bazel @@ -17,7 +17,10 @@ ng_module( ["**/*.ts"], exclude = ["**/*.spec.ts"], ), - assets = [":slider_scss"] + glob(["**/*.html"]), + assets = [ + ":slider_scss", + ":slider_thumb_scss", + ] + glob(["**/*.html"]), module_name = "@angular/material-experimental/mdc-slider", deps = [ "//src/cdk/bidi", @@ -35,6 +38,7 @@ sass_library( deps = [ "//src/cdk/a11y:a11y_scss_lib", "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", + "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib", ], ) @@ -50,6 +54,11 @@ sass_binary( ], ) +sass_binary( + name = "slider_thumb_scss", + src = "slider-thumb.scss", +) + ########### # Testing ########### @@ -66,8 +75,11 @@ ng_test_library( "//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", ], ) @@ -84,7 +96,9 @@ ng_e2e_test_library( name = "e2e_test_sources", srcs = glob(["**/*.e2e.spec.ts"]), deps = [ + ":mdc-slider", "//src/cdk/testing/private/e2e", + "@npm//@material/slider", ], ) diff --git a/src/material-experimental/mdc-slider/_slider-theme.scss b/src/material-experimental/mdc-slider/_slider-theme.scss index bf9f432751da..d465538fa955 100644 --- a/src/material-experimental/mdc-slider/_slider-theme.scss +++ b/src/material-experimental/mdc-slider/_slider-theme.scss @@ -1,24 +1,46 @@ -// TODO: disabled until we implement the new MDC slider. -// @use '@material/slider' as mdc-slider; +@use 'sass:map'; + +@use '@material/slider/slider' as mdc-slider; +@use '@material/slider/slider-theme'; +@use '@material/theme/variables' as theme-variables; @use '../mdc-helpers/mdc-helpers'; @use '../../material/core/typography/typography'; +@use '../../material/core/ripple/ripple-theme'; @use '../../material/core/theming/theming'; @mixin color($config-or-theme) { $config: theming.get-color-config($config-or-theme); @include mdc-helpers.mat-using-mdc-theme($config) { - // TODO: disabled until we implement the new MDC slider. - // @include mdc-slider-core-styles($query: $mat-theme-styles-query); + @include mdc-slider.without-ripple($query: mdc-helpers.$mat-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.$mat-theme-styles-query + ); + @include slider-theme.value-indicator-text-color( + $color: $indicator-text-color, + $query: mdc-helpers.$mat-theme-styles-query + ); + } + &.mat-primary { - // TODO: disabled until we implement the new MDC slider. - // @include mdc-slider-color-accessible(primary, $mat-theme-styles-query); + @include _custom-slider-color(primary, on-primary); + } + + &.mat-accent { + @include _custom-slider-color(secondary, on-secondary); } &.mat-warn { - // TODO: disabled until we implement the new MDC slider. - // @include mdc-slider-color-accessible(error, $mat-theme-styles-query); + @include _custom-slider-color(error, on-error); } } } @@ -28,8 +50,7 @@ $config: typography.private-typography-to-2018-config( theming.get-typography-config($config-or-theme)); @include mdc-helpers.mat-using-mdc-typography($config) { - // TODO: disabled until we implement the new MDC slider. - // @include mdc-slider-core-styles($query: $mat-typography-styles-query); + @include mdc-slider.without-ripple($query: mdc-helpers.$mat-typography-styles-query); } } @@ -54,3 +75,52 @@ } } +@mixin _custom-slider-color($color, $on-color) { + @include slider-theme.thumb-color( + $color-or-map: ( + default: $color, + disabled: on-surface, + ), + $query: mdc-helpers.$mat-theme-styles-query + ); + @include slider-theme.track-active-color( + $color-or-map: ( + default: $color, + disabled: on-surface, + ), + $query: mdc-helpers.$mat-theme-styles-query + ); + @include slider-theme.track-inactive-color( + $color-or-map: ( + default: $color, + disabled: on-surface, + ), + $query: mdc-helpers.$mat-theme-styles-query + ); + @include slider-theme.tick-mark-active-color( + $color-or-map: ( + default: $on-color, + disabled: surface, + ), + $query: mdc-helpers.$mat-theme-styles-query + ); + @include slider-theme.tick-mark-inactive-color( + $color-or-map: ( + default: $color, + disabled: on-surface, + ), + $query: mdc-helpers.$mat-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-experimental/mdc-slider/global-change-and-input-listener.ts new file mode 100644 index 000000000000..7f56b7cc1741 --- /dev/null +++ b/src/material-experimental/mdc-slider/global-change-and-input-listener.ts @@ -0,0 +1,67 @@ +/** + * @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 {DOCUMENT} from '@angular/common'; +import {Inject, Injectable, NgZone, OnDestroy} from '@angular/core'; +import {SpecificEventListener} from '@material/base'; +import {fromEvent, Observable, Subject, Subscription} from 'rxjs'; +import {finalize, share, takeUntil} from 'rxjs/operators'; + +/** + * Handles listening for all change and input events that occur on the document. + * + * This service exposes a single method #listen to allow users to subscribe to change and input + * events that occur on the document. Since listening for these events can be expensive, we use + * #fromEvent which will lazily attach a listener when the first subscription is made and remove the + * listener once the last observer unsubscribes. + */ +@Injectable({providedIn: 'root'}) +export class GlobalChangeAndInputListener implements OnDestroy { + + /** The injected document if available or fallback to the global document reference. */ + private _document: Document; + + /** Stores the subjects that emit the events that occur on the global document. */ + private _observables = new Map>(); + + /** The notifier that triggers the global event observables to stop emitting and complete. */ + private _destroyed = new Subject(); + + constructor(@Inject(DOCUMENT) document: any, private _ngZone: NgZone) { + this._document = document; + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + this._observables.clear(); + } + + /** Returns a subscription to global change or input events. */ + listen(type: K, callback: SpecificEventListener): Subscription { + // If this is the first time we are listening to this event, create the observable for it. + if (!this._observables.has(type)) { + this._observables.set(type, this._createGlobalEventObservable(type)); + } + + return this._ngZone.runOutsideAngular(() => + this._observables.get(type)!.subscribe((event: Event) => + this._ngZone.run(() => callback(event)) + ) + ); + } + + /** Creates an observable that emits all events of the given type. */ + private _createGlobalEventObservable(type: K) { + return fromEvent(this._document, type, {capture: true, passive: true}).pipe( + takeUntil(this._destroyed), + finalize(() => this._observables.delete(type)), + share(), + ); + } +} diff --git a/src/material-experimental/mdc-slider/module.ts b/src/material-experimental/mdc-slider/module.ts index b77814532ae8..1f06dffadb88 100644 --- a/src/material-experimental/mdc-slider/module.ts +++ b/src/material-experimental/mdc-slider/module.ts @@ -8,13 +8,17 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; -import {MatCommonModule} from '@angular/material-experimental/mdc-core'; -import {MatSlider} from './slider'; +import {MatCommonModule, MatRippleModule} from '@angular/material-experimental/mdc-core'; +import {MatSlider, MatSliderThumb, MatSliderVisualThumb} from './slider'; @NgModule({ - imports: [MatCommonModule, CommonModule], - exports: [MatSlider, MatCommonModule], - declarations: [MatSlider], + imports: [MatCommonModule, CommonModule, MatRippleModule], + exports: [MatSlider, MatSliderThumb], + declarations: [ + MatSlider, + MatSliderThumb, + MatSliderVisualThumb, + ], }) export class MatSliderModule { } diff --git a/src/material-experimental/mdc-slider/public-api.ts b/src/material-experimental/mdc-slider/public-api.ts index e6307b9bf1c3..294dbb8a1be7 100644 --- a/src/material-experimental/mdc-slider/public-api.ts +++ b/src/material-experimental/mdc-slider/public-api.ts @@ -6,5 +6,5 @@ * found in the LICENSE file at https://angular.io/license */ -export * from './slider'; -export * from './module'; +export {MatSlider, MatSliderThumb, MatSliderDragEvent} from './slider'; +export {MatSliderModule} from './module'; diff --git a/src/material-experimental/mdc-slider/slider-thumb.html b/src/material-experimental/mdc-slider/slider-thumb.html new file mode 100644 index 000000000000..595c373fe1f7 --- /dev/null +++ b/src/material-experimental/mdc-slider/slider-thumb.html @@ -0,0 +1,7 @@ +
+
+ {{valueIndicatorText}} +
+
+
+
diff --git a/src/material-experimental/mdc-slider/slider-thumb.scss b/src/material-experimental/mdc-slider/slider-thumb.scss new file mode 100644 index 000000000000..e5cd1605936a --- /dev/null +++ b/src/material-experimental/mdc-slider/slider-thumb.scss @@ -0,0 +1,4 @@ +.mat-mdc-slider-visual-thumb .mat-ripple { + height: 100%; + width: 100%; +} diff --git a/src/material-experimental/mdc-slider/slider.e2e.spec.ts b/src/material-experimental/mdc-slider/slider.e2e.spec.ts index e82f8ebde4b4..d0a2a25a0a49 100644 --- a/src/material-experimental/mdc-slider/slider.e2e.spec.ts +++ b/src/material-experimental/mdc-slider/slider.e2e.spec.ts @@ -1,14 +1,128 @@ -import {browser, by, element} from 'protractor'; +/** + * @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 + */ -// TODO: disabled until we implement the new MDC slider. -describe('mat-slider dummy' , () => it('', () => {})); +import {clickElementAtPoint, getElement, Point} from '@angular/cdk/testing/private/e2e'; +import {Thumb} from '@material/slider'; +import {browser, by, element, ElementFinder} from 'protractor'; -// tslint:disable-next-line:ban -xdescribe('mat-slider', () => { - beforeEach(async () => await browser.get('/mdc-slider')); +describe('MDC-based MatSlider' , () => { + const getStandardSlider = () => element(by.id('standard-slider')); + const getDisabledSlider = () => element(by.id('disabled-slider')); + const getRangeSlider = () => element(by.id('range-slider')); - it('should show a slider', async () => { - expect(await element(by.tagName('mat-slider')).isPresent()).toBe(true); + beforeEach(async () => await browser.get('mdc-slider')); + + describe('standard slider', async () => { + let slider: ElementFinder; + beforeEach(() => { slider = getStandardSlider(); }); + + it('should update the value on click', async () => { + await setValueByClick(slider, 15); + expect(await getSliderValue(slider, Thumb.END)).toBe(15); + }); + + it('should update the value on slide', async () => { + await slideToValue(slider, 35, Thumb.END); + expect(await getSliderValue(slider, Thumb.END)).toBe(35); + }); }); + describe('disabled slider', async () => { + let slider: ElementFinder; + beforeEach(() => { slider = getDisabledSlider(); }); + + it('should not update the value on click', async () => { + await setValueByClick(slider, 15); + expect(await getSliderValue(slider, Thumb.END)).not.toBe(15); + }); + + it('should not update the value on slide', async () => { + await slideToValue(slider, 35, Thumb.END); + expect(await getSliderValue(slider, Thumb.END)).not.toBe(35); + }); + }); + + describe('range slider', async () => { + let slider: ElementFinder; + beforeEach(() => { slider = getRangeSlider(); }); + + it('should update the start thumb value on slide', async () => { + await slideToValue(slider, 35, Thumb.START); + expect(await getSliderValue(slider, Thumb.START)).toBe(35); + }); + + it('should update the end thumb value on slide', async () => { + await slideToValue(slider, 55, Thumb.END); + expect(await getSliderValue(slider, Thumb.END)).toBe(55); + }); + + it('should update the start thumb value on click between thumbs ' + + 'but closer to the start thumb', async () => { + await setValueByClick(slider, 49); + expect(await getSliderValue(slider, Thumb.START)).toBe(49); + expect(await getSliderValue(slider, Thumb.END)).toBe(100); + }); + + it('should update the end thumb value on click between thumbs ' + + 'but closer to the end thumb', async () => { + await setValueByClick(slider, 51); + expect(await getSliderValue(slider, Thumb.START)).toBe(0); + expect(await getSliderValue(slider, Thumb.END)).toBe(51); + }); + }); }); + +/** Returns the current value of the slider. */ +async function getSliderValue(slider: ElementFinder, thumbPosition: Thumb): Promise { + const inputs = await slider.all(by.css('.mdc-slider__input')); + return thumbPosition === Thumb.END + ? Number(await inputs[inputs.length - 1].getAttribute('value')) + : Number(await inputs[0].getAttribute('value')); +} + +/** Clicks on the MatSlider at the coordinates corresponding to the given value. */ +async function setValueByClick(slider: ElementFinder, value: number): Promise { + return clickElementAtPoint(slider, await getCoordsForValue(slider, value)); +} + +/** Clicks on the MatSlider at the coordinates corresponding to the given value. */ +async function slideToValue + (slider: ElementFinder, value: number, thumbPosition: Thumb): Promise { + const webElement = await getElement(slider).getWebElement(); + const startCoords = await getCoordsForValue( + slider, + await getSliderValue(slider, thumbPosition), + ); + const endCoords = await getCoordsForValue(slider, value); + return await browser.actions() + .mouseMove(webElement, startCoords) + .mouseDown() + .mouseMove(webElement, endCoords) + .mouseUp() + .perform(); +} + +/** Returns the x and y coordinates for the given slider value. */ +async function getCoordsForValue(slider: ElementFinder, value: number): Promise { + const inputs = await slider.all(by.css('.mdc-slider__input')); + + const min = Number(await inputs[0].getAttribute('min')); + const max = Number(await inputs[inputs.length - 1].getAttribute('max')); + const percent = (value - min) / (max - min); + + const {width, height} = await slider.getSize(); + + // NOTE: We use Math.round here because protractor silently breaks if you pass in an imprecise + // floating point number with lots of decimals. This allows us to avoid the headache but it may + // cause some innaccuracies in places where these decimals mean the difference between values. + + const x = Math.round(width * percent); + const y = Math.round(height / 2); + + return {x, y}; +} diff --git a/src/material-experimental/mdc-slider/slider.html b/src/material-experimental/mdc-slider/slider.html index f925043d4f5c..caa46f348407 100644 --- a/src/material-experimental/mdc-slider/slider.html +++ b/src/material-experimental/mdc-slider/slider.html @@ -1 +1,22 @@ - + + + + +
+
+
+
+
+
+
+
+
+ + + + diff --git a/src/material-experimental/mdc-slider/slider.scss b/src/material-experimental/mdc-slider/slider.scss index dc38ea9472a2..e8d05b58bda9 100644 --- a/src/material-experimental/mdc-slider/slider.scss +++ b/src/material-experimental/mdc-slider/slider.scss @@ -1,6 +1,8 @@ -// TODO: disabled until we implement the new MDC slider. -// @use '@material/slider' as mdc-slider; +@use '@material/slider/slider' as mdc-slider; @use '../../cdk/a11y'; +@use '../mdc-helpers/mdc-helpers'; + +@include mdc-slider.without-ripple($query: mdc-helpers.$mat-base-styles-query); $mat-slider-min-size: 128px !default; $mat-slider-horizontal-margin: 8px !default; diff --git a/src/material-experimental/mdc-slider/slider.spec.ts b/src/material-experimental/mdc-slider/slider.spec.ts index 0d0398232c4a..215809988411 100644 --- a/src/material-experimental/mdc-slider/slider.spec.ts +++ b/src/material-experimental/mdc-slider/slider.spec.ts @@ -1,1176 +1,1388 @@ -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'; +/** + * @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 { - createKeyboardEvent, - createMouseEvent, - createPointerEvent, - dispatchEvent, - dispatchFakeEvent, - dispatchKeyboardEvent, dispatchMouseEvent, + dispatchPointerEvent, + dispatchTouchEvent, } from '@angular/cdk/testing/private'; -import {Component, DebugElement, Type, ViewChild} from '@angular/core'; -import {ComponentFixture, fakeAsync, flush, TestBed, tick, inject} from '@angular/core/testing'; +import {Component, Provider, QueryList, Type, ViewChild, ViewChildren} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + flush, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {By} from '@angular/platform-browser'; -import {NoopAnimationsModule} from '@angular/platform-browser/animations'; -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; +} -// TODO: disabled until we implement the new MDC slider. -// TODO: once the tests are re-enabled, we should remove `mdc-slider` -// from the `skippedPackages` in `check-mdc-tests-config.ts`. -describe('MDC-based MatSlider dummy' , () => it('', () => {})); +describe('MDC-based MatSlider' , () => { + let platform: Platform; + + beforeAll(() => { + platform = TestBed.inject(Platform); + // Mock #setPointerCapture as it throws errors on pointerdown without a real pointerId. + spyOn(Element.prototype, 'setPointerCapture'); + }); -// tslint:disable-next-line:ban -xdescribe('MDC-based MatSlider', () => { - function createComponent(component: Type): ComponentFixture { + function createComponent( + component: Type, + providers: Provider[] = [], + ): ComponentFixture { TestBed.configureTestingModule({ - imports: [ - MatSliderModule, - ReactiveFormsModule, - FormsModule, - BidiModule, - NoopAnimationsModule, - ], + imports: [FormsModule, MatSliderModule, ReactiveFormsModule, BidiModule], declarations: [component], + providers: [...providers], }).compileComponents(); - return TestBed.createComponent(component); } describe('standard slider', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; let sliderInstance: MatSlider; + let inputInstance: MatSliderThumb; - beforeEach(() => { - fixture = createComponent(StandardSlider); + beforeEach(waitForAsync(() => { + const 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; - }); + 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); }); 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:ban - 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); + 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); + slideToValue(sliderInstance, 101, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(100); + }); - dispatchSlideEventSequence(sliderNativeElement, 0, 1.75); + 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); + }); - expect(sliderInstance.value).toBe(100); + 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 change value without emitting a change event', () => { - const onChangeSpy = jasmine.createSpy('slider onChange'); + describe('standard range slider', () => { + let sliderInstance: MatSlider; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; - sliderInstance.change.subscribe(onChangeSpy); - sliderInstance.value = 50; + beforeEach(waitForAsync(() => { + const fixture = createComponent(StandardRangeSlider); 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', () => { + expect(startInputInstance.value).toBe(0); + expect(endInputInstance.value).toBe(100); + expect(sliderInstance.min).toBe(0); + expect(sliderInstance.max).toBe(100); + }); - dispatchSlideEventSequence(sliderNativeElement, 0, 0.1); + it('should update the start value on a slide', () => { + slideToValue(sliderInstance, 19, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(19); + }); - expect(onChangeSpy).toHaveBeenCalledTimes(1); + it('should update the end value on a slide', () => { + slideToValue(sliderInstance, 27, Thumb.END, platform.IOS); + expect(endInputInstance.value).toBe(27); }); - it('should have aria-orientation horizontal', () => { - expect(sliderNativeElement.getAttribute('aria-orientation')).toEqual('horizontal'); + 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 slide to the max value when the steps do not divide evenly into it', () => { - sliderInstance.min = 5; - sliderInstance.max = 100; - sliderInstance.step = 15; + 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); + }); - dispatchSlideEventSequence(sliderNativeElement, 0, 1); - fixture.detectChanges(); + 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); + }); - expect(sliderInstance.value).toBe(100); + 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 have a focus indicator', () => { - expect(sliderNativeElement.classList.contains('mat-mdc-focus-indicator')).toBe(true); + 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); + }); }); describe('disabled slider', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; let sliderInstance: MatSlider; + let inputInstance: MatSliderThumb; - beforeEach(() => { - fixture = createComponent(DisabledSlider); + beforeEach(waitForAsync(() => { + const fixture = createComponent(DisabledSlider); fixture.detectChanges(); - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderNativeElement = sliderDebugElement.nativeElement; + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); sliderInstance = sliderDebugElement.componentInstance; - }); + inputInstance = sliderInstance._getInput(Thumb.END); + })); it('should be disabled', () => { - expect(sliderInstance.disabled).toBeTruthy(); + 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 not change the value on mousedown when disabled', () => { - expect(sliderInstance.value).toBe(0); + it('should set the disabled attribute on the input element', () => { + expect(inputInstance._hostElement.disabled).toBeTrue(); + }); - dispatchMousedownEventSequence(sliderNativeElement, 0.63); + it('should not update the value on mousedown', () => { + setValueByClick(sliderInstance, 19, platform.IOS); + expect(inputInstance.value).toBe(0); + }); - expect(sliderInstance.value).toBe(0); + it('should not update the value on a slide', () => { + slideToValue(sliderInstance, 77, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(0); }); + }); - it('should not change the value on slide when disabled', () => { - expect(sliderInstance.value).toBe(0); + describe('disabled range slider', () => { + let sliderInstance: MatSlider; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; - dispatchSlideEventSequence(sliderNativeElement, 0, 0.5); + 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(0); + it('should be disabled', () => { + expect(sliderInstance.disabled).toBeTrue(); }); - it('should not emit change when disabled', () => { - const onChangeSpy = jasmine.createSpy('slider onChange'); - sliderInstance.change.subscribe(onChangeSpy); - - dispatchSlideEventSequence(sliderNativeElement, 0, 0.5); + it('should have the disabled class on the root element', () => { + expect(sliderInstance._elementRef.nativeElement.classList).toContain('mdc-slider--disabled'); + }); - expect(onChangeSpy).toHaveBeenCalledTimes(0); + it('should set the disabled attribute on the input elements', () => { + expect(startInputInstance._hostElement.disabled).toBeTrue(); + expect(endInputInstance._hostElement.disabled).toBeTrue(); }); - it('should not add the mat-slider-active class on mousedown when disabled', () => { - expect(sliderNativeElement.classList).not.toContain('mat-slider-active'); + it('should not update the start value on a slide', () => { + slideToValue(sliderInstance, 19, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(0); + }); - dispatchMousedownEventSequence(sliderNativeElement, 0.43); - fixture.detectChanges(); + it('should not update the end value on a slide', () => { + slideToValue(sliderInstance, 27, Thumb.END, platform.IOS); + expect(endInputInstance.value).toBe(100); + }); - expect(sliderNativeElement.classList).not.toContain('mat-slider-active'); + 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 disable tabbing to the slider', inject([Platform], (platform: Platform) => { - 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); - } - })); + 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('slider with set min and max', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let sliderInstance: MatSlider; - let thumbContainerEl: HTMLElement; + describe('ripple states', () => { + let inputInstance: MatSliderThumb; + let thumbInstance: MatSliderVisualThumb; + let thumbElement: HTMLElement; + let thumbX: number; + let thumbY: number; - beforeEach(fakeAsync(() => { - fixture = createComponent(SliderWithMinAndMax); + 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) { + tick(500); + return !!document.querySelector(`.mat-mdc-slider-${selector}-ripple`); + } + + 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 + ); + } - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderNativeElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.injector.get(MatSlider); - thumbContainerEl = sliderNativeElement - .querySelector('.mdc-slider__thumb-container'); + function pointerup() { + dispatchPointerOrTouchEvent( + thumbElement, PointerEventType.POINTER_UP, thumbX, thumbY, platform.IOS + ); + } - // Flush the "requestAnimationFrame" timer that performs the initial - // rendering of the MDC slider. - flushRequestAnimationFrame(); + it('should show the hover ripple on mouseenter', fakeAsync(() => { + expect(isRippleVisible('hover')).toBeFalse(); + mouseenter(); + expect(isRippleVisible('hover')).toBeTrue(); })); - 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 hide the hover ripple on mouseleave', fakeAsync(() => { + mouseenter(); + mouseleave(); + expect(isRippleVisible('hover')).toBeFalse(); + })); - it('should set the correct value on mousedown', () => { - dispatchMousedownEventSequence(sliderNativeElement, 0.09); - fixture.detectChanges(); + it('should show the focus ripple on pointerdown', fakeAsync(() => { + expect(isRippleVisible('focus')).toBeFalse(); + pointerdown(); + expect(isRippleVisible('focus')).toBeTrue(); + })); - // 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 continue to show the focus ripple on pointerup', fakeAsync(() => { + pointerdown(); + pointerup(); + expect(isRippleVisible('focus')).toBeTrue(); + })); - it('should set the correct value on slide', () => { - dispatchSlideEventSequence(sliderNativeElement, 0, 0.62); - fixture.detectChanges(); + it('should hide the focus ripple on blur', fakeAsync(() => { + pointerdown(); + pointerup(); + blur(); + expect(isRippleVisible('focus')).toBeFalse(); + })); - // 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 show the active ripple on pointerdown', fakeAsync(() => { + expect(isRippleVisible('active')).toBeFalse(); + pointerdown(); + expect(isRippleVisible('active')).toBeTrue(); + })); - it('should snap the fill to the nearest value on mousedown', fakeAsync(() => { - dispatchMousedownEventSequence(sliderNativeElement, 0.68); - fixture.detectChanges(); - flushRequestAnimationFrame(); + it('should hide the active ripple on pointerup', fakeAsync(() => { + pointerdown(); + pointerup(); + expect(isRippleVisible('active')).toBeFalse(); + })); - // The closest snap is halfway on the slider. - expect(thumbContainerEl.style.transform).toContain('translateX(50px)'); + // Edge cases. + + it('should not show the hover ripple if the thumb is already focused', fakeAsync(() => { + pointerdown(); + mouseenter(); + expect(isRippleVisible('hover')).toBeFalse(); })); - it('should snap the fill to the nearest value on slide', fakeAsync(() => { - dispatchSlideEventSequence(sliderNativeElement, 0, 0.74); - fixture.detectChanges(); - flushRequestAnimationFrame(); + 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(); + })); - // The closest snap is at the halfway point on the slider. - expect(thumbContainerEl.style.transform).toContain('translateX(50px)'); + 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 value', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; + describe('slider with set min and max', () => { + let fixture: ComponentFixture; let sliderInstance: MatSlider; + let inputInstance: MatSliderThumb; - beforeEach(() => { - fixture = createComponent(SliderWithValue); + beforeEach(waitForAsync(() => { + fixture = createComponent(SliderWithMinAndMax); fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + })); - 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 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', () => { - 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, 33, platform.IOS); + expect(inputInstance.value).toBe(33); }); it('should set the correct value on slide', () => { - dispatchSlideEventSequence(sliderNativeElement, 0, 0.32); - fixture.detectChanges(); + slideToValue(sliderInstance, 55, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(55); + }); - expect(sliderInstance.value).toBe(32); + 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('slider with set step', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; + describe('range slider with set min and max', () => { + let fixture: ComponentFixture; let sliderInstance: MatSlider; - let thumbContainerEl: HTMLElement; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; - beforeEach(fakeAsync(() => { - fixture = createComponent(SliderWithStep); + beforeEach(waitForAsync(() => { + fixture = createComponent(RangeSliderWithMinAndMax); 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(); + 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', () => { - expect(sliderInstance.value).toBe(0); + 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); + }); - dispatchMousedownEventSequence(sliderNativeElement, 0.13); - fixture.detectChanges(); + 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); + }); - expect(sliderInstance.value).toBe(25); + 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 step value on keydown', () => { - expect(sliderInstance.value).toBe(0); + it('should set the correct start value on slide', () => { + slideToValue(sliderInstance, 40, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(40); + }); - dispatchKeyboardEvent(sliderNativeElement, 'keydown', UP_ARROW); - fixture.detectChanges(); + it('should set the correct end value on slide', () => { + slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); + expect(endInputInstance.value).toBe(60); + }); - expect(sliderInstance.value).toBe(25); + 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); }); + }); - it('should snap the fill to a step on mousedown', fakeAsync(() => { - dispatchMousedownEventSequence(sliderNativeElement, 0.66); - fixture.detectChanges(); - flushRequestAnimationFrame(); + describe('slider with set value', () => { + let sliderInstance: MatSlider; + let inputInstance: MatSliderThumb; - // The closest step is at 75% of the slider. - expect(thumbContainerEl.style.transform).toContain('translateX(75px)'); + 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 correct step value on slide', () => { - dispatchSlideEventSequence(sliderNativeElement, 0, 0.07); - fixture.detectChanges(); + it('should set the default value from the attribute', () => { + expect(inputInstance.value).toBe(50); + }); - expect(sliderInstance.value).toBe(0); + it('should set the correct value on mousedown', () => { + setValueByClick(sliderInstance, 19, platform.IOS); + expect(inputInstance.value).toBe(19); }); - it('should snap the thumb and fill to a step on slide', fakeAsync(() => { - dispatchSlideEventSequence(sliderNativeElement, 0, 0.88); - fixture.detectChanges(); - flushRequestAnimationFrame(); + it('should set the correct value on slide', () => { + slideToValue(sliderInstance, 77, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(77); + }); + }); - // The closest snap is at the end of the slider. - expect(thumbContainerEl.style.transform).toContain('translateX(100px)'); - })); + describe('range slider with set value', () => { + let sliderInstance: MatSlider; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; - it('should not add decimals to the value if it is a whole number', () => { - fixture.componentInstance.step = 0.1; + 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); + })); - dispatchSlideEventSequence(sliderNativeElement, 0, 1); + it('should set the default value from the attribute', () => { + expect(startInputInstance.value).toBe(25); + expect(endInputInstance.value).toBe(75); + }); - expect(sliderDebugElement.componentInstance.displayValue).toBe('100'); + it('should set the correct start value on mousedown behind the start thumb', () => { + setValueByClick(sliderInstance, 19, platform.IOS); + expect(startInputInstance.value).toBe(19); }); - // TODO(devversion): MDC slider does not support decimal steps. - // tslint:disable-next-line:ban - xit('should truncate long decimal values when using a decimal step and the arrow keys', () => { - fixture.componentInstance.step = 0.1; - fixture.detectChanges(); + 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); + }); - for (let i = 0; i < 3; i++) { - dispatchKeyboardEvent(sliderNativeElement, 'keydown', UP_ARROW); - } + it('should set the correct start value on slide', () => { + slideToValue(sliderInstance, 73, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(73); + }); - expect(sliderInstance.value).toBe(0.3); + 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 tick interval', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let ticksContainerElement: HTMLElement; + describe('slider with set step', () => { + let fixture: ComponentFixture; + let sliderInstance: MatSlider; + let inputInstance: MatSliderThumb; - beforeEach(() => { - fixture = createComponent(SliderWithSetTickInterval); + beforeEach(waitForAsync(() => { + fixture = createComponent(SliderWithStep); fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + })); - 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 12.1 evaluate to a percentage value. Chrome evaluates to - // a rounded five-digit decimal number and Safari 13.1 evaluates to a decimal - // representing the percentage. - const expectationRegex = new RegExp( - `(${sizeOfTick}|${ticksPerTrackPercentage}%|${sizeOfTick.toFixed(5)}|` + - `${ticksPerTrackPercentage / 100})`); - 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(); + 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); + }); - expect(sliderNativeElement.classList) - .not.toContain('mat-slider-has-ticks', 'Expected element not to have ticks after reset.'); + 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('slider with thumb label', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; + describe('range slider with set step', () => { + let fixture: ComponentFixture; let sliderInstance: MatSlider; - let thumbLabelTextElement: Element; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; - beforeEach(() => { - fixture = createComponent(SliderWithThumbLabel); + beforeEach(waitForAsync(() => { + fixture = createComponent(RangeSliderWithStep); fixture.detectChanges(); - - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderNativeElement = sliderDebugElement.nativeElement; + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); sliderInstance = sliderDebugElement.componentInstance; - thumbLabelTextElement = sliderNativeElement.querySelector('.mdc-slider__pin-value-marker')!; + 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 add the thumb label class to the slider container', () => { - expect(sliderNativeElement.classList).toContain('mat-slider-thumb-label-showing'); + 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 update the thumb label text on mousedown', () => { - expect(thumbLabelTextElement.textContent).toBe('0'); + it('should set the correct start thumb step value on slide', () => { + slideToValue(sliderInstance, 26, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(25); + }); - dispatchMousedownEventSequence(sliderNativeElement, 0.13); - fixture.detectChanges(); + 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); + }); - // The thumb label text is set to the slider's value. These should always be the same. - expect(thumbLabelTextElement.textContent).toBe('13'); + 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 update the thumb label text on slide', () => { - expect(thumbLabelTextElement.textContent).toBe('0'); + 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); + }); - dispatchSlideEventSequence(sliderNativeElement, 0, 0.56); - fixture.detectChanges(); + 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); - // The thumb label text is set to the slider's value. These should always be the same. - expect(thumbLabelTextElement.textContent).toBe(`${sliderInstance.value}`); + // 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 sliderNativeElement: HTMLElement; - let thumbLabelTextElement: Element; + let fixture: ComponentFixture; + let sliderInstance: MatSlider; + let valueIndicatorTextElement: Element; beforeEach(() => { - fixture = createComponent(SliderWithCustomThumbLabelFormatting); + fixture = createComponent(DiscreteSliderWithDisplayWith); fixture.detectChanges(); - - const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderNativeElement = sliderDebugElement.nativeElement; - thumbLabelTextElement = sliderNativeElement.querySelector('.mdc-slider__pin-value-marker')!; + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; + const sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + valueIndicatorTextElement = + sliderNativeElement.querySelector('.mdc-slider__value-indicator-text')!; }); 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:ban - 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'); + 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', () => { - dispatchMousedownEventSequence(sliderNativeElement, 1); + sliderInstance._setValue(200000, Thumb.END); fixture.detectChanges(); - - expect(thumbLabelTextElement.textContent).toBe('100k'); + expect(valueIndicatorTextElement.textContent).toBe('$200k'); }); }); - describe('slider with value property binding', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; + describe('range slider with custom thumb label formatting', () => { + let fixture: ComponentFixture; let sliderInstance: MatSlider; - let testComponent: SliderWithOneWayBinding; - let thumbContainerEl: HTMLElement; + let startValueIndicatorTextElement: Element; + let endValueIndicatorTextElement: Element; - beforeEach(fakeAsync(() => { - fixture = createComponent(SliderWithOneWayBinding); + beforeEach(() => { + fixture = createComponent(DiscreteRangeSliderWithDisplayWith); fixture.detectChanges(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider))!; + sliderInstance = sliderDebugElement.componentInstance; - 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; + 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')!; + }); - // Flush the "requestAnimationFrame" timer that performs the initial - // rendering of the MDC slider. - flushRequestAnimationFrame(); - })); + 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 initialize based on bound value', () => { - expect(sliderInstance.value).toBe(50); - expect(thumbContainerEl.style.transform).toContain('translateX(50px)'); + 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 update when bound value changes', fakeAsync(() => { - testComponent.val = 75; + it('should format the start thumb label based on the passed-in `displayWith` function', () => { + sliderInstance._setValue(200000, Thumb.START); fixture.detectChanges(); - flushRequestAnimationFrame(); + expect(startValueIndicatorTextElement.textContent).toBe('$200k'); + }); - expect(sliderInstance.value).toBe(75); - expect(thumbContainerEl.style.transform).toContain('translateX(75px)'); - })); + 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 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; + describe('slider with value property binding', () => { + let fixture: ComponentFixture; + let testComponent: SliderWithOneWayBinding; + let inputInstance: MatSliderThumb; - beforeEach(fakeAsync(() => { - fixture = createComponent(SliderWithValueSmallerThanMin); + beforeEach(waitForAsync(() => { + fixture = createComponent(SliderWithOneWayBinding); 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(); + testComponent = fixture.debugElement.componentInstance; + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + const sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); })); - 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)'); + it('should update when bound value changes', () => { + testComponent.value = 75; + fixture.detectChanges(); + expect(inputInstance.value).toBe(75); }); }); - 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; + describe('range slider with value property binding', () => { + let fixture: ComponentFixture; + let testComponent: RangeSliderWithOneWayBinding; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; - beforeEach(fakeAsync(() => { - fixture = createComponent(SliderWithValueGreaterThanMax); + beforeEach(waitForAsync(() => { + fixture = createComponent(RangeSliderWithOneWayBinding); 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(); + 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 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 update when bound start value changes', () => { + testComponent.startValue = 30; + fixture.detectChanges(); + expect(startInputInstance.value).toBe(30); }); - it('should set the fill to the max value', () => { - expect(thumbContainerEl.style.transform).toContain('translateX(100px)'); + 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 mousedown', () => { + it('should emit change on mouseup', () => { expect(testComponent.onChange).not.toHaveBeenCalled(); - - dispatchMousedownEventSequence(sliderNativeElement, 0.2); - fixture.detectChanges(); - + 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); }); - // 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:ban - xit('should not emit multiple changes for same value', () => { + 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); }); 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(); + const dispatchSliderEvent = (type: PointerEventType, value: number) => { + const {x, y} = getCoordsForValue(sliderInstance, value); + dispatchPointerOrTouchEvent(sliderElement, type, x, y, platform.IOS); + }; - dispatchMousedownEventSequence(sliderNativeElement, 0.2); - fixture.detectChanges(); + expect(testComponent.onChange).not.toHaveBeenCalled(); + expect(testComponent.onInput).not.toHaveBeenCalled(); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - expect(testComponent.onInput).toHaveBeenCalledTimes(1); + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 20); + fixture.detectChanges(); - testComponent.value = 0; - fixture.detectChanges(); + expect(testComponent.onChange).not.toHaveBeenCalled(); + expect(testComponent.onInput).toHaveBeenCalledTimes(1); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - - dispatchMousedownEventSequence(sliderNativeElement, 0.2); - fixture.detectChanges(); + dispatchSliderEvent(PointerEventType.POINTER_UP, 20); + fixture.detectChanges(); - expect(testComponent.onChange).toHaveBeenCalledTimes(2); - expect(testComponent.onInput).toHaveBeenCalledTimes(2); - }); - }); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + expect(testComponent.onInput).toHaveBeenCalledTimes(1); - describe('slider with input event', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let testComponent: SliderWithChangeHandler; + inputInstance.value = 0; + fixture.detectChanges(); - beforeEach(() => { - fixture = createComponent(SliderWithChangeHandler); - fixture.detectChanges(); + expect(testComponent.onChange).toHaveBeenCalledTimes(1); + expect(testComponent.onInput).toHaveBeenCalledTimes(1); - testComponent = fixture.debugElement.componentInstance; - spyOn(testComponent, 'onInput'); - spyOn(testComponent, 'onChange'); + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 20); + fixture.detectChanges(); + dispatchSliderEvent(PointerEventType.POINTER_UP, 20); - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderNativeElement = sliderDebugElement.nativeElement; + expect(testComponent.onChange).toHaveBeenCalledTimes(2); + expect(testComponent.onInput).toHaveBeenCalledTimes(2); }); + }); - it('should emit an input event while sliding', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); - - dispatchSliderMouseEvent(sliderNativeElement, 'down', 0); - dispatchSliderMouseEvent(sliderNativeElement, 'move', 0.5); - dispatchSliderMouseEvent(sliderNativeElement, 'move', 1); - dispatchSliderMouseEvent(sliderNativeElement, 'up', 1); + 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); + })); - // 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 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 an input event when clicking', () => { - expect(testComponent.onChange).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); + }); - dispatchMousedownEventSequence(sliderNativeElement, 0.75); - fixture.detectChanges(); + 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(); + }); - // The `onInput` event should be emitted once due to a single click. - expect(testComponent.onInput).toHaveBeenCalledTimes(1); - expect(testComponent.onChange).toHaveBeenCalledTimes(1); + 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(); - describe('slider with auto ticks', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let ticksContainerElement: HTMLElement; + setValueByClick(sliderInstance, 30, platform.IOS); + slideToValue(sliderInstance, 30, Thumb.START, platform.IOS); + setValueByClick(sliderInstance, 30, platform.IOS); + slideToValue(sliderInstance, 30, Thumb.START, platform.IOS); - beforeEach(fakeAsync(() => { - fixture = createComponent(SliderWithAutoTickInterval); - fixture.detectChanges(); + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + }); - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderNativeElement = sliderDebugElement.nativeElement; - ticksContainerElement = - sliderNativeElement.querySelector('.mdc-slider__track-marker-container'); + it('should not emit multiple changes for the same end thumb value', () => { + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); - flushRequestAnimationFrame(); - })); + setValueByClick(sliderInstance, 60, platform.IOS); + slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); + setValueByClick(sliderInstance, 60, platform.IOS); + slideToValue(sliderInstance, 60, Thumb.END, platform.IOS); - it('should set the correct tick separation', () => { - expect(ticksContainerElement.style.background).toContain('30px'); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); }); - }); - describe('keyboard support', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let testComponent: SliderWithChangeHandler; - let sliderInstance: MatSlider; - - beforeEach(() => { - fixture = createComponent(SliderWithChangeHandler); - 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); + }; + + 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(); - testComponent = fixture.debugElement.componentInstance; - spyOn(testComponent, 'onInput'); - spyOn(testComponent, 'onChange'); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderNativeElement = sliderDebugElement.nativeElement; - sliderInstance = sliderDebugElement.injector.get(MatSlider); - }); + dispatchSliderEvent(PointerEventType.POINTER_UP, 20); + fixture.detectChanges(); - it('should increment slider by 1 on up arrow pressed', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - dispatchKeyboardEvent(sliderNativeElement, 'keydown', UP_ARROW); - fixture.detectChanges(); + startInputInstance.value = 0; + 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); - }); + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - it('should increment slider by 1 on right arrow pressed', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); - - dispatchKeyboardEvent(sliderNativeElement, 'keydown', RIGHT_ARROW); - fixture.detectChanges(); + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 20); + fixture.detectChanges(); + dispatchSliderEvent(PointerEventType.POINTER_UP, 20); - // 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); + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(2); + expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(2); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); }); - it('should decrement slider by 1 on down arrow pressed', () => { - fixture.componentInstance.value = 100; - fixture.detectChanges(); + 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.onChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(1); - dispatchKeyboardEvent(sliderNativeElement, 'keydown', DOWN_ARROW); - fixture.detectChanges(); + dispatchSliderEvent(PointerEventType.POINTER_UP, 80); + 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); - }); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(1); - it('should decrement slider by 1 on left arrow pressed', () => { - fixture.componentInstance.value = 100; - fixture.detectChanges(); + endInputInstance.value = 100; + fixture.detectChanges(); - expect(testComponent.onChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(1); - dispatchKeyboardEvent(sliderNativeElement, 'keydown', LEFT_ARROW); - fixture.detectChanges(); + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 80); + fixture.detectChanges(); + dispatchSliderEvent(PointerEventType.POINTER_UP, 80); - // 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).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(2); + expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(2); }); + }); - // 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(); + describe('slider with input event', () => { + let sliderInstance: MatSlider; + let sliderElement: HTMLElement; + let testComponent: SliderWithChangeHandler; - dispatchKeyboardEvent(sliderNativeElement, 'keydown', PAGE_UP); + beforeEach(waitForAsync(() => { + const fixture = createComponent(SliderWithChangeHandler); 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); - }); + testComponent = fixture.debugElement.componentInstance; - // 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(); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + sliderElement = sliderInstance._elementRef.nativeElement; + })); - expect(testComponent.onChange).not.toHaveBeenCalled(); + 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', PAGE_DOWN); - fixture.detectChanges(); + expect(testComponent.onChange).not.toHaveBeenCalled(); + expect(testComponent.onInput).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(96); - }); + // pointer down on current value (should not trigger input event) + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 0); - it('should set slider to max on end pressed', () => { - expect(testComponent.onChange).not.toHaveBeenCalled(); + // value changes (should trigger input) + dispatchSliderEvent(PointerEventType.POINTER_MOVE, 10); + dispatchSliderEvent(PointerEventType.POINTER_MOVE, 25); - dispatchKeyboardEvent(sliderNativeElement, 'keydown', END); - fixture.detectChanges(); + // a new value has been committed (should trigger change event) + dispatchSliderEvent(PointerEventType.POINTER_UP, 25); - // The `onInput` event should be emitted once due to a single keyboard press. - expect(testComponent.onInput).toHaveBeenCalledTimes(1); + expect(testComponent.onInput).toHaveBeenCalledTimes(2); 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(); - + it('should emit an input event when clicking', () => { 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).not.toHaveBeenCalled(); + setValueByClick(sliderInstance, 75, platform.IOS); 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(); + describe('range slider with input event', () => { + let sliderInstance: MatSlider; + let sliderElement: HTMLElement; + let testComponent: RangeSliderWithChangeHandler; - dispatchKeyboardEvent(sliderNativeElement, 'keydown', BACKSPACE); + beforeEach(waitForAsync(() => { + const fixture = createComponent(RangeSliderWithChangeHandler); 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:ban - xit('should ignore events modifier keys', () => { - sliderInstance.value = 0; + testComponent = fixture.debugElement.componentInstance; - [ - 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); - }); + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + sliderElement = sliderInstance._elementRef.nativeElement; + })); - expect(testComponent.onInput).not.toHaveBeenCalled(); - expect(testComponent.onChange).not.toHaveBeenCalled(); - expect(sliderInstance.value).toBe(0); - }); - }); + 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('slider with direction', () => { - let fixture: ComponentFixture; - let sliderDebugElement: DebugElement; - let sliderNativeElement: HTMLElement; - let sliderInstance: MatSlider; - let testComponent: SliderWithDir; + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - beforeEach(() => { - fixture = createComponent(SliderWithDir); - 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; - }); + // value changes (should trigger input) + dispatchSliderEvent(PointerEventType.POINTER_MOVE, 10); + dispatchSliderEvent(PointerEventType.POINTER_MOVE, 25); - it('works in RTL languages', fakeAsync(() => { - testComponent.dir = 'rtl'; - fixture.detectChanges(); - flushRequestAnimationFrame(); + // a new value has been committed (should trigger change event) + dispatchSliderEvent(PointerEventType.POINTER_UP, 25); - dispatchMousedownEventSequence(sliderNativeElement, 0.3); - fixture.detectChanges(); + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(2); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); + }); - expect(sliderInstance.value).toBe(70); - })); + 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); + }; - it('should re-render slider with updated style upon directionality change', fakeAsync(() => { - testComponent.dir = 'rtl'; - fixture.detectChanges(); - flushRequestAnimationFrame(); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); - const thumbContainerEl = sliderNativeElement - .querySelector('.mdc-slider__thumb-container'); + // pointer down on current end thumb value (should not trigger input event) + dispatchSliderEvent(PointerEventType.POINTER_DOWN, 100); - expect(thumbContainerEl.style.transform).toContain('translateX(100px)'); + // value changes (should trigger input) + dispatchSliderEvent(PointerEventType.POINTER_MOVE, 90); + dispatchSliderEvent(PointerEventType.POINTER_MOVE, 55); - testComponent.dir = 'ltr'; - fixture.detectChanges(); - flushRequestAnimationFrame(); + // a new value has been committed (should trigger change event) + dispatchSliderEvent(PointerEventType.POINTER_UP, 55); - expect(thumbContainerEl.style.transform).toContain('translateX(0px)'); - })); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(2); + }); - it('should decrement RTL slider by 1 on right arrow pressed', () => { - testComponent.dir = 'rtl'; - testComponent.value = 100; - fixture.detectChanges(); + 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(); - dispatchKeyboardEvent(sliderNativeElement, 'keydown', RIGHT_ARROW); - fixture.detectChanges(); + setValueByClick(sliderInstance, 30, platform.IOS); - expect(sliderInstance.value).toBe(99); + expect(testComponent.onStartThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onStartThumbInput).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbInput).not.toHaveBeenCalled(); }); - it('should increment RTL slider by 1 on left arrow pressed', () => { - testComponent.dir = 'rtl'; - fixture.detectChanges(); + 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(); - dispatchKeyboardEvent(sliderNativeElement, 'keydown', LEFT_ARROW); - fixture.detectChanges(); + setValueByClick(sliderInstance, 55, platform.IOS); - expect(sliderInstance.value).toBe(1); + expect(testComponent.onStartThumbChange).not.toHaveBeenCalled(); + expect(testComponent.onStartThumbInput).not.toHaveBeenCalled(); + expect(testComponent.onEndThumbChange).toHaveBeenCalledTimes(1); + expect(testComponent.onEndThumbInput).toHaveBeenCalledTimes(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.'); + 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(sliderNativeEl.tabIndex).toBe(3, 'Expected the tabIndex to have been changed.'); + 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; - it('should detect the native tabindex attribute', () => { - const fixture = createComponent(SliderWithNativeTabindexAttr); + 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) - .toBe(5, 'Expected the tabIndex to be set to the value of the native attribute.'); + 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; + const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + const sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + })); - sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); - sliderNativeElement = sliderDebugElement.nativeElement; - }); - - it('should update the model on mousedown', () => { + it('should update the model on mouseup', () => { expect(testComponent.val).toBe(0); - - dispatchMousedownEventSequence(sliderNativeElement, 0.76); + 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); - - 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); - dispatchKeyboardEvent(sliderNativeElement, 'keydown', UP_ARROW); + testComponent.val = undefined; fixture.detectChanges(); + flush(); + expect(inputInstance.value).toBe(0); + })); + }); - expect(testComponent.val).toBe(1); - }); + describe('slider with ngModel', () => { + let fixture: ComponentFixture; + let testComponent: RangeSliderWithNgModel; - it('should be able to reset a slider by setting the model back to undefined', fakeAsync(() => { - expect(testComponent.slider.value).toBe(0); + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; - testComponent.val = 5; + beforeEach(waitForAsync(() => { + fixture = createComponent(RangeSliderWithNgModel); fixture.detectChanges(); - flush(); + 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); + }); - expect(testComponent.slider.value).toBe(5); + 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 = undefined; + it('should update the start thumb model on slide', () => { + expect(testComponent.startVal).toBe(0); + slideToValue(testComponent.slider, 19, Thumb.START, platform.IOS); fixture.detectChanges(); - flush(); + 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); + }); - expect(testComponent.slider.value).toBe(0); + 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 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 mousedown', () => { + it('should update the control on mouseup', () => { expect(testComponent.control.value).toBe(0); - - dispatchMousedownEventSequence(sliderNativeElement, 0.76); - fixture.detectChanges(); - + 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(); - fixture.detectChanges(); - expect(sliderInstance.disabled).toBe(false); }); @@ -1184,16 +1396,155 @@ xdescribe('MDC-based MatSlider', () => { // After changing the value, the control should become dirty (not pristine), // but remain untouched. - dispatchMousedownEventSequence(sliderNativeElement, 0.5); + 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. - dispatchFakeEvent(sliderNativeElement, 'blur'); + 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); @@ -1205,264 +1556,418 @@ xdescribe('MDC-based 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); - dispatchMouseEvent(sliderNativeElement, 'mouseup'); - fixture.detectChanges(); + 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; -function flushRequestAnimationFrame() { - // Flush the "requestAnimationFrame" timer that performs the rendering of - // the MDC slider. Zone uses 16ms for "requestAnimationFrame". - tick(16); -} + beforeEach(waitForAsync(() => { + fixture = createComponent(RangeSliderWithTwoWayBinding); + fixture.detectChanges(); + testComponent = fixture.componentInstance; + })); -// 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; - width: 100px; - } -`; + 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: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class StandardSlider { } +class StandardSlider {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class DisabledSlider { } +class StandardRangeSlider {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithMinAndMax { - min = 4; - max = 6; -} +class DisabledSlider {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithValue { } +class DisabledRangeSlider {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithStep { - step = 25; - max = 100; -} +class SliderWithMinAndMax {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithAutoTickInterval { } +class RangeSliderWithMinAndMax {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithSetTickInterval { - tickInterval = 6; -} +class SliderWithValue {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithThumbLabel { } - +class RangeSliderWithValue {} @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithCustomThumbLabelFormatting { - value = 0; - - displayWith(value: number | null) { - if (!value) { - return 0; - } +class SliderWithStep {} - if (value >= 1000) { - return (value / 1000) + 'k'; - } +@Component({ + template: ` + + + + + `, + styles: SLIDER_STYLES, +}) +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: SLIDER_STYLES, +}) +class DiscreteRangeSliderWithDisplayWith { + displayWith(v: number) { + if (v >= 1000) { return `$${v / 1000}k`; } + return `$${v}`; + } +} @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) class SliderWithOneWayBinding { - val = 50; + value = 50; } @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithFormControl { - control = new FormControl(0); +class RangeSliderWithOneWayBinding { + startValue = 25; + endValue = 75; } @Component({ - template: ``, - styles: [styles], + template: ` + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithNgModel { +class SliderWithChangeHandler { + onChange = jasmine.createSpy('onChange'); + onInput = jasmine.createSpy('onChange'); @ViewChild(MatSlider) slider: MatSlider; - val: number | undefined = 0; } @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithValueSmallerThanMin { } +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 SliderWithValueGreaterThanMax { } +class SliderWithNgModel { + @ViewChild(MatSlider) slider: MatSlider; + val: number | undefined = 0; +} @Component({ - template: ``, - styles: [styles], + template: ` + + + + + `, + styles: SLIDER_STYLES, }) -class SliderWithChangeHandler { - value = 0; - onChange() { } - onInput() { } - +class RangeSliderWithNgModel { @ViewChild(MatSlider) slider: MatSlider; + startVal: number | undefined = 0; + endVal: number | undefined = 100; } @Component({ - template: `
`, - styles: [styles], + template: ` + + + `, + styles: SLIDER_STYLES, }) -class SliderWithDir { - value = 0; - dir = 'ltr'; +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 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, 'down', percentage, button); - dispatchSliderMouseEvent(sliderElement, 'up', percentage, 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 { - dispatchSliderMouseEvent(sliderElement, 'down', startPercent); - dispatchSliderMouseEvent(sliderElement, 'move', startPercent); - dispatchSliderMouseEvent(sliderElement, 'move', endPercent); - dispatchSliderMouseEvent(sliderElement, 'up', endPercent); +/** The touch event types used by the MDC Slider. */ +const enum TouchEventType { + TOUCH_START = 'touchstart', + TOUCH_END = 'touchend', + TOUCH_MOVE = 'touchmove', } -/** - * 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 mouse interaction. - * @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: 'up' | 'down' | 'move', - percent: number, button = 0): void { - const trackElement = sliderElement.querySelector('.mdc-slider__track-container')!; - const dimensions = trackElement.getBoundingClientRect(); - const clientX = dimensions.left + (dimensions.width * percent); - const clientY = dimensions.top + (dimensions.height * percent); - - // The latest versions of all browsers we support have the new `PointerEvent` API. - // Though since we capture the two most recent versions of these browsers, we also - // need to support Safari 12 at time of writing. Safari 12 does not have support for this, - // so we need to conditionally create and dispatch these events based on feature detection. - if (window.PointerEvent !== undefined) { - dispatchEvent(sliderElement, createPointerEvent(`pointer${type}`, clientX, clientY)); +/** 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; } - dispatchEvent(sliderElement, createMouseEvent(`mouse${type}`, clientX, clientY, 0)); } diff --git a/src/material-experimental/mdc-slider/slider.ts b/src/material-experimental/mdc-slider/slider.ts index 024be32ab32b..44a70e413d88 100644 --- a/src/material-experimental/mdc-slider/slider.ts +++ b/src/material-experimental/mdc-slider/slider.ts @@ -13,516 +13,1130 @@ import { coerceNumberProperty, NumberInput } from '@angular/cdk/coercion'; -import {normalizePassiveListenerOptions, Platform} from '@angular/cdk/platform'; +import {Platform} from '@angular/cdk/platform'; +import {DOCUMENT} from '@angular/common'; import { AfterViewInit, - Attribute, ChangeDetectionStrategy, + ChangeDetectorRef, Component, + ContentChildren, + Directive, ElementRef, EventEmitter, forwardRef, Inject, Input, NgZone, - OnChanges, OnDestroy, + OnInit, Optional, Output, - SimpleChanges, + QueryList, ViewChild, - ViewEncapsulation + ViewChildren, + ViewEncapsulation, } from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; -import {ThemePalette} from '@angular/material-experimental/mdc-core'; -import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; -import {MDCSliderAdapter, MDCSliderFoundation, Thumb} from '@material/slider'; +import { + CanColorCtor, + CanDisableRipple, + CanDisableRippleCtor, + MatRipple, + MAT_RIPPLE_GLOBAL_OPTIONS, + mixinColor, + mixinDisableRipple, + RippleAnimationConfig, + RippleGlobalOptions, + RippleRef, + RippleState, +} from '@angular/material/core'; +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'; -/** - * 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; +/** Represents a drag event emitted by the MatSlider component. */ +export interface MatSliderDragEvent { + /** The MatSliderThumb that was interacted with. */ + source: MatSliderThumb; -// TODO: disabled until we implement the new MDC slider. -/** Event options used to bind passive listeners. */ -// tslint:disable-next-line:no-unused-variable -const passiveListenerOptions = normalizePassiveListenerOptions({passive: true}); + /** The MatSlider that was interacted with. */ + parent: MatSlider; -// TODO: disabled until we implement the new MDC slider. -/** Event options used to bind active listeners. */ -// tslint:disable-next-line:no-unused-variable -const activeListenerOptions = normalizePassiveListenerOptions({passive: false}); + /** The current value of the slider. */ + value: number; +} /** - * Provider Expression that allows mat-slider to register as a ControlValueAccessor. - * This allows it to support [(ngModel)] and [formControl]. + * The visual slider thumb. + * + * Handles the slider thumb ripple states (hover, focus, and active), + * and displaying the value tooltip on discrete sliders. * @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({ - selector: 'mat-slider', - templateUrl: 'slider.html', - styleUrls: ['slider.css'], + selector: 'mat-slider-visual-thumb', + templateUrl: './slider-thumb.html', + styleUrls: ['slider-thumb.css'], host: { - 'class': 'mat-mdc-slider mdc-slider mat-mdc-focus-indicator', - '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.mdc-slider--display-markers]': 'tickInterval !== 0', - '[class.mat-slider-thumb-label-showing]': 'thumbLabel', - // 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]': '_isRtl()', - '[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()', + 'class': 'mdc-slider__thumb mat-mdc-slider-visual-thumb', }, - exportAs: 'matSlider', - encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, - providers: [MAT_SLIDER_VALUE_ACCESSOR], + 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 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. */ + private _isActive: boolean = 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); + + this._sliderInput.dragStart.subscribe((e: MatSliderDragEvent) => this._onDragStart(e)); + this._sliderInput.dragEnd.subscribe((e: MatSliderDragEvent) => this._onDragEnd(e)); + + 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 pervent angular from needlessly running change detection. + this._ngZone.runOutsideAngular(() => { + this._elementRef.nativeElement.addEventListener('mouseenter', this._onMouseEnter.bind(this)); + this._elementRef.nativeElement.addEventListener('mouseleave', this._onMouseLeave.bind(this)); + }); + } + + ngOnDestroy() { + this._sliderInput.dragStart.unsubscribe(); + this._sliderInput.dragEnd.unsubscribe(); + this._sliderInput._focus.unsubscribe(); + this._sliderInput._blur.unsubscribe(); + this._elementRef.nativeElement.removeEventListener('mouseenter', this._onMouseEnter); + this._elementRef.nativeElement.removeEventListener('mouseleave', this._onMouseLeave); + } + + 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._isActive = true; + this._showActiveRipple(); + } + } + + private _onDragEnd(event: MatSliderDragEvent): void { + if (event.source._thumbPosition === this.thumbPosition) { + this._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 { + 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, 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; + } +} + +/** + * 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 MatSlider implements AfterViewInit, OnChanges, OnDestroy, ControlValueAccessor { - /** Event emitted when the slider value has changed. */ - @Output() readonly change: EventEmitter = new EventEmitter(); +export class MatSliderThumb implements AfterViewInit, ControlValueAccessor, OnInit { - /** Event emitted when the slider thumb moves. */ - @Output() readonly input: EventEmitter = new EventEmitter(); + // ** 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: number) { + 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(); + @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(); - /** Tabindex for the slider. */ - @Input() tabIndex: number = 0; + /** Event emitted every time the MatSliderThumb is focused. */ + @Output() readonly _focus: EventEmitter = new EventEmitter(); - /** The color palette for this slider. */ - @Input() color: ThemePalette = 'accent'; + /** Event emitted on pointer up or after left or right arrow key presses. */ + @Output() readonly change: EventEmitter = new EventEmitter(); + + /** Event emitted on each value change that happens to the slider. */ + @Output() readonly input: EventEmitter = new EventEmitter(); /** - * 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. + * 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. */ - @Input() displayWith: (value: number) => string | number; + _disabled: boolean = false; - /** The minimum value that the slider can have. */ - @Input() - get min(): number { - return this._min; + /** + * 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(); } - set min(value: number) { - this._min = coerceNumberProperty(value); + + ngAfterViewInit() { + this._initializeInputState(); + this._initializeInputValueProperty(); + + // Setup for the MDC foundation. + if (this._slider.disabled) { + this._hostElement.disabled = true; + } } - private _min = 0; - /** The maximum value that the slider can have. */ - @Input() - get max(): number { - return this._max; + _onBlur(): void { + this._onTouched(); + this._blur.emit(); } - set max(value: number) { - this._max = coerceNumberProperty(value); + + _emitFakeEvent(type: 'change'|'input') { + const event = new Event(type) as any; + event._matIsHandled = true; + this._hostElement.dispatchEvent(event); } - 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; + /** + * Sets the model value. Implemented as part of ControlValueAccessor. + * @param value + */ + writeValue(value: any): void { + this.value = value; } - set value(value: number|null) { - this._value = coerceNumberProperty(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; } - private _value: number|null = null; - /** The values at which the thumb will snap. */ - @Input() - get step(): number { - return this._step; + /** + * 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; } - set step(v: number) { - this._step = coerceNumberProperty(v, this._step); + + /** + * 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; } - 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). + * 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. + * */ - @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; + _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; } } - private _tickInterval: number|'auto' = 0; - /** Whether or not to show the thumb label. */ + static ngAcceptInputType_value: NumberInput; +} + +// Boilerplate for applying mixins to MatSlider. +/** @docs-private */ +class MatSliderBase { + constructor(public _elementRef: ElementRef) {} +} +const _MatSliderMixinBase: + CanColorCtor & + CanDisableRippleCtor & + typeof MatSliderBase = + mixinColor(mixinDisableRipple(MatSliderBase), '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', + }, + 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: boolean) { + 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: boolean) { 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: boolean) { this._showTickMarks = coerceBooleanProperty(v); } + private _showTickMarks: boolean = false; + + /** The minimum value that the slider can have. */ @Input() - get thumbLabel(): boolean { - return this._thumbLabel; + get min(): number { return this._min; } + set min(v: number) { + this._min = coerceNumberProperty(v, this._min); + this._reinitialize(); } - set thumbLabel(value: boolean) { - this._thumbLabel = coerceBooleanProperty(value); + private _min: number = 0; + + /** The maximum value that the slider can have. */ + @Input() + get max(): number { return this._max; } + set max(v: number) { + this._max = coerceNumberProperty(v, this._max); + this._reinitialize(); } - private _thumbLabel: boolean = false; + private _max: number = 100; - /** Whether the slider is disabled. */ + /** The values at which the thumb will snap. */ @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: string) => false, - addClass: (_className: string) => {}, - removeClass: (_className: string) => {}, - getAttribute: (_attribute: string) => null, - addThumbClass: (_className: string, _thumb: Thumb) => {}, - removeThumbClass: (_className: string, _thumb: Thumb) => {}, - getThumbKnobWidth: (_thumb: Thumb) => 0, - getThumbBoundingClientRect: (_thumb: Thumb) => null!, - getBoundingClientRect: () => null!, - isRTL: () => false, - setThumbStyleProperty: (_propertyName: string, _value: string, _thumb: Thumb) => {}, - removeThumbStyleProperty: (_propertyName: string, _thumb: Thumb) => {}, - setTrackActiveStyleProperty: (_propertyName: string, _value: string) => {}, - setValueIndicatorText: (_value: number, _thumb: Thumb) => {}, - updateTickMarks: () => {}, - setPointerCapture: (_pointerId: number) => {}, - emitChangeEvent: (_value: number, _thumb: Thumb) => {}, - emitInputEvent: (_value: number, _thumb: Thumb) => {}, - registerEventHandler: () => {}, - deregisterEventHandler: () => {}, - registerThumbEventHandler: () => {}, - deregisterThumbEventHandler: () => {}, - registerBodyEventHandler: () => {}, - deregisterBodyEventHandler: () => {}, - registerWindowEventHandler: () => {}, - deregisterWindowEventHandler: () => {}, - removeTrackActiveStyleProperty: (_propertyName: string) => {}, - emitDragStartEvent: (_value: number, _thumb: Thumb) => {}, - emitDragEndEvent: (_value: number, _thumb: Thumb) => {}, - getValueToAriaValueTextFn: () => null, - getInputValue: () => '', - setInputValue: (_value: string, _thumb: Thumb) => {}, - getInputAttribute: (_attribute: string, _thumb: Thumb) => null, - setInputAttribute: (_attribute: string, _value: string) => {}, - removeInputAttribute: (_attribute: string) => {}, - focusInput: () => {}, - isInputFocused: (_thumb: Thumb) => false, - registerInputEventHandler: (_thumb: Thumb, _evtType: string, _handler: any) => {}, - deregisterInputEventHandler: (_thumb: Thumb, _evtType: string, _handler: any) => {}, - }; + get step(): number { return this._step; } + set step(v: number) { + 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(this._sliderAdapter); + private _foundation = new MDCSliderFoundation(new SliderAdapter(this)); + + /** Whether the foundation has been initialized. */ + _initialized: boolean = false; - /** Whether the MDC foundation has been initialized. */ - private _isInitialized = false; + /** The injected document if available or fallback to the global document reference. */ + _document: Document; - /** Function that notifies the control value accessor about a value change. */ - private _controlValueAccessorChangeFn: (value: number) => void = () => {}; + /** + * 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[]; - /** Subscription to the Directionality change EventEmitter. */ - private _dirChangeSubscription = Subscription.EMPTY; + /** The display value of the start thumb. */ + _startValueIndicatorText: string; - /** Function that marks the slider as touched. Registered via "registerOnTouch". */ - _markAsTouched: () => any = () => {}; + /** The display value of the end thumb. */ + _endValueIndicatorText: string; + + /** + * 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; - @ViewChild('thumbContainer') _thumbContainer: ElementRef; - @ViewChild('track') _track: ElementRef; - @ViewChild('pinValueMarker') _pinValueMarker: ElementRef; - @ViewChild('trackMarker') _trackMarker: ElementRef; + /** Subscription to changes to the directionality (LTR / RTL) context for the application. */ + private _dirChangeSubscription: Subscription; constructor( - private _elementRef: ElementRef, - private _ngZone: NgZone, - private _platform: Platform, - @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())); - }); + readonly _ngZone: NgZone, + readonly _cdr: ChangeDetectorRef, + readonly _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) { + super(_elementRef); + this._document = document; + this._window = this._document.defaultView || window; + this._dirChangeSubscription = this._dir.change.subscribe(() => this._onDirChange()); + this._attachUISyncEventListener(); } - } ngAfterViewInit() { - this._isInitialized = true; + if (typeof ngDevMode === 'undefined' || ngDevMode) { + _validateInputs( + this._isRange(), + this._getInputElement(Thumb.START), + this._getInputElement(Thumb.END), + ); + } + if (this._platform.isBrowser) { + this._foundation.init(); + this._foundation.layout(); + this._initialized = true; + } + // 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) { - // The MDC slider foundation accesses DOM globals, so we cannot initialize the - // foundation on the server. The foundation would be needed to move the thumb - // to the proper position and to render the ticks. - // 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; - - // These bindings cannot be synced in the foundation, as the foundation is not - // initialized and they cause DOM globals to be accessed (to move the thumb) - this._syncStep(); - this._syncMax(); - this._syncMin(); - - // Note that "value" needs to be synced after "max" and "min" because otherwise - // the value will be clamped by the MDC foundation implementation. - this._syncValue(); + this._foundation.destroy(); } + this._dirChangeSubscription.unsubscribe(); + this._removeUISyncEventListener(); + } - this._syncDisabled(); + /** Returns true if the language direction for this slider element is right to left. */ + _isRTL() { + return this._dir && this._dir.value === 'rtl'; } - ngOnChanges(changes: SimpleChanges) { - if (!this._isInitialized) { - return; + /** + * 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); } + } - 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(); + /** 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); } } - ngOnDestroy() { - this._dirChangeSubscription.unsubscribe(); - // The foundation cannot be destroyed on the server, as the foundation - // has not be initialized on the server. - if (this._platform.isBrowser) { + /** 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(); } } - /** Focuses the slider. */ - focus(options?: FocusOptions) { - this._elementRef.nativeElement.focus(options); + /** 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); + }); } - /** Blurs the slider. */ - blur() { - this._elementRef.nativeElement.blur(); + /** Sets the value of a slider thumb. */ + _setValue(value: number, thumbPosition: Thumb): void { + thumbPosition === Thumb.START + ? this._foundation.setValueStart(value) + : this._foundation.setValue(value); } - /** Gets the display text of the current value. */ - get displayValue() { - if (this.displayWith) { - return this.displayWith(this.value!).toString(); + /** 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); } - 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; + /** 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; + } + } } - // TODO: disabled until we implement the new MDC slider. - /** Emits a change event and notifies the control value accessor. */ - // tslint:disable-next-line:no-unused-variable - private _emitChangeEvent(newValue: number) { - this._controlValueAccessorChangeFn(newValue); - this.valueChange.emit(newValue); - this.change.emit(this._createChangeEvent(newValue)); + /** Whether this is a ranged slider. */ + _isRange(): boolean { + return this._inputs.length === 2; } - // TODO: disabled until we implement the new MDC slider. - /** Computes the CSS background value for the track markers (aka ticks). */ - // tslint:disable-next-line:no-unused-variable - 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)`; + /** Sets the disabled state based on the disabled state of the inputs (ControlValueAccessor). */ + _updateDisabled(): void { + const disabled = this._inputs.some(input => input._disabled); + this._setDisabled(disabled); + } - 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`; - } + /** Gets the slider thumb input of the given thumb position. */ + _getInput(thumbPosition: Thumb): MatSliderThumb { + return thumbPosition === Thumb.END ? this._inputs.last : this._inputs.first; + } - // 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}`; + /** Gets the slider thumb HTML input element of the given thumb position. */ + _getInputElement(thumbPosition: Thumb): HTMLInputElement { + return this._getInput(thumbPosition)._hostElement; } - /** Method that ensures that track markers are refreshed. */ - private _refreshTrackMarkers() { - // MDC only checks whether the slider has markers once on init by looking for the - // `mdc-slider--display-markers` class in the DOM, whereas we support changing and hiding - // the markers dynamically. This is a workaround until we can get a public API for it. See: - // https://github.com/material-components/material-components-web/issues/5020 - (this._foundation as any).hasTrackMarker_ = this.tickInterval !== 0; + _getThumb(thumbPosition: Thumb): MatSliderVisualThumb { + return thumbPosition === Thumb.END ? this._thumbs.last : this._thumbs.first; + } - // TODO: disabled until we implement the new MDC slider. - // this._foundation.setupTrackMarker(); + /** Gets the slider thumb HTML element of the given thumb position. */ + _getThumbElement(thumbPosition: Thumb): HTMLElement { + return this._getThumb(thumbPosition)._getHostElement(); } - /** Syncs the "step" input value with the MDC foundation. */ - private _syncStep() { - // TODO: disabled until we implement the new MDC slider. - // this._foundation.setStep(this.step); + /** Gets the slider knob HTML element of the given thumb position. */ + _getKnobElement(thumbPosition: Thumb): HTMLElement { + return this._getThumb(thumbPosition)._getKnob(); } - /** Syncs the "max" input value with the MDC foundation. */ - private _syncMax() { - // TODO: disabled until we implement the new MDC slider. - // this._foundation.setMax(this.max); + /** + * 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(); } - /** Syncs the "min" input value with the MDC foundation. */ - private _syncMin() { - // TODO: disabled until we implement the new MDC slider. - // this._foundation.setMin(this.min); + /** Gets the value indicator text for the given thumb position. */ + _getValueIndicatorText(thumbPosition: Thumb): string { + return thumbPosition === Thumb.START + ? this._startValueIndicatorText + : this._endValueIndicatorText; } - /** Syncs the "value" input binding with the MDC foundation. */ - private _syncValue() { - // TODO: disabled until we implement the new MDC slider. - // this._foundation.setValue(this.value!); + /** 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'; } - /** Syncs the "disabled" input value with the MDC foundation. */ - private _syncDisabled() { - // TODO: disabled until we implement the new MDC slider. - // this._foundation.setDisabled(this.disabled); + /** Whether the slider thumb ripples should be disabled. */ + _isRippleDisabled(): boolean { + return this.disabled || this.disableRipple || !!this._globalRippleOptions?.disabled; } - /** Whether the slider is displayed in RTL-mode. */ - _isRtl(): boolean { - return this._dir && this._dir.value === 'rtl'; + static ngAcceptInputType_disabled: BooleanInput; + static ngAcceptInputType_discrete: BooleanInput; + static ngAcceptInputType_showTickMarks: BooleanInput; + static ngAcceptInputType_min: NumberInput; + static ngAcceptInputType_max: NumberInput; + static ngAcceptInputType_step: NumberInput; + static ngAcceptInputType_disableRipple: BooleanInput; +} + +/** 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')); } /** - * Registers a callback to be triggered when the value has changed. - * Implemented as part of ControlValueAccessor. - * @param fn Callback to be registered. + * 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. */ - registerOnChange(fn: any) { - this._controlValueAccessorChangeFn = fn; + 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 component is touched. - * Implemented as part of ControlValueAccessor. - * @param fn Callback to be registered. - */ - registerOnTouched(fn: any) { - this._markAsTouched = 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); + } } - /** - * Sets whether the component should be disabled. - * Implemented as part of ControlValueAccessor. - * @param isDisabled - */ - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - this._syncDisabled(); + /** 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 the model value. - * Implemented as part of ControlValueAccessor. - * @param value + * Returns the thumb position of the given event target. + * Returns null if the given event target does not correspond to a slider thumb input. */ - writeValue(value: any) { - this.value = value; - this._syncValue(); + 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; } - static ngAcceptInputType_min: NumberInput; - static ngAcceptInputType_max: NumberInput; - static ngAcceptInputType_value: NumberInput; - static ngAcceptInputType_step: NumberInput; - static ngAcceptInputType_tickInterval: NumberInput; - static ngAcceptInputType_thumbLabel: BooleanInput; - static ngAcceptInputType_disabled: BooleanInput; + // 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 => { + this._delegate._getInputElement(thumbPosition).setAttribute(attribute, value); + } + 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 => { + // TODO(wagnermaciel): Check if this causes issues for SSR + // once the mdc-slider is added back to the kitchen sink SSR app. + return this._delegate._getKnobElement(thumbPosition).getBoundingClientRect().width; + } + getThumbBoundingClientRect = (thumbPosition: Thumb): ClientRect => { + return this._delegate._getThumbElement(thumbPosition).getBoundingClientRect(); + } + getBoundingClientRect = (): ClientRect => { + return this._delegate._elementRef.nativeElement.getBoundingClientRect(); + } + 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 { + if (isRange) { + if (!startInputElement.hasAttribute('matSliderStartThumb')) { + _throwInvalidInputConfigurationError(); + } + if (!endInputElement.hasAttribute('matSliderEndThumb')) { + _throwInvalidInputConfigurationError(); + } + } else { + if (!endInputElement.hasAttribute('matSliderThumb')) { + _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/public-api.ts b/src/material-experimental/mdc-slider/testing/public-api.ts index e26601667fe1..3b5b997b0d34 100644 --- a/src/material-experimental/mdc-slider/testing/public-api.ts +++ b/src/material-experimental/mdc-slider/testing/public-api.ts @@ -6,5 +6,5 @@ * found in the LICENSE file at https://angular.io/license */ -export * from './slider-harness'; export {SliderHarnessFilters} from '@angular/material/slider/testing'; +export {MatSliderHarness} from './slider-harness'; diff --git a/src/material-experimental/mdc-slider/testing/slider-harness.spec.ts b/src/material-experimental/mdc-slider/testing/slider-harness.spec.ts index 7738add5be2a..cbc3d0bf18d0 100644 --- a/src/material-experimental/mdc-slider/testing/slider-harness.spec.ts +++ b/src/material-experimental/mdc-slider/testing/slider-harness.spec.ts @@ -1,14 +1,16 @@ -import {runHarnessTests} from '@angular/material/slider/testing/shared.spec'; -import {MatSliderModule} from '../index'; -import {MatSliderHarness} from './slider-harness'; +/** + * @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 + */ -// TODO: disabled until we implement the new MDC slider. -describe('MDC-based MatSliderHarness dummy' , () => it('', () => {})); +/* tslint:disable-next-line:no-unused-variable */ +import {MatSlider} from '../index'; -// tslint:disable-next-line:ban -xdescribe('MDC-based MatSliderHarness', () => { - runHarnessTests(MatSliderModule, MatSliderHarness as any, { - supportsVertical: false, - supportsInvert: false, - }); +// TODO(wagnermaciel): Implement this in a separate PR + +describe('MDC-based MatSliderHarness', () => { + it('does nothing yet', () => {}); }); diff --git a/src/material-experimental/mdc-slider/testing/slider-harness.ts b/src/material-experimental/mdc-slider/testing/slider-harness.ts index 6b32d54cde53..02ab59c0288d 100644 --- a/src/material-experimental/mdc-slider/testing/slider-harness.ts +++ b/src/material-experimental/mdc-slider/testing/slider-harness.ts @@ -6,134 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; -import {ComponentHarness, HarnessPredicate, parallel} from '@angular/cdk/testing'; -import {SliderHarnessFilters} from '@angular/material/slider/testing'; +import {ComponentHarness} from '@angular/cdk/testing'; /** 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 mat-slider with - * specific attributes. - * @param options Options for narrowing the search: - * - `selector` finds a slider whose host element matches the given selector. - * - `id` finds a slider with specific id. - * @return a `HarnessPredicate` configured with the given options. - */ - static with(options: SliderHarnessFilters = {}): HarnessPredicate { - return new HarnessPredicate(MatSliderHarness, options); - } - - private _textLabel = this.locatorForOptional('.mdc-slider__pin-value-marker'); - private _trackContainer = this.locatorFor('.mdc-slider__track-container'); - - /** Gets the slider's id. */ - async getId(): Promise { - const id = await (await this.host()).getProperty('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 null if the thumb - * label is disabled. - */ - async getDisplayValue(): Promise { - const textLabelEl = await this._textLabel(); - return textLabelEl ? textLabelEl.text() : 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'> { - // "aria-orientation" will always be set to "horizontal" for the MDC - // slider as there is no vertical slider support yet. - return (await this.host()).getAttribute('aria-orientation') as Promise<'horizontal'>; - } - - /** - * 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 { - // Need to wait for async tasks outside Angular to complete. This is necessary because - // whenever directionality changes, the slider updates the element dimensions in the next - // tick (in a timer outside of the NgZone). Since this method relies on the element - // dimensions to be updated, we wait for the delayed calculation task to complete. - await this.waitForTasksOutsideAngular(); - - const [sliderEl, trackContainer] = - await parallel(() => [this.host(), this._trackContainer()]); - let percentage = await this._calculatePercentage(value); - const {width} = await trackContainer.getDimensions(); - - // In case the slider is displayed 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. - await sliderEl.click(Math.round(width * percentage), 0); - } - - /** - * Focuses the slider and returns a void promise that indicates when the - * action is complete. - */ - async focus(): Promise { - return (await this.host()).focus(); - } - - /** - * Blurs the slider and returns a void promise that indicates when the - * action is complete. - */ - 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); - } + // TODO(wagnermaciel): Implement this in a separate PR } diff --git a/src/material-experimental/mdc-theming/_all-theme.scss b/src/material-experimental/mdc-theming/_all-theme.scss index b50de0f52882..4d9068e3afd4 100644 --- a/src/material-experimental/mdc-theming/_all-theme.scss +++ b/src/material-experimental/mdc-theming/_all-theme.scss @@ -10,6 +10,7 @@ @use '../mdc-radio/radio-theme'; @use '../mdc-select/select-theme'; @use '../mdc-slide-toggle/slide-toggle-theme'; +@use '../mdc-slider/slider-theme'; @use '../mdc-snack-bar/snack-bar-theme'; @use '../mdc-tabs/tabs-theme'; @use '../mdc-table/table-theme'; @@ -41,6 +42,7 @@ @include radio-theme.theme($theme-or-color-config); @include select-theme.theme($theme-or-color-config); @include slide-toggle-theme.theme($theme-or-color-config); + @include slider-theme.theme($theme-or-color-config); @include snack-bar-theme.theme($theme-or-color-config); @include table-theme.theme($theme-or-color-config); @include form-field-theme.theme($theme-or-color-config); diff --git a/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.html b/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.html index 0e698b8d6cab..78bd1b80548a 100644 --- a/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.html +++ b/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.html @@ -104,10 +104,6 @@

MDC slide-toggle

with a label

MDC Slider

- - - -

MDC Tabs