diff --git a/src/material-experimental/mdc-slider/harness/BUILD.bazel b/src/material-experimental/mdc-slider/harness/BUILD.bazel index fd28fde6bc19..84b2bb3c92a7 100644 --- a/src/material-experimental/mdc-slider/harness/BUILD.bazel +++ b/src/material-experimental/mdc-slider/harness/BUILD.bazel @@ -21,6 +21,7 @@ ng_test_library( ":harness", "//src/cdk-experimental/testing", "//src/cdk-experimental/testing/testbed", + "//src/material-experimental/mdc-slider", "//src/material/slider", ], ) diff --git a/src/material-experimental/mdc-slider/harness/mdc-slider-harness.ts b/src/material-experimental/mdc-slider/harness/mdc-slider-harness.ts new file mode 100644 index 000000000000..b3a8de120904 --- /dev/null +++ b/src/material-experimental/mdc-slider/harness/mdc-slider-harness.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ComponentHarness, HarnessPredicate} from '@angular/cdk-experimental/testing'; +import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; +import {SliderHarnessFilters} from './slider-harness-filters'; + +/** + * Harness for interacting with a MDC mat-slider in tests. + * @dynamic + */ +export class MatSliderHarness extends ComponentHarness { + static hostSelector = 'mat-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 any; + } + + /** + * Sets the value of the slider by clicking on the slider track. + * + * Note that in rare cases the value cannot be set to the exact specified value. This + * can happen if not every value of the slider maps to a single pixel that could be + * clicked using mouse interaction. In such cases consider using the keyboard to + * select the given value or expand the slider's size for a better user experience. + */ + async setValue(value: number): Promise { + const [sliderEl, trackContainer] = + await Promise.all([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(); + } + + /** Calculates the percentage of the given value. */ + private async _calculatePercentage(value: number) { + const [min, max] = await Promise.all([this.getMinValue(), this.getMaxValue()]); + return (value - min) / (max - min); + } +} diff --git a/src/material-experimental/mdc-slider/harness/slider-harness.spec.ts b/src/material-experimental/mdc-slider/harness/slider-harness.spec.ts index b60eae16e270..c7dfaa6b0f0c 100644 --- a/src/material-experimental/mdc-slider/harness/slider-harness.spec.ts +++ b/src/material-experimental/mdc-slider/harness/slider-harness.spec.ts @@ -1,8 +1,10 @@ import {HarnessLoader} from '@angular/cdk-experimental/testing'; import {TestbedHarnessEnvironment} from '@angular/cdk-experimental/testing/testbed'; -import {Component} from '@angular/core'; +import {Component, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {MatSliderModule} from '@angular/material/slider'; +import {MatSliderModule as MatMdcSliderModule} from '../module'; +import {MatSliderHarness as MatMdcSliderHarness} from './mdc-slider-harness'; import {MatSliderHarness} from './slider-harness'; let fixture: ComponentFixture; @@ -25,18 +27,37 @@ describe('MatSliderHarness', () => { sliderHarness = MatSliderHarness; }); - runTests(); + // Standard slider supports vertical and inverted sliders. + createTests(true, true); }); describe( 'MDC-based', () => { - // TODO: run tests for MDC based slider once implemented. + beforeEach(async () => { + await TestBed + .configureTestingModule({ + imports: [MatMdcSliderModule], + declarations: [SliderHarnessTest], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SliderHarnessTest); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + // Public APIs are the same as "MatSliderHarness", but cast is necessary because + // of different private fields. + sliderHarness = MatMdcSliderHarness as any; + }); + + // MDC slider does not support vertical or inverted sliders. + createTests(false, false); }); }); /** Shared tests to run on both the original and MDC-based sliders. */ -function runTests() { +function createTests(supportsVertical: boolean, supportsInvert: boolean) { it('should load all slider harnesses', async () => { const sliders = await loader.getAllHarnesses(sliderHarness); expect(sliders.length).toBe(3); @@ -84,7 +105,7 @@ function runTests() { it('should get display value of slider', async () => { const sliders = await loader.getAllHarnesses(sliderHarness); - expect(await sliders[0].getDisplayValue()).toBe('50'); + expect(await sliders[0].getDisplayValue()).toBe(null); expect(await sliders[1].getDisplayValue()).toBe('Null'); expect(await sliders[2].getDisplayValue()).toBe('#225'); }); @@ -93,18 +114,20 @@ function runTests() { const sliders = await loader.getAllHarnesses(sliderHarness); expect(await sliders[0].getOrientation()).toBe('horizontal'); expect(await sliders[1].getOrientation()).toBe('horizontal'); - expect(await sliders[2].getOrientation()).toBe('vertical'); + expect(await sliders[2].getOrientation()).toBe(supportsVertical ? 'vertical' : 'horizontal'); }); it('should be able to focus slider', async () => { - const [slider] = await loader.getAllHarnesses(sliderHarness); + // the first slider is disabled. + const slider = (await loader.getAllHarnesses(sliderHarness))[1]; expect(getActiveElementTagName()).not.toBe('mat-slider'); await slider.focus(); expect(getActiveElementTagName()).toBe('mat-slider'); }); it('should be able to blur slider', async () => { - const [slider] = await loader.getAllHarnesses(sliderHarness); + // the first slider is disabled. + const slider = (await loader.getAllHarnesses(sliderHarness))[1]; expect(getActiveElementTagName()).not.toBe('mat-slider'); await slider.focus(); expect(getActiveElementTagName()).toBe('mat-slider'); @@ -139,43 +162,45 @@ function runTests() { expect(await sliders[1].getValue()).toBe(80); }); - it('should be able to set value of inverted slider', async () => { + it('should get disabled state of slider', async () => { const sliders = await loader.getAllHarnesses(sliderHarness); - expect(await sliders[1].getValue()).toBe(0); - expect(await sliders[2].getValue()).toBe(225); + expect(await sliders[0].isDisabled()).toBe(true); + expect(await sliders[1].isDisabled()).toBe(false); + expect(await sliders[2].isDisabled()).toBe(false); + }); - fixture.componentInstance.invertSliders = true; - fixture.detectChanges(); + if (supportsInvert) { + it('should be able to set value of inverted slider', async () => { + const sliders = await loader.getAllHarnesses(sliderHarness); + expect(await sliders[1].getValue()).toBe(0); + expect(await sliders[2].getValue()).toBe(225); - await sliders[1].setValue(75); - await sliders[2].setValue(210); + fixture.componentInstance.invertSliders = true; + fixture.detectChanges(); - expect(await sliders[1].getValue()).toBe(75); - expect(await sliders[2].getValue()).toBe(210); - }); + await sliders[1].setValue(75); + await sliders[2].setValue(210); - it('should be able to set value of inverted slider in rtl', async () => { - const sliders = await loader.getAllHarnesses(sliderHarness); - expect(await sliders[1].getValue()).toBe(0); - expect(await sliders[2].getValue()).toBe(225); + expect(await sliders[1].getValue()).toBe(75); + expect(await sliders[2].getValue()).toBe(210); + }); - fixture.componentInstance.invertSliders = true; - fixture.componentInstance.dir = 'rtl'; - fixture.detectChanges(); + it('should be able to set value of inverted slider in rtl', async () => { + const sliders = await loader.getAllHarnesses(sliderHarness); + expect(await sliders[1].getValue()).toBe(0); + expect(await sliders[2].getValue()).toBe(225); - await sliders[1].setValue(75); - await sliders[2].setValue(210); + fixture.componentInstance.invertSliders = true; + fixture.componentInstance.dir = 'rtl'; + fixture.detectChanges(); - expect(await sliders[1].getValue()).toBe(75); - expect(await sliders[2].getValue()).toBe(210); - }); + await sliders[1].setValue(75); + await sliders[2].setValue(210); - it('should get disabled state of slider', async () => { - const sliders = await loader.getAllHarnesses(sliderHarness); - expect(await sliders[0].isDisabled()).toBe(true); - expect(await sliders[1].isDisabled()).toBe(false); - expect(await sliders[2].isDisabled()).toBe(false); - }); + expect(await sliders[1].getValue()).toBe(75); + expect(await sliders[2].getValue()).toBe(210); + }); + } } function getActiveElementTagName() { @@ -187,12 +212,12 @@ function getActiveElementTagName() {
+ [invert]="invertSliders" thumbLabel>
+ [invert]="invertSliders" thumbLabel> - ` + `, }) class SliderHarnessTest { sliderId = 'my-slider'; diff --git a/src/material-experimental/mdc-slider/harness/slider-harness.ts b/src/material-experimental/mdc-slider/harness/slider-harness.ts index 09954dba5c52..8b16a42f840c 100644 --- a/src/material-experimental/mdc-slider/harness/slider-harness.ts +++ b/src/material-experimental/mdc-slider/harness/slider-harness.ts @@ -40,9 +40,16 @@ export class MatSliderHarness extends ComponentHarness { return id !== '' ? id : null; } - /** Gets the current display value of the slider. */ - async getDisplayValue(): Promise { - return (await this._textLabel()).text(); + /** + * Gets the current display value of the slider. Returns null if the thumb + * label is disabled. + */ + async getDisplayValue(): Promise { + const [host, textLabel] = await Promise.all([this.host(), this._textLabel()]); + if (await host.hasClass('mat-slider-thumb-label-showing')) { + return textLabel.text(); + } + return null; } /** Gets the current percentage value of the slider. */ diff --git a/src/material-experimental/mdc-slider/slider.ts b/src/material-experimental/mdc-slider/slider.ts index 7396823d33ad..607f9ee52dbe 100644 --- a/src/material-experimental/mdc-slider/slider.ts +++ b/src/material-experimental/mdc-slider/slider.ts @@ -90,6 +90,9 @@ export class MatSliderChange { '[class.mdc-slider--discrete]': 'thumbLabel', '[class.mat-slider-has-ticks]': '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"', @@ -298,7 +301,7 @@ export class MatSlider implements AfterViewInit, OnChanges, OnDestroy, ControlVa this._trackMarker.nativeElement.style.setProperty( 'background', this._getTrackMarkersBackground(min, max, step)); }, - isRTL: () => this._dir && this._dir.value === 'rtl', + isRTL: () => this._isRtl(), }; /** Instance of the MDC slider foundation for this slider. */ @@ -335,7 +338,7 @@ export class MatSlider implements AfterViewInit, OnChanges, OnDestroy, ControlVa // 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())); + setTimeout(() => this._foundation.layout()); }); } } @@ -352,10 +355,12 @@ export class MatSlider implements AfterViewInit, OnChanges, OnDestroy, ControlVa (this._foundation as any).isDiscrete_ = true; this._syncStep(); - this._syncValue(); this._syncMax(); this._syncMin(); this._syncDisabled(); + // 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(); } ngOnChanges(changes: SimpleChanges) { @@ -476,6 +481,11 @@ export class MatSlider implements AfterViewInit, OnChanges, OnDestroy, ControlVa this._foundation.setDisabled(this.disabled); } + /** Whether the slider is displayed in RTL-mode. */ + _isRtl(): boolean { + return this._dir && this._dir.value === 'rtl'; + } + /** * Registers a callback to be triggered when the value has changed. * Implemented as part of ControlValueAccessor.