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/material-experimental/mdc-slider/BUILD.bazel b/src/material-experimental/mdc-slider/BUILD.bazel index 5aeb7db3c082..cb14d0a3ba2c 100644 --- a/src/material-experimental/mdc-slider/BUILD.bazel +++ b/src/material-experimental/mdc-slider/BUILD.bazel @@ -75,8 +75,10 @@ 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", ], ) diff --git a/src/material-experimental/mdc-slider/slider-thumb.html b/src/material-experimental/mdc-slider/slider-thumb.html index da92e3e64b9c..595c373fe1f7 100644 --- a/src/material-experimental/mdc-slider/slider-thumb.html +++ b/src/material-experimental/mdc-slider/slider-thumb.html @@ -4,4 +4,4 @@
-
+
diff --git a/src/material-experimental/mdc-slider/slider.spec.ts b/src/material-experimental/mdc-slider/slider.spec.ts index e702f5243e95..8ae842f776e4 100644 --- a/src/material-experimental/mdc-slider/slider.spec.ts +++ b/src/material-experimental/mdc-slider/slider.spec.ts @@ -6,14 +6,369 @@ * found in the LICENSE file at https://angular.io/license */ +import {Platform} from '@angular/cdk/platform'; +import { + dispatchMouseEvent, + dispatchPointerEvent, + dispatchTouchEvent, +} from '@angular/cdk/testing/private'; +import {Component, DebugElement, Type} from '@angular/core'; +import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {Thumb} from '@material/slider'; +import {MatSliderModule} from './module'; +import {MatSlider, MatSliderThumb, MatSliderVisualThumb} from './slider'; -/* tslint:disable-next-line:no-unused-variable */ -import {MatSlider} from './index'; +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'); + }); -// TODO(wagnermaciel): Implement this in a separate PR + function createComponent(component: Type): ComponentFixture { + TestBed.configureTestingModule({ + imports: [MatSliderModule], + declarations: [component], + }).compileComponents(); + return TestBed.createComponent(component); + } -describe('MDC-based MatSlider' , () => { describe('standard slider', () => { - it('does nothing yet', () => {}); + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderInstance: MatSlider; + let inputInstance: MatSliderThumb; + + beforeEach(waitForAsync(() => { + fixture = createComponent(StandardSlider); + fixture.detectChanges(); + sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider)); + sliderInstance = sliderDebugElement.componentInstance; + inputInstance = sliderInstance._getInput(Thumb.END); + })); + + it('should set the default values', () => { + expect(inputInstance.value).toBe(0); + expect(sliderInstance.min).toBe(0); + expect(sliderInstance.max).toBe(100); + }); + + it('should update the value on mousedown', () => { + setValueByClick(sliderInstance, 19, platform.IOS); + expect(inputInstance.value).toBe(19); + }); + + it('should update the value on a slide', () => { + slideToValue(sliderInstance, 77, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(77); + }); + + it('should set the value as min when sliding before the track', () => { + slideToValue(sliderInstance, -1, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(0); + }); + + it('should set the value as max when sliding past the track', () => { + slideToValue(sliderInstance, 101, Thumb.END, platform.IOS); + expect(inputInstance.value).toBe(100); + }); + + it('should focus the slider input when clicking on the slider', () => { + expect(document.activeElement).not.toBe(inputInstance._hostElement); + setValueByClick(sliderInstance, 0, platform.IOS); + expect(document.activeElement).toBe(inputInstance._hostElement); + }); + }); + + describe('standard range slider', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderInstance: MatSlider; + let startInputInstance: MatSliderThumb; + let endInputInstance: MatSliderThumb; + + beforeEach(waitForAsync(() => { + fixture = createComponent(StandardRangeSlider); + fixture.detectChanges(); + 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); + }); + + it('should update the start value on a slide', () => { + slideToValue(sliderInstance, 19, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(19); + }); + + it('should update the end value on a slide', () => { + slideToValue(sliderInstance, 27, Thumb.END, platform.IOS); + expect(endInputInstance.value).toBe(27); + }); + + it('should update the start value on mousedown behind the start thumb', () => { + sliderInstance._setValue(19, Thumb.START); + setValueByClick(sliderInstance, 12, platform.IOS); + expect(startInputInstance.value).toBe(12); + }); + + it('should update the end value on mousedown in front of the end thumb', () => { + sliderInstance._setValue(27, Thumb.END); + setValueByClick(sliderInstance, 55, platform.IOS); + expect(endInputInstance.value).toBe(55); + }); + + it('should set the start value as min when sliding before the track', () => { + slideToValue(sliderInstance, -1, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(0); + }); + + it('should set the end value as max when sliding past the track', () => { + slideToValue(sliderInstance, 101, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(100); + }); + + it('should not let the start thumb slide past the end thumb', () => { + sliderInstance._setValue(50, Thumb.END); + slideToValue(sliderInstance, 75, Thumb.START, platform.IOS); + expect(startInputInstance.value).toBe(50); + }); + + it('should not let the end thumb slide before the start thumb', () => { + sliderInstance._setValue(50, Thumb.START); + slideToValue(sliderInstance, 25, Thumb.END, platform.IOS); + expect(startInputInstance.value).toBe(50); + }); + }); + + describe('ripple states', () => { + let fixture: ComponentFixture; + let inputInstance: MatSliderThumb; + let thumbInstance: MatSliderVisualThumb; + let thumbElement: HTMLElement; + let thumbX: number; + let thumbY: number; + + beforeEach(waitForAsync(() => { + 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 + ); + } + + function pointerup() { + dispatchPointerOrTouchEvent( + thumbElement, PointerEventType.POINTER_UP, thumbX, thumbY, platform.IOS + ); + } + + it('should show the hover ripple on mouseenter', fakeAsync(() => { + expect(isRippleVisible('hover')).toBe(false); + mouseenter(); + expect(isRippleVisible('hover')).toBe(true); + })); + + it('should hide the hover ripple on mouseleave', fakeAsync(() => { + mouseenter(); + mouseleave(); + expect(isRippleVisible('hover')).toBe(false); + })); + + it('should show the focus ripple on pointerdown', fakeAsync(() => { + expect(isRippleVisible('focus')).toBe(false); + pointerdown(); + expect(isRippleVisible('focus')).toBe(true); + })); + + it('should continue to show the focus ripple on pointerup', fakeAsync(() => { + pointerdown(); + pointerup(); + expect(isRippleVisible('focus')).toBe(true); + })); + + it('should hide the focus ripple on blur', fakeAsync(() => { + pointerdown(); + pointerup(); + blur(); + expect(isRippleVisible('focus')).toBe(false); + })); + + it('should show the active ripple on pointerdown', fakeAsync(() => { + expect(isRippleVisible('active')).toBe(false); + pointerdown(); + expect(isRippleVisible('active')).toBe(true); + })); + + it('should hide the active ripple on pointerup', fakeAsync(() => { + pointerdown(); + pointerup(); + expect(isRippleVisible('active')).toBe(false); + })); + + // Edge cases. + + it('should not show the hover ripple if the thumb is already focused', fakeAsync(() => { + pointerdown(); + mouseenter(); + expect(isRippleVisible('hover')).toBe(false); + })); + + it('should hide the hover ripple if the thumb is focused', fakeAsync(() => { + mouseenter(); + pointerdown(); + expect(isRippleVisible('hover')).toBe(false); + })); + + it('should not hide the focus ripple if the thumb is pressed', fakeAsync(() => { + pointerdown(); + blur(); + expect(isRippleVisible('focus')).toBe(true); + })); + + it('should not hide the hover ripple on blur if the thumb is hovered', fakeAsync(() => { + mouseenter(); + pointerdown(); + pointerup(); + blur(); + expect(isRippleVisible('hover')).toBe(true); + })); + + it('should hide the focus ripple on drag end if the thumb already lost focus', fakeAsync(() => { + pointerdown(); + blur(); + pointerup(); + expect(isRippleVisible('focus')).toBe(false); + })); }); }); + + +@Component({ + template: ` + + + + `, +}) +class StandardSlider {} + +@Component({ + template: ` + + + + + `, +}) +class StandardRangeSlider {} + +/** The pointer event types used by the MDC Slider. */ +const enum PointerEventType { + POINTER_DOWN = 'pointerdown', + POINTER_UP = 'pointerup', + POINTER_MOVE = 'pointermove', +} + +/** The touch event types used by the MDC Slider. */ +const enum TouchEventType { + TOUCH_START = 'touchstart', + TOUCH_END = 'touchend', + TOUCH_MOVE = 'touchmove', +} + +/** Clicks on the MatSlider at the coordinates corresponding to the given value. */ +function setValueByClick(slider: MatSlider, value: number, isIOS: boolean) { + const {min, max} = slider; + const percent = (value - min) / (max - min); + + const sliderElement = slider._elementRef.nativeElement; + const {top, left, width, height} = sliderElement.getBoundingClientRect(); + const x = left + (width * percent); + const y = top + (height / 2); + + 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 {min, max} = slider; + const percent = (value - min) / (max - min); + + const sliderElement = slider._elementRef.nativeElement; + const thumbElement = slider._getThumbElement(thumbPosition); + + const sliderDimensions = sliderElement.getBoundingClientRect(); + let thumbDimensions = thumbElement.getBoundingClientRect(); + + const startX = thumbDimensions.left + (thumbDimensions.width / 2); + const startY = thumbDimensions.top + (thumbDimensions.height / 2); + + const endX = sliderDimensions.left + (sliderDimensions.width * percent); + const endY = sliderDimensions.top + (sliderDimensions.height / 2); + + dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_DOWN, startX, startY, isIOS); + dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_MOVE, endX, endY, isIOS); + dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_UP, endX, endY, isIOS); +} + +/** Dispatch a pointerdown or pointerup event if supported, otherwise dispatch the touch event. */ +function dispatchPointerOrTouchEvent( + node: Node, type: PointerEventType, x: number, y: number, isIOS: boolean) { + if (isIOS) { + dispatchTouchEvent(node, pointerEventTypeToTouchEventType(type), x, y, x, y); + } else { + dispatchPointerEvent(node, type, x, y); + } +} + +/** Returns the touch event equivalent of the given pointer event. */ +function pointerEventTypeToTouchEventType(pointerEventType: PointerEventType) { + switch (pointerEventType) { + case PointerEventType.POINTER_DOWN: + return TouchEventType.TOUCH_START; + case PointerEventType.POINTER_UP: + return TouchEventType.TOUCH_END; + case PointerEventType.POINTER_MOVE: + return TouchEventType.TOUCH_MOVE; + } +} diff --git a/src/material-experimental/mdc-slider/slider.ts b/src/material-experimental/mdc-slider/slider.ts index 4d3c96343a08..90753fbaf329 100644 --- a/src/material-experimental/mdc-slider/slider.ts +++ b/src/material-experimental/mdc-slider/slider.ts @@ -23,6 +23,7 @@ import { Directive, ElementRef, EventEmitter, + forwardRef, Inject, Input, NgZone, @@ -110,7 +111,7 @@ export class MatSliderVisualThumb implements AfterViewInit, OnDestroy { constructor( private readonly _ngZone: NgZone, - private readonly _slider: MatSlider, + @Inject(forwardRef(() => MatSlider)) private readonly _slider: MatSlider, private readonly _elementRef: ElementRef) {} ngAfterViewInit() { @@ -331,9 +332,8 @@ export class MatSliderThumb implements AfterViewInit, ControlValueAccessor { constructor( @Inject(DOCUMENT) document: any, - private readonly _slider: MatSlider, - private readonly _elementRef: ElementRef, - ) { + @Inject(forwardRef(() => MatSlider)) private readonly _slider: MatSlider, + private readonly _elementRef: ElementRef) { this._document = document; this._hostElement = _elementRef.nativeElement; // By calling this in the constructor we guarantee that the sibling sliders initial value by @@ -638,7 +638,7 @@ export class MatSlider extends _MatSliderMixinBase implements AfterViewInit, OnD return this._getInput(thumbPosition)._hostElement; } - private _getThumb(thumbPosition: Thumb): MatSliderVisualThumb { + _getThumb(thumbPosition: Thumb): MatSliderVisualThumb { return thumbPosition === Thumb.END ? this._thumbs.last : this._thumbs.first; } diff --git a/src/material-experimental/mdc-theming/_all-theme.scss b/src/material-experimental/mdc-theming/_all-theme.scss index 843410f23827..83730bffb575 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'; @@ -42,6 +43,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);