diff --git a/package.json b/package.json index 7fe21b6c32ec..3ff0352afe59 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "run-sequence": "^1.2.2", "sass": "^0.5.0", "strip-ansi": "^3.0.0", - "stylelint": "^6.9.0", + "stylelint": "^7.5.0", "symlink-or-copy": "^1.0.1", "ts-node": "^0.7.3", "tslint": "^3.13.0", diff --git a/src/demo-app/slider/slider-demo.html b/src/demo-app/slider/slider-demo.html index 2050cd374534..16a5816548a5 100644 --- a/src/demo-app/slider/slider-demo.html +++ b/src/demo-app/slider/slider-demo.html @@ -33,3 +33,9 @@

Slider with one-way binding

Slider with two-way binding

+ + + + + + \ No newline at end of file diff --git a/src/lib/core/coersion/number-property.spec.ts b/src/lib/core/coersion/number-property.spec.ts new file mode 100644 index 000000000000..fb0edfd2e544 --- /dev/null +++ b/src/lib/core/coersion/number-property.spec.ts @@ -0,0 +1,81 @@ +import {coerceNumberProperty} from './number-property'; + + +describe('coerceNumberProperty', () => { + it('should coerce undefined to 0 or default', () => { + expect(coerceNumberProperty(undefined)).toBe(0); + expect(coerceNumberProperty(undefined, 111)).toBe(111); + }); + + it('should coerce null to 0 or default', () => { + expect(coerceNumberProperty(null)).toBe(0); + expect(coerceNumberProperty(null, 111)).toBe(111); + }); + + it('should coerce true to 0 or default', () => { + expect(coerceNumberProperty(true)).toBe(0); + expect(coerceNumberProperty(true, 111)).toBe(111); + }); + + it('should coerce false to 0 or default', () => { + expect(coerceNumberProperty(false)).toBe(0); + expect(coerceNumberProperty(false, 111)).toBe(111); + + }); + + it('should coerce the empty string to 0 or default', () => { + expect(coerceNumberProperty('')).toBe(0); + expect(coerceNumberProperty('', 111)).toBe(111); + + }); + + it('should coerce the string "1" to 1', () => { + expect(coerceNumberProperty('1')).toBe(1); + expect(coerceNumberProperty('1', 111)).toBe(1); + }); + + it('should coerce the string "123.456" to 123.456', () => { + expect(coerceNumberProperty('123.456')).toBe(123.456); + expect(coerceNumberProperty('123.456', 111)).toBe(123.456); + }); + + it('should coerce the string "-123.456" to -123.456', () => { + expect(coerceNumberProperty('-123.456')).toBe(-123.456); + expect(coerceNumberProperty('-123.456', 111)).toBe(-123.456); + }); + + it('should coerce an arbitrary string to 0 or default', () => { + expect(coerceNumberProperty('pink')).toBe(0); + expect(coerceNumberProperty('pink', 111)).toBe(111); + }); + + it('should coerce an arbitrary string prefixed with a number to 0 or default', () => { + expect(coerceNumberProperty('123pink')).toBe(0); + expect(coerceNumberProperty('123pink', 111)).toBe(111); + }); + + it('should coerce the number 1 to 1', () => { + expect(coerceNumberProperty(1)).toBe(1); + expect(coerceNumberProperty(1, 111)).toBe(1); + }); + + it('should coerce the number 123.456 to 123.456', () => { + expect(coerceNumberProperty(123.456)).toBe(123.456); + expect(coerceNumberProperty(123.456, 111)).toBe(123.456); + }); + + it('should coerce the number -123.456 to -123.456', () => { + expect(coerceNumberProperty(-123.456)).toBe(-123.456); + expect(coerceNumberProperty(-123.456, 111)).toBe(-123.456); + }); + + it('should coerce an object to 0 or default', () => { + expect(coerceNumberProperty({})).toBe(0); + expect(coerceNumberProperty({}, 111)).toBe(111); + }); + + it('should coerce an array to 0 or default', () => { + expect(coerceNumberProperty([])).toBe(0); + expect(coerceNumberProperty([], 111)).toBe(111); + }); +}); diff --git a/src/lib/core/coersion/number-property.ts b/src/lib/core/coersion/number-property.ts new file mode 100644 index 000000000000..c23b74316d43 --- /dev/null +++ b/src/lib/core/coersion/number-property.ts @@ -0,0 +1,7 @@ +/** Coerces a data-bound value (typically a string) to a number. */ +export function coerceNumberProperty(value: any, fallbackValue = 0) { + // parseFloat(value) handles most of the cases we're interested in (it treats null, empty string, + // and other non-number values as NaN, where Number just uses 0) but it considers the string + // '123hello' to be a valid number. Therefore we also check if Number(value) is NaN. + return isNaN(parseFloat(value as any)) || isNaN(Number(value)) ? fallbackValue : Number(value); +} diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index 5802e3b40284..6c6142cf1a08 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -83,6 +83,7 @@ export * from './animation/animation'; // Coersion export {coerceBooleanProperty} from './coersion/boolean-property'; +export {coerceNumberProperty} from './coersion/number-property'; @NgModule({ diff --git a/src/lib/slider/_slider-theme.scss b/src/lib/slider/_slider-theme.scss index 28f67927e7b3..d4a196c3daf2 100644 --- a/src/lib/slider/_slider-theme.scss +++ b/src/lib/slider/_slider-theme.scss @@ -18,9 +18,8 @@ background-color: md-color($accent); } - .md-slider-thumb::after { + .md-slider-thumb { background-color: md-color($accent); - border-color: md-color($accent); } .md-slider-thumb-label { diff --git a/src/lib/slider/slider.html b/src/lib/slider/slider.html index 9d502deca0a2..538a371e3c78 100644 --- a/src/lib/slider/slider.html +++ b/src/lib/slider/slider.html @@ -1,21 +1,13 @@ -
-
-
-
-
-
-
-
-
-
-
-
- {{value}} -
-
+
+
+
+
+
+
+
+
+ {{value}}
-
+
\ No newline at end of file diff --git a/src/lib/slider/slider.scss b/src/lib/slider/slider.scss index 04243e0775df..a8ae53908b05 100644 --- a/src/lib/slider/slider.scss +++ b/src/lib/slider/slider.scss @@ -7,148 +7,135 @@ $md-slider-thickness: 48px !default; $md-slider-min-size: 128px !default; $md-slider-padding: 8px !default; -$md-slider-track-height: 2px !default; +$md-slider-track-thickness: 2px !default; $md-slider-thumb-size: 20px !default; $md-slider-thumb-default-scale: 0.7 !default; $md-slider-thumb-focus-scale: 1 !default; -$md-slider-thumb-arrow-height: 16px !default; -$md-slider-thumb-arrow-width: 28px !default; +$md-slider-thumb-arrow-gap: 12px !default; $md-slider-thumb-label-size: 28px !default; -// The thumb has to be moved down so that it appears right over the slider track when visible and -// on the slider track when not. -$md-slider-thumb-label-top: ($md-slider-thickness / 2) - - ($md-slider-thumb-default-scale * $md-slider-thumb-size / 2) - $md-slider-thumb-label-size - - $md-slider-thumb-arrow-height + 10px !default; -// Uses a container height and an item height to center an item vertically within the container. -@function center-vertically($containerHeight, $itemHeight) { - @return ($containerHeight / 2) - ($itemHeight / 2); -} +$md-slider-tick-color: rgba(0, 0, 0, 0.6) !default; +$md-slider-tick-size: 2px !default; -// Positions the thumb based on its width and height. -@mixin slider-thumb-position($width: $md-slider-thumb-size, $height: $md-slider-thumb-size) { - position: absolute; - top: center-vertically($md-slider-thickness, $height); - // This makes it so that the center of the thumb aligns with where the click was. - // This is not affected by the movement of the thumb. - left: (-$width / 2); - width: $width; - height: $height; - border-radius: max($width, $height); -} md-slider { + display: inline-block; + box-sizing: border-box; + position: relative; height: $md-slider-thickness; min-width: $md-slider-min-size; - position: relative; - padding: 0; - display: inline-block; + padding: $md-slider-padding; outline: none; vertical-align: middle; } -md-slider *, -md-slider *::after { - box-sizing: border-box; +.md-slider-track { + display: flex; + flex-grow: 1; + align-items: center; + position: relative; + top: ($md-slider-thickness - $md-slider-track-thickness) / 2 - $md-slider-padding; + height: $md-slider-track-thickness; + transition: box-shadow $swift-ease-out-duration $swift-ease-out-timing-function; } -// Exists in order to pad the slider and keep everything positioned correctly. -// Cannot be merged with the .md-slider-container. -.md-slider-wrapper { - width: 100%; - height: 100%; - padding-left: $md-slider-padding; - padding-right: $md-slider-padding; +.md-slider-has-ticks.md-slider-active .md-slider-track, +.md-slider-has-ticks:hover .md-slider-track { + box-shadow: inset (-2 * $md-slider-tick-size) 0 0 (-$md-slider-tick-size) $md-slider-tick-color; } - -// Holds the isActive and isSliding classes as well as helps with positioning the children. -// Cannot be merged with .md-slider-wrapper. -.md-slider-container { - position: relative; +.md-slider-track-fill { + flex: 0 0 50%; + height: $md-slider-track-thickness; + transition: flex-basis $swift-ease-out-duration $swift-ease-out-timing-function; } -.md-slider-track-container { - width: 100%; - position: absolute; - top: center-vertically($md-slider-thickness, $md-slider-track-height); - height: $md-slider-track-height; +.md-slider-sliding .md-slider-track-fill { + transition: none; } -.md-slider-track { +.md-slider-ticks-container { position: absolute; left: 0; - right: 0; - height: 100%; + top: 0; + height: $md-slider-track-thickness; + width: 100%; + overflow: hidden; } -.md-slider-track-fill { - transition-duration: $swift-ease-out-duration; - transition-timing-function: $swift-ease-out-timing-function; - transition-property: width, height; +.md-slider-ticks { + background: repeating-linear-gradient(to right, $md-slider-tick-color, + $md-slider-tick-color $md-slider-tick-size, transparent 0, transparent) repeat; + // Firefox doesn't draw the gradient correctly with 'to right' + // (see https://bugzilla.mozilla.org/show_bug.cgi?id=1314319). + background: -moz-repeating-linear-gradient(0.0001deg, $md-slider-tick-color, + $md-slider-tick-color $md-slider-tick-size, transparent 0, transparent) repeat; + height: $md-slider-track-thickness; + width: 100%; + opacity: 0; + transition: opacity $swift-ease-out-duration $swift-ease-out-timing-function; } -.md-slider-tick-container, .md-slider-last-tick-container { - position: absolute; - left: 0; - right: 0; - height: 100%; +.md-slider-has-ticks.md-slider-active .md-slider-ticks, +.md-slider-has-ticks:hover .md-slider-ticks { + opacity: 1; } .md-slider-thumb-container { - position: absolute; - left: 0; - top: 50%; - transform: translate3d(-50%, -50%, 0); - transition-duration: $swift-ease-out-duration; - transition-timing-function: $swift-ease-out-timing-function; - transition-property: left, bottom; -} - -.md-slider-thumb-position { - transition: transform $swift-ease-out-duration $swift-ease-out-timing-function; + flex: 0 0 auto; + position: relative; + width: 0; + height: 0; } .md-slider-thumb { - z-index: 1; - - @include slider-thumb-position($md-slider-thumb-size, $md-slider-thumb-size); + position: absolute; + left: -$md-slider-thumb-size / 2; + top: -$md-slider-thumb-size / 2; + width: $md-slider-thumb-size; + height: $md-slider-thumb-size; + border-radius: 50%; + transform-origin: 50% 50%; transform: scale($md-slider-thumb-default-scale); transition: transform $swift-ease-out-duration $swift-ease-out-timing-function; } -.md-slider-thumb::after { - content: ''; - position: absolute; - width: $md-slider-thumb-size; - height: $md-slider-thumb-size; - border-radius: max($md-slider-thumb-size, $md-slider-thumb-size); - // Separate border properties because, if you combine them into "border", it defaults to 'black'. - border-width: 3px; - border-style: solid; - transition: inherit; +.md-slider-active .md-slider-thumb { + transform: scale($md-slider-thumb-focus-scale); +} + +.md-slider-active.md-slider-thumb-label-showing .md-slider-thumb { + transform: scale(0); } .md-slider-thumb-label { display: flex; align-items: center; justify-content: center; - position: absolute; - left: -($md-slider-thumb-label-size / 2); - top: $md-slider-thumb-label-top; + left: -$md-slider-thumb-label-size / 2; + top: -($md-slider-thumb-label-size + $md-slider-thumb-arrow-gap); width: $md-slider-thumb-label-size; height: $md-slider-thumb-label-size; border-radius: 50%; - - transform: scale(0.4) translate3d(0, (-$md-slider-thumb-label-top + 10) / 0.4, 0) rotate(45deg); + transform: translateY($md-slider-thumb-label-size / 2 + $md-slider-thumb-arrow-gap) + scale(0.4) rotate(45deg); transition: 300ms $swift-ease-in-out-timing-function; transition-property: transform, border-radius; } +.md-slider-active .md-slider-thumb-label { + border-radius: 50% 50% 0; + transform: rotate(45deg); +} + +md-slider:not(.md-slider-thumb-label-showing) .md-slider-thumb-label { + display: none; +} + .md-slider-thumb-label-text { z-index: 1; font-size: 12px; @@ -158,29 +145,6 @@ md-slider *::after { transition: opacity 300ms $swift-ease-in-out-timing-function; } -.md-slider-container:not(.md-slider-thumb-label-showing) .md-slider-thumb-label { - display: none; -} - -.md-slider-active.md-slider-thumb-label-showing .md-slider-thumb { - transform: scale(0); -} - -.md-slider-sliding .md-slider-thumb-position, -.md-slider-sliding .md-slider-track-fill { - transition: none; - cursor: default; -} - -.md-slider-active .md-slider-thumb { - transform: scale($md-slider-thumb-focus-scale); -} - -.md-slider-active .md-slider-thumb-label { - border-radius: 50% 50% 0; - transform: rotate(45deg); -} - .md-slider-active .md-slider-thumb-label-text { opacity: 1; } diff --git a/src/lib/slider/slider.spec.ts b/src/lib/slider/slider.spec.ts index 32dfe87a398c..320b54a9e5b2 100644 --- a/src/lib/slider/slider.spec.ts +++ b/src/lib/slider/slider.spec.ts @@ -1,6 +1,6 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {ReactiveFormsModule, FormControl} from '@angular/forms'; -import {Component, DebugElement, ViewEncapsulation} from '@angular/core'; +import {Component, DebugElement} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdSlider, MdSliderModule} from './slider'; import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; @@ -45,11 +45,7 @@ describe('MdSlider', () => { let sliderNativeElement: HTMLElement; let sliderInstance: MdSlider; let trackFillElement: HTMLElement; - let trackFillDimensions: ClientRect; let sliderTrackElement: HTMLElement; - let sliderDimensions: ClientRect; - let thumbElement: HTMLElement; - let thumbDimensions: ClientRect; beforeEach(() => { fixture = TestBed.createComponent(StandardSlider); @@ -60,12 +56,7 @@ describe('MdSlider', () => { sliderInstance = sliderDebugElement.componentInstance; trackFillElement = sliderNativeElement.querySelector('.md-slider-track-fill'); - trackFillDimensions = trackFillElement.getBoundingClientRect(); sliderTrackElement = sliderNativeElement.querySelector('.md-slider-track'); - sliderDimensions = sliderTrackElement.getBoundingClientRect(); - - thumbElement = sliderNativeElement.querySelector('.md-slider-thumb-position'); - thumbDimensions = thumbElement.getBoundingClientRect(); }); it('should set the default values', () => { @@ -76,118 +67,88 @@ describe('MdSlider', () => { it('should update the value on a click', () => { expect(sliderInstance.value).toBe(0); - dispatchClickEvent(sliderNativeElement, 0.19); - // The expected value is 19 from: percentage * difference of max and min. + + dispatchClickEventSequence(sliderNativeElement, 0.19); + expect(sliderInstance.value).toBe(19); }); it('should update the value on a slide', () => { expect(sliderInstance.value).toBe(0); + dispatchSlideEventSequence(sliderNativeElement, 0, 0.89, gestureConfig); - // The expected value is 89 from: percentage * difference of max and min. + expect(sliderInstance.value).toBe(89); }); it('should set the value as min when sliding before the track', () => { expect(sliderInstance.value).toBe(0); + dispatchSlideEventSequence(sliderNativeElement, 0, -1.33, gestureConfig); + expect(sliderInstance.value).toBe(0); }); it('should set the value as max when sliding past the track', () => { expect(sliderInstance.value).toBe(0); + dispatchSlideEventSequence(sliderNativeElement, 0, 1.75, gestureConfig); + expect(sliderInstance.value).toBe(100); }); it('should update the track fill on click', () => { - expect(trackFillDimensions.width).toBe(0); - dispatchClickEvent(sliderNativeElement, 0.39); - - trackFillDimensions = trackFillElement.getBoundingClientRect(); - thumbDimensions = thumbElement.getBoundingClientRect(); + expect(trackFillElement.style.flexBasis).toBe('0%'); - // The thumb and track fill positions are relative to the viewport, so to get the thumb's - // offset relative to the track, subtract the offset on the track fill. - let thumbPosition = thumbDimensions.left - trackFillDimensions.left; - // The track fill width should be equal to the thumb's position. - expect(trackFillDimensions.width).toBe(thumbPosition); - }); - - it('should update the thumb position on click', () => { - expect(thumbDimensions.left).toBe(sliderDimensions.left); - // 50% is used here because the click event that is dispatched truncates the position and so - // a value had to be used that would not be truncated. - dispatchClickEvent(sliderNativeElement, 0.5); + dispatchClickEventSequence(sliderNativeElement, 0.39); + fixture.detectChanges(); - thumbDimensions = thumbElement.getBoundingClientRect(); - // The thumb position should be at 50% of the slider's width + the offset of the slider. - // Both the thumb and the slider are affected by this offset. - expect(thumbDimensions.left).toBe(sliderDimensions.width * 0.5 + sliderDimensions.left); + expect(trackFillElement.style.flexBasis).toBe('39%'); }); it('should update the track fill on slide', () => { - expect(trackFillDimensions.width).toBe(0); - dispatchSlideEventSequence(sliderNativeElement, 0, 0.86, gestureConfig); - - trackFillDimensions = trackFillElement.getBoundingClientRect(); - thumbDimensions = thumbElement.getBoundingClientRect(); + expect(trackFillElement.style.flexBasis).toBe('0%'); - // The thumb and track fill positions are relative to the viewport, so to get the thumb's - // offset relative to the track, subtract the offset on the track fill. - let thumbPosition = thumbDimensions.left - trackFillDimensions.left; - // The track fill width should be equal to the thumb's position. - expect(trackFillDimensions.width).toBe(thumbPosition); - }); - - it('should update the thumb position on slide', () => { - expect(thumbDimensions.left).toBe(sliderDimensions.left); - // The slide event also truncates the position passed in, so 50% is used here as well to - // ensure the ability to calculate the expected position. - dispatchSlideEventSequence(sliderNativeElement, 0, 0.5, gestureConfig); + dispatchSlideEventSequence(sliderNativeElement, 0, 0.86, gestureConfig); + fixture.detectChanges(); - thumbDimensions = thumbElement.getBoundingClientRect(); - expect(thumbDimensions.left).toBe(sliderDimensions.width * 0.5 + sliderDimensions.left); + expect(trackFillElement.style.flexBasis).toBe('86%'); }); it('should add the md-slider-active class on click', () => { - let containerElement = sliderNativeElement.querySelector('.md-slider-container'); - expect(containerElement.classList).not.toContain('md-slider-active'); + expect(sliderNativeElement.classList).not.toContain('md-slider-active'); - dispatchClickEvent(sliderNativeElement, 0.23); + dispatchClickEventSequence(sliderNativeElement, 0.23); fixture.detectChanges(); - expect(containerElement.classList).toContain('md-slider-active'); + expect(sliderNativeElement.classList).toContain('md-slider-active'); }); it('should remove the md-slider-active class on blur', () => { - let containerElement = sliderNativeElement.querySelector('.md-slider-container'); - - dispatchClickEvent(sliderNativeElement, 0.95); + dispatchClickEventSequence(sliderNativeElement, 0.95); fixture.detectChanges(); - expect(containerElement.classList).toContain('md-slider-active'); + expect(sliderNativeElement.classList).toContain('md-slider-active'); // Call the `onBlur` handler directly because we cannot simulate a focus event in unit tests. - sliderInstance.onBlur(); + sliderInstance._onBlur(); fixture.detectChanges(); - expect(containerElement.classList).not.toContain('md-slider-active'); + expect(sliderNativeElement.classList).not.toContain('md-slider-active'); }); it('should add and remove the md-slider-sliding class when sliding', () => { - let containerElement = sliderNativeElement.querySelector('.md-slider-container'); - expect(containerElement.classList).not.toContain('md-slider-sliding'); + expect(sliderNativeElement.classList).not.toContain('md-slider-sliding'); dispatchSlideStartEvent(sliderNativeElement, 0, gestureConfig); fixture.detectChanges(); - expect(containerElement.classList).toContain('md-slider-sliding'); + expect(sliderNativeElement.classList).toContain('md-slider-sliding'); dispatchSlideEndEvent(sliderNativeElement, 0.34, gestureConfig); fixture.detectChanges(); - expect(containerElement.classList).not.toContain('md-slider-sliding'); + expect(sliderNativeElement.classList).not.toContain('md-slider-sliding'); }); }); @@ -214,34 +175,36 @@ describe('MdSlider', () => { it('should not change the value on click when disabled', () => { expect(sliderInstance.value).toBe(0); - dispatchClickEvent(sliderNativeElement, 0.63); + + dispatchClickEventSequence(sliderNativeElement, 0.63); + expect(sliderInstance.value).toBe(0); }); it('should not change the value on slide when disabled', () => { expect(sliderInstance.value).toBe(0); + dispatchSlideEventSequence(sliderNativeElement, 0, 0.5, gestureConfig); + expect(sliderInstance.value).toBe(0); }); it('should not add the md-slider-active class on click when disabled', () => { - let containerElement = sliderNativeElement.querySelector('.md-slider-container'); - expect(containerElement.classList).not.toContain('md-slider-active'); + expect(sliderNativeElement.classList).not.toContain('md-slider-active'); - dispatchClickEvent(sliderNativeElement, 0.43); + dispatchClickEventSequence(sliderNativeElement, 0.43); fixture.detectChanges(); - expect(containerElement.classList).not.toContain('md-slider-active'); + expect(sliderNativeElement.classList).not.toContain('md-slider-active'); }); it('should not add the md-slider-sliding class on slide when disabled', () => { - let containerElement = sliderNativeElement.querySelector('.md-slider-container'); - expect(containerElement.classList).not.toContain('md-slider-sliding'); + expect(sliderNativeElement.classList).not.toContain('md-slider-sliding'); dispatchSlideStartEvent(sliderNativeElement, 0.46, gestureConfig); fixture.detectChanges(); - expect(containerElement.classList).not.toContain('md-slider-sliding'); + expect(sliderNativeElement.classList).not.toContain('md-slider-sliding'); }); }); @@ -251,10 +214,9 @@ describe('MdSlider', () => { let sliderNativeElement: HTMLElement; let sliderInstance: MdSlider; let sliderTrackElement: HTMLElement; - let sliderDimensions: ClientRect; let trackFillElement: HTMLElement; - let thumbElement: HTMLElement; - let tickContainerElement: HTMLElement; + let ticksContainerElement: HTMLElement; + let ticksElement: HTMLElement; let testComponent: SliderWithMinAndMax; beforeEach(() => { @@ -266,11 +228,10 @@ describe('MdSlider', () => { sliderNativeElement = sliderDebugElement.nativeElement; sliderInstance = sliderDebugElement.injector.get(MdSlider); sliderTrackElement = sliderNativeElement.querySelector('.md-slider-track'); - sliderDimensions = sliderTrackElement.getBoundingClientRect(); trackFillElement = sliderNativeElement.querySelector('.md-slider-track-fill'); - thumbElement = sliderNativeElement.querySelector('.md-slider-thumb-position'); - tickContainerElement = - sliderNativeElement.querySelector('.md-slider-tick-container'); + ticksContainerElement = + sliderNativeElement.querySelector('.md-slider-ticks-container'); + ticksElement = sliderNativeElement.querySelector('.md-slider-ticks'); }); it('should set the default values from the attributes', () => { @@ -280,7 +241,9 @@ describe('MdSlider', () => { }); it('should set the correct value on click', () => { - dispatchClickEvent(sliderNativeElement, 0.09); + dispatchClickEventSequence(sliderNativeElement, 0.09); + fixture.detectChanges(); + // Computed by multiplying the difference between the min and the max by the percentage from // the click and adding that to the minimum. let value = Math.round(4 + (0.09 * (6 - 4))); @@ -289,69 +252,59 @@ describe('MdSlider', () => { it('should set the correct value on slide', () => { dispatchSlideEventSequence(sliderNativeElement, 0, 0.62, gestureConfig); + fixture.detectChanges(); + // Computed by multiplying the difference between the min and the max by the percentage from // the click and adding that to the minimum. let value = Math.round(4 + (0.62 * (6 - 4))); expect(sliderInstance.value).toBe(value); }); - it('should snap the thumb and fill to the nearest value on click', () => { - dispatchClickEvent(sliderNativeElement, 0.68); + it('should snap the fill to the nearest value on click', () => { + dispatchClickEventSequence(sliderNativeElement, 0.68); fixture.detectChanges(); - let trackFillDimensions = trackFillElement.getBoundingClientRect(); - let thumbDimensions = thumbElement.getBoundingClientRect(); - let thumbPosition = thumbDimensions.left - trackFillDimensions.left; - // The closest snap is halfway on the slider. - expect(thumbDimensions.left).toBe(sliderDimensions.width * 0.5 + sliderDimensions.left); - expect(trackFillDimensions.width).toBe(thumbPosition); + expect(trackFillElement.style.flexBasis).toBe('50%'); }); - it('should snap the thumb and fill to the nearest value on slide', () => { + it('should snap the fill to the nearest value on slide', () => { dispatchSlideEventSequence(sliderNativeElement, 0, 0.74, gestureConfig); fixture.detectChanges(); - let trackFillDimensions = trackFillElement.getBoundingClientRect(); - let thumbDimensions = thumbElement.getBoundingClientRect(); - let thumbPosition = thumbDimensions.left - trackFillDimensions.left; - // The closest snap is at the halfway point on the slider. - expect(thumbDimensions.left).toBe(sliderDimensions.left + sliderDimensions.width * 0.5); - expect(trackFillDimensions.width).toBe(thumbPosition); + expect(trackFillElement.style.flexBasis).toBe('50%'); }); - it('should adjust thumb and ticks when min changes', () => { + it('should adjust fill and ticks on mouse enter when min changes', () => { testComponent.min = -2; fixture.detectChanges(); - let trackFillDimensions = trackFillElement.getBoundingClientRect(); - let tickContainerDimensions = tickContainerElement.getBoundingClientRect(); + dispatchMouseenterEvent(sliderNativeElement); + fixture.detectChanges(); - expect(trackFillDimensions.width).toBe(sliderDimensions.width * 6 / 8); - expect(tickContainerDimensions.width) - .toBe(sliderDimensions.width - sliderDimensions.width * 6 / 8); - expect(tickContainerElement.style.background) - .toContain(`repeating-linear-gradient(to right, black, black 2px, transparent 2px, ` + - `transparent ${sliderDimensions.width * 6 / 8 - 1}px)`); + expect(trackFillElement.style.flexBasis).toBe('75%'); + expect(ticksElement.style.backgroundSize).toBe('75% 2px'); + // Make sure it cuts off the last half tick interval. + expect(ticksElement.style.marginLeft).toBe('37.5%'); + expect(ticksContainerElement.style.marginLeft).toBe('-37.5%'); }); - it('should adjust thumb and ticks when max changes', () => { + it('should adjust fill and ticks on mouse enter when max changes', () => { testComponent.min = -2; fixture.detectChanges(); testComponent.max = 10; fixture.detectChanges(); - let trackFillDimensions = trackFillElement.getBoundingClientRect(); - let tickContainerDimensions = tickContainerElement.getBoundingClientRect(); + dispatchMouseenterEvent(sliderNativeElement); + fixture.detectChanges(); - expect(trackFillDimensions.width).toBe(sliderDimensions.width * 6 / 12); - expect(tickContainerDimensions.width) - .toBe(sliderDimensions.width - sliderDimensions.width * 6 / 12); - expect(tickContainerElement.style.background) - .toContain(`repeating-linear-gradient(to right, black, black 2px, transparent 2px, ` + - `transparent ${sliderDimensions.width * 6 / 12 - 1}px)`); + expect(trackFillElement.style.flexBasis).toBe('50%'); + expect(ticksElement.style.backgroundSize).toBe('50% 2px'); + // Make sure it cuts off the last half tick interval. + expect(ticksElement.style.marginLeft).toBe('25%'); + expect(ticksContainerElement.style.marginLeft).toBe('-25%'); }); }); @@ -377,7 +330,9 @@ describe('MdSlider', () => { }); it('should set the correct value on click', () => { - dispatchClickEvent(sliderNativeElement, 0.92); + dispatchClickEventSequence(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); @@ -385,6 +340,8 @@ describe('MdSlider', () => { it('should set the correct value on slide', () => { dispatchSlideEventSequence(sliderNativeElement, 0, 0.32, gestureConfig); + fixture.detectChanges(); + expect(sliderInstance.value).toBe(32); }); }); @@ -395,9 +352,7 @@ describe('MdSlider', () => { let sliderNativeElement: HTMLElement; let sliderInstance: MdSlider; let sliderTrackElement: HTMLElement; - let sliderDimensions: ClientRect; let trackFillElement: HTMLElement; - let thumbElement: HTMLElement; beforeEach(() => { fixture = TestBed.createComponent(SliderWithStep); @@ -407,31 +362,24 @@ describe('MdSlider', () => { sliderNativeElement = sliderDebugElement.nativeElement; sliderInstance = sliderDebugElement.injector.get(MdSlider); sliderTrackElement = sliderNativeElement.querySelector('.md-slider-track'); - sliderDimensions = sliderTrackElement.getBoundingClientRect(); trackFillElement = sliderNativeElement.querySelector('.md-slider-track-fill'); - thumbElement = sliderNativeElement.querySelector('.md-slider-thumb-position'); }); it('should set the correct step value on click', () => { expect(sliderInstance.value).toBe(0); - dispatchClickEvent(sliderNativeElement, 0.13); + dispatchClickEventSequence(sliderNativeElement, 0.13); fixture.detectChanges(); expect(sliderInstance.value).toBe(25); }); - it('should snap the thumb and fill to a step on click', () => { - dispatchClickEvent(sliderNativeElement, 0.66); + it('should snap the fill to a step on click', () => { + dispatchClickEventSequence(sliderNativeElement, 0.66); fixture.detectChanges(); - let trackFillDimensions = trackFillElement.getBoundingClientRect(); - let thumbDimensions = thumbElement.getBoundingClientRect(); - let thumbPosition = thumbDimensions.left - trackFillDimensions.left; - // The closest step is at 75% of the slider. - expect(thumbDimensions.left).toBe(sliderDimensions.width * 0.75 + sliderDimensions.left); - expect(trackFillDimensions.width).toBe(thumbPosition); + expect(trackFillElement.style.flexBasis).toBe('75%'); }); it('should set the correct step value on slide', () => { @@ -445,13 +393,8 @@ describe('MdSlider', () => { dispatchSlideEventSequence(sliderNativeElement, 0, 0.88, gestureConfig); fixture.detectChanges(); - let trackFillDimensions = trackFillElement.getBoundingClientRect(); - let thumbDimensions = thumbElement.getBoundingClientRect(); - let thumbPosition = thumbDimensions.left - trackFillDimensions.left; - // The closest snap is at the end of the slider. - expect(thumbDimensions.left).toBe(sliderDimensions.width + sliderDimensions.left); - expect(trackFillDimensions.width).toBe(thumbPosition); + expect(trackFillElement.style.flexBasis).toBe('100%'); }); }); @@ -459,8 +402,8 @@ describe('MdSlider', () => { let fixture: ComponentFixture; let sliderDebugElement: DebugElement; let sliderNativeElement: HTMLElement; - let tickContainer: HTMLElement; - let lastTickContainer: HTMLElement; + let ticksContainerElement: HTMLElement; + let ticksElement: HTMLElement; beforeEach(() => { fixture = TestBed.createComponent(SliderWithAutoTickInterval); @@ -468,34 +411,20 @@ describe('MdSlider', () => { sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider)); sliderNativeElement = sliderDebugElement.nativeElement; - tickContainer = sliderNativeElement.querySelector('.md-slider-tick-container'); - lastTickContainer = - sliderNativeElement.querySelector('.md-slider-last-tick-container'); + ticksContainerElement = + sliderNativeElement.querySelector('.md-slider-ticks-container'); + ticksElement = sliderNativeElement.querySelector('.md-slider-ticks'); }); - it('should set the correct tick separation', () => { - // The first tick mark is going to be at value 30 as it is the first step after 30px. The - // width of the slider is 112px because the minimum width is 128px with padding of 8px on - // both sides. The value 30 will be located at the position 33.6px, and 1px is removed from - // the tick mark location in order to center the tick. Therefore, the tick separation should - // be 32.6px. - // toContain is used rather than toBe because FireFox adds 'transparent' to the beginning - // of the background before the repeating linear gradient. - expect(tickContainer.style.background).toContain('repeating-linear-gradient(to right, ' + - 'black, black 2px, transparent 2px, transparent 32.6px)'); - }); - - it('should draw a tick mark on the end of the track', () => { - expect(lastTickContainer.style.background).toContain('linear-gradient(to left, black, black' + - ' 2px, transparent 2px, transparent)'); - }); + it('should set the correct tick separation on mouse enter', () => { + dispatchMouseenterEvent(sliderNativeElement); + fixture.detectChanges(); - it('should not draw the second to last tick when it is too close to the last tick', () => { - // When the second to last tick is too close (less than half the tick separation) to the last - // one, the tick container width is cut by the tick separation, which removes the second to - // last tick. Since the width of the slider is 112px and the tick separation is 33.6px, the - // tick container width should be 78.4px (112 - 33.6). - expect(tickContainer.style.width).toBe('78.4px'); + // Ticks should be 30px apart (therefore 30% for a 100px long slider). + expect(ticksElement.style.backgroundSize).toBe('30% 2px'); + // Make sure it cuts off the last half tick interval. + expect(ticksElement.style.marginLeft).toBe('15%'); + expect(ticksContainerElement.style.marginLeft).toBe('-15%'); }); }); @@ -503,8 +432,8 @@ describe('MdSlider', () => { let fixture: ComponentFixture; let sliderDebugElement: DebugElement; let sliderNativeElement: HTMLElement; - let tickContainer: HTMLElement; - let lastTickContainer: HTMLElement; + let ticksContainerElement: HTMLElement; + let ticksElement: HTMLElement; beforeEach(() => { fixture = TestBed.createComponent(SliderWithSetTickInterval); @@ -512,22 +441,21 @@ describe('MdSlider', () => { sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider)); sliderNativeElement = sliderDebugElement.nativeElement; - tickContainer = sliderNativeElement.querySelector('.md-slider-tick-container'); - lastTickContainer = - sliderNativeElement.querySelector('.md-slider-last-tick-container'); + ticksContainerElement = + sliderNativeElement.querySelector('.md-slider-ticks-container'); + ticksElement = sliderNativeElement.querySelector('.md-slider-ticks'); }); - it('should set the correct tick separation', () => { - // The slider width is 112px, the first step is at value 18 (step of 3 * tick interval of 6), - // which is at the position 20.16px and 1px is subtracted to center, giving a tick - // separation of 19.16px. - expect(tickContainer.style.background).toContain('repeating-linear-gradient(to right, ' + - 'black, black 2px, transparent 2px, transparent 19.16px)'); - }); + it('should set the correct tick separation on mouse enter', () => { + dispatchMouseenterEvent(sliderNativeElement); + fixture.detectChanges(); - it('should draw a tick mark on the end of the track', () => { - expect(lastTickContainer.style.background).toContain('linear-gradient(to left, black, ' - + 'black 2px, transparent 2px, transparent)'); + // Ticks should be every 18 values (tickInterval of 6 * step size of 3). On a slider 100px + // long with 100 values, this is 18%. + expect(ticksElement.style.backgroundSize).toBe('18% 2px'); + // Make sure it cuts off the last half tick interval. + expect(ticksElement.style.marginLeft).toBe('9%'); + expect(ticksContainerElement.style.marginLeft).toBe('-9%'); }); }); @@ -537,7 +465,6 @@ describe('MdSlider', () => { let sliderNativeElement: HTMLElement; let sliderInstance: MdSlider; let sliderTrackElement: HTMLElement; - let sliderContainerElement: Element; let thumbLabelTextElement: Element; beforeEach(() => { @@ -548,18 +475,17 @@ describe('MdSlider', () => { sliderNativeElement = sliderDebugElement.nativeElement; sliderInstance = sliderDebugElement.componentInstance; sliderTrackElement = sliderNativeElement.querySelector('.md-slider-track'); - sliderContainerElement = sliderNativeElement.querySelector('.md-slider-container'); thumbLabelTextElement = sliderNativeElement.querySelector('.md-slider-thumb-label-text'); }); it('should add the thumb label class to the slider container', () => { - expect(sliderContainerElement.classList).toContain('md-slider-thumb-label-showing'); + expect(sliderNativeElement.classList).toContain('md-slider-thumb-label-showing'); }); it('should update the thumb label text on click', () => { expect(thumbLabelTextElement.textContent).toBe('0'); - dispatchClickEvent(sliderNativeElement, 0.13); + dispatchClickEventSequence(sliderNativeElement, 0.13); fixture.detectChanges(); // The thumb label text is set to the slider's value. These should always be the same. @@ -577,26 +503,26 @@ describe('MdSlider', () => { }); it('should show the thumb label on click', () => { - expect(sliderContainerElement.classList).not.toContain('md-slider-active'); - expect(sliderContainerElement.classList).toContain('md-slider-thumb-label-showing'); + expect(sliderNativeElement.classList).not.toContain('md-slider-active'); + expect(sliderNativeElement.classList).toContain('md-slider-thumb-label-showing'); - dispatchClickEvent(sliderNativeElement, 0.49); + dispatchClickEventSequence(sliderNativeElement, 0.49); fixture.detectChanges(); // The thumb label appears when the slider is active and the 'md-slider-thumb-label-showing' // class is applied. - expect(sliderContainerElement.classList).toContain('md-slider-thumb-label-showing'); - expect(sliderContainerElement.classList).toContain('md-slider-active'); + expect(sliderNativeElement.classList).toContain('md-slider-thumb-label-showing'); + expect(sliderNativeElement.classList).toContain('md-slider-active'); }); it('should show the thumb label on slide', () => { - expect(sliderContainerElement.classList).not.toContain('md-slider-active'); + expect(sliderNativeElement.classList).not.toContain('md-slider-active'); dispatchSlideEventSequence(sliderNativeElement, 0, 0.91, gestureConfig); fixture.detectChanges(); - expect(sliderContainerElement.classList).toContain('md-slider-thumb-label-showing'); - expect(sliderContainerElement.classList).toContain('md-slider-active'); + expect(sliderNativeElement.classList).toContain('md-slider-thumb-label-showing'); + expect(sliderNativeElement.classList).toContain('md-slider-active'); }); }); @@ -620,19 +546,19 @@ describe('MdSlider', () => { sliderTrackElement = sliderNativeElement.querySelector('.md-slider-track'); }); - it('should update the control when the value is updated', () => { + it('should not update the control when the value is updated', () => { expect(testComponent.control.value).toBe(0); sliderInstance.value = 11; fixture.detectChanges(); - expect(testComponent.control.value).toBe(11); + expect(testComponent.control.value).toBe(0); }); it('should update the control on click', () => { expect(testComponent.control.value).toBe(0); - dispatchClickEvent(sliderNativeElement, 0.76); + dispatchClickEventSequence(sliderNativeElement, 0.76); fixture.detectChanges(); expect(testComponent.control.value).toBe(76); @@ -685,8 +611,6 @@ describe('MdSlider', () => { let sliderTrackElement: HTMLElement; let testComponent: SliderWithOneWayBinding; let trackFillElement: HTMLElement; - let thumbElement: HTMLElement; - let sliderDimensions: ClientRect; beforeEach(() => { fixture = TestBed.createComponent(SliderWithOneWayBinding); @@ -699,29 +623,19 @@ describe('MdSlider', () => { sliderInstance = sliderDebugElement.injector.get(MdSlider); sliderTrackElement = sliderNativeElement.querySelector('.md-slider-track'); trackFillElement = sliderNativeElement.querySelector('.md-slider-track-fill'); - thumbElement = sliderNativeElement.querySelector('.md-slider-thumb-position'); - sliderDimensions = sliderTrackElement.getBoundingClientRect(); }); it('should initialize based on bound value', () => { - let trackFillDimensions = trackFillElement.getBoundingClientRect(); - let thumbDimensions = thumbElement.getBoundingClientRect(); - let thumbPosition = thumbDimensions.left - trackFillDimensions.left; - expect(sliderInstance.value).toBe(50); - expect(thumbPosition).toBe(sliderDimensions.width / 2); + expect(trackFillElement.style.flexBasis).toBe('50%'); }); it('should update when bound value changes', () => { testComponent.val = 75; fixture.detectChanges(); - let trackFillDimensions = trackFillElement.getBoundingClientRect(); - let thumbDimensions = thumbElement.getBoundingClientRect(); - let thumbPosition = thumbDimensions.left - trackFillDimensions.left; - expect(sliderInstance.value).toBe(75); - expect(thumbPosition).toBe(sliderDimensions.width * 3 / 4); + expect(trackFillElement.style.flexBasis).toBe('75%'); }); }); @@ -731,24 +645,17 @@ describe('MdSlider', () => { let sliderNativeElement: HTMLElement; let sliderInstance: MdSlider; let sliderTrackElement: HTMLElement; - let sliderDimensions: ClientRect; - let thumbElement: HTMLElement; - let thumbDimensions: ClientRect; + let trackFillElement: HTMLElement; beforeEach(() => { - fixture = TestBed.createComponent(SliderWithValueSmallerThanMin); fixture.detectChanges(); sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider)); sliderNativeElement = sliderDebugElement.nativeElement; sliderInstance = sliderDebugElement.componentInstance; - sliderTrackElement = sliderNativeElement.querySelector('.md-slider-track'); - sliderDimensions = sliderTrackElement.getBoundingClientRect(); - - thumbElement = sliderNativeElement.querySelector('.md-slider-thumb-position'); - thumbDimensions = thumbElement.getBoundingClientRect(); + trackFillElement = sliderNativeElement.querySelector('.md-slider-track-fill'); }); it('should set the value smaller than the min value', () => { @@ -757,9 +664,8 @@ describe('MdSlider', () => { expect(sliderInstance.max).toBe(6); }); - it('should place the thumb on the min value', () => { - thumbDimensions = thumbElement.getBoundingClientRect(); - expect(thumbDimensions.left).toBe(sliderDimensions.left); + it('should set the fill to the min value', () => { + expect(trackFillElement.style.flexBasis).toBe('0%'); }); }); @@ -769,9 +675,7 @@ describe('MdSlider', () => { let sliderNativeElement: HTMLElement; let sliderInstance: MdSlider; let sliderTrackElement: HTMLElement; - let sliderDimensions: ClientRect; - let thumbElement: HTMLElement; - let thumbDimensions: ClientRect; + let trackFillElement: HTMLElement; beforeEach(() => { fixture = TestBed.createComponent(SliderWithValueGreaterThanMax); @@ -780,13 +684,8 @@ describe('MdSlider', () => { sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider)); sliderNativeElement = sliderDebugElement.nativeElement; sliderInstance = sliderDebugElement.componentInstance; - sliderTrackElement = sliderNativeElement.querySelector('.md-slider-track'); - sliderDimensions = sliderTrackElement.getBoundingClientRect(); - - thumbElement = sliderNativeElement.querySelector('.md-slider-thumb-position'); - thumbDimensions = thumbElement.getBoundingClientRect(); - + trackFillElement = sliderNativeElement.querySelector('.md-slider-track-fill'); }); it('should set the value greater than the max value', () => { @@ -795,9 +694,8 @@ describe('MdSlider', () => { expect(sliderInstance.max).toBe(6); }); - it('should place the thumb on the max value', () => { - thumbDimensions = thumbElement.getBoundingClientRect(); - expect(thumbDimensions.left).toBe(sliderDimensions.right); + it('should set the fill to the max value', () => { + expect(trackFillElement.style.flexBasis).toBe('100%'); }); }); @@ -823,7 +721,7 @@ describe('MdSlider', () => { it('should emit change on click', () => { expect(testComponent.onChange).not.toHaveBeenCalled(); - dispatchClickEvent(sliderNativeElement, 0.2); + dispatchClickEventSequence(sliderNativeElement, 0.2); fixture.detectChanges(); expect(testComponent.onChange).toHaveBeenCalledTimes(1); @@ -841,7 +739,7 @@ describe('MdSlider', () => { it('should not emit multiple changes for same value', () => { expect(testComponent.onChange).not.toHaveBeenCalled(); - dispatchClickEvent(sliderNativeElement, 0.6); + dispatchClickEventSequence(sliderNativeElement, 0.6); dispatchSlideEventSequence(sliderNativeElement, 0.6, 0.6, gestureConfig); fixture.detectChanges(); @@ -850,26 +748,28 @@ describe('MdSlider', () => { }); }); -// The transition has to be removed in order to test the updated positions without setTimeout. -const noTransitionStyle = - '.md-slider-track-fill, .md-slider-thumb-position { transition: none !important; }'; +// Disable animations and make the slider an even 100px (+ 8px padding on either side) +// so we get nice round values in tests. +const styles = ` + md-slider { min-width: 116px !important; } + .md-slider-track-fill { transition: none !important; } +`; @Component({ template: ``, - styles: [noTransitionStyle], - encapsulation: ViewEncapsulation.None + styles: [styles], }) class StandardSlider { } @Component({ - template: `` + template: ``, + styles: [styles], }) class DisabledSlider { } @Component({ template: ``, - styles: [noTransitionStyle], - encapsulation: ViewEncapsulation.None + styles: [styles], }) class SliderWithMinAndMax { min = 4; @@ -877,60 +777,66 @@ class SliderWithMinAndMax { } @Component({ - template: `` + template: ``, + styles: [styles], }) class SliderWithValue { } @Component({ template: ``, - styles: [noTransitionStyle], - encapsulation: ViewEncapsulation.None + styles: [styles], }) class SliderWithStep { } -@Component({template: ``}) +@Component({ + template: ``, + styles: [styles], +}) class SliderWithAutoTickInterval { } -@Component({template: ``}) +@Component({ + template: ``, + styles: [styles], +}) class SliderWithSetTickInterval { } @Component({ template: ``, - styles: [noTransitionStyle], - encapsulation: ViewEncapsulation.None + styles: [styles], }) class SliderWithThumbLabel { } @Component({ - template: `` + template: ``, + styles: [styles], }) class SliderWithOneWayBinding { val = 50; } @Component({ - template: `` + template: ``, + styles: [styles], }) class SliderWithTwoWayBinding { - control = new FormControl(''); + control = new FormControl(0); } @Component({ template: ``, - styles: [noTransitionStyle], - encapsulation: ViewEncapsulation.None + styles: [styles], }) class SliderWithValueSmallerThanMin { } @Component({ template: ``, - styles: [noTransitionStyle], - encapsulation: ViewEncapsulation.None + styles: [styles], }) class SliderWithValueGreaterThanMax { } @Component({ - template: `` + template: ``, + styles: [styles], }) class SliderWithChangeHandler { onChange() { } @@ -943,12 +849,14 @@ class SliderWithChangeHandler { * @param percentage The percentage of the slider where the click should occur. Used to find the * physical location of the click. */ -function dispatchClickEvent(sliderElement: HTMLElement, percentage: number): void { +function dispatchClickEventSequence(sliderElement: HTMLElement, percentage: number): void { let trackElement = sliderElement.querySelector('.md-slider-track'); let dimensions = trackElement.getBoundingClientRect(); let y = dimensions.top; let x = dimensions.left + (dimensions.width * percentage); + dispatchMouseenterEvent(sliderElement); + let event = document.createEvent('MouseEvent'); event.initMouseEvent( 'click', true, true, window, 0, x, y, x, y, false, false, false, false, 0, null); @@ -964,6 +872,7 @@ function dispatchClickEvent(sliderElement: HTMLElement, percentage: number): voi */ function dispatchSlideEventSequence(sliderElement: HTMLElement, startPercent: number, endPercent: number, gestureConfig: TestGestureConfig): void { + dispatchMouseenterEvent(sliderElement); dispatchSlideStartEvent(sliderElement, startPercent, gestureConfig); dispatchSlideEvent(sliderElement, startPercent, gestureConfig); dispatchSlideEvent(sliderElement, endPercent, gestureConfig); @@ -1023,3 +932,22 @@ function dispatchSlideEndEvent(sliderElement: HTMLElement, percent: number, srcEvent: { preventDefault: jasmine.createSpy('preventDefault') } }); } + +/** + * Dispatches a mouseenter event from an element. + * Note: The mouse event truncates the position for the click. + * @param trackElement The track element from which the event location will be calculated. + * @param containerElement The container element from which the event will be dispatched. + * @param percentage The percentage of the slider where the click should occur. Used to find the + * physical location of the click. + */ +function dispatchMouseenterEvent(element: HTMLElement): void { + let dimensions = element.getBoundingClientRect(); + let y = dimensions.top; + let x = dimensions.left; + + let event = document.createEvent('MouseEvent'); + event.initMouseEvent( + 'mouseenter', true, true, window, 0, x, y, x, y, false, false, false, false, 0, null); + element.dispatchEvent(event); +} diff --git a/src/lib/slider/slider.ts b/src/lib/slider/slider.ts index c86bc0b5c654..ac7f0c6912bc 100644 --- a/src/lib/slider/slider.ts +++ b/src/lib/slider/slider.ts @@ -1,19 +1,17 @@ import { - NgModule, - ModuleWithProviders, - Component, - ElementRef, - EventEmitter, - HostBinding, - Input, - Output, - ViewEncapsulation, - AfterContentInit, - forwardRef, + NgModule, + ModuleWithProviders, + Component, + ElementRef, + Input, + Output, + ViewEncapsulation, + forwardRef, + EventEmitter, } from '@angular/core'; import {NG_VALUE_ACCESSOR, ControlValueAccessor, FormsModule} from '@angular/forms'; import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; -import {MdGestureConfig, applyCssTransform, coerceBooleanProperty} from '../core'; +import {MdGestureConfig, coerceBooleanProperty, coerceNumberProperty} from '../core'; import {Input as HammerInput} from 'hammerjs'; /** @@ -43,30 +41,38 @@ export class MdSliderChange { selector: 'md-slider', providers: [MD_SLIDER_VALUE_ACCESSOR], host: { + '(blur)': '_onBlur()', + '(click)': '_onClick($event)', + '(mouseenter)': '_onMouseenter()', + '(slide)': '_onSlide($event)', + '(slideend)': '_onSlideEnd()', + '(slidestart)': '_onSlideStart($event)', 'tabindex': '0', - '(click)': 'onClick($event)', - '(slide)': 'onSlide($event)', - '(slidestart)': 'onSlideStart($event)', - '(slideend)': 'onSlideEnd()', - '(window:resize)': 'onResize()', - '(blur)': 'onBlur()', + '[attr.aria-disabled]': 'disabled', + '[attr.aria-valuemax]': 'max', + '[attr.aria-valuemin]': 'min', + '[attr.aria-valuenow]': 'value', + '[class.md-slider-active]': '_isActive', + '[class.md-slider-disabled]': 'disabled', + '[class.md-slider-has-ticks]': 'tickInterval', + '[class.md-slider-sliding]': '_isSliding', + '[class.md-slider-thumb-label-showing]': 'thumbLabel', }, templateUrl: 'slider.html', styleUrls: ['slider.css'], encapsulation: ViewEncapsulation.None, }) -export class MdSlider implements AfterContentInit, ControlValueAccessor { +export class MdSlider implements ControlValueAccessor { /** A renderer to handle updating the slider's thumb and fill track. */ private _renderer: SliderRenderer = null; /** The dimensions of the slider. */ private _sliderDimensions: ClientRect = null; + /** Whether or not the slider is disabled. */ private _disabled: boolean = false; @Input() - @HostBinding('class.md-slider-disabled') - @HostBinding('attr.aria-disabled') get disabled(): boolean { return this._disabled; } set disabled(value) { this._disabled = coerceBooleanProperty(value); } @@ -77,16 +83,7 @@ export class MdSlider implements AfterContentInit, ControlValueAccessor { get thumbLabel(): boolean { return this._thumbLabel; } set thumbLabel(value) { this._thumbLabel = coerceBooleanProperty(value); } - /** The miniumum value that the slider can have. */ - private _min: number = 0; - - /** The maximum value that the slider can have. */ - private _max: number = 100; - - /** The percentage of the slider that coincides with the value. */ - private _percent: number = 0; - - private _controlValueAccessorChangeFn: (value: any) => void = (value) => {}; + private _controlValueAccessorChangeFn: (value: any) => void = () => {}; /** The last value for which a change event was emitted. */ private _lastEmittedValue: number = null; @@ -94,82 +91,106 @@ export class MdSlider implements AfterContentInit, ControlValueAccessor { /** onTouch function registered via registerOnTouch (ControlValueAccessor). */ onTouched: () => any = () => {}; - /** The values at which the thumb will snap. */ - @Input() step: number = 1; - - /** - * How often to show ticks. Relative to the step so that a tick always appears on a step. - * Ex: Tick interval of 4 with a step of 3 will draw a tick every 4 steps (every 12 values). - */ - @Input('tick-interval') _tickInterval: 'auto' | number; - /** * Whether or not the thumb is sliding. * Used to determine if there should be a transition for the thumb and fill track. - * TODO: internal */ - isSliding: boolean = false; + _isSliding: boolean = false; /** * Whether or not the slider is active (clicked or sliding). * Used to shrink and grow the thumb as according to the Material Design spec. - * TODO: internal */ - isActive: boolean = false; + _isActive: boolean = false; + + /** The values at which the thumb will snap. */ + private _step: number = 1; + + @Input() + get step() { return this._step; } + set step(v) { this._step = coerceNumberProperty(v, this._step); } + + /** + * 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). + */ + private _tickInterval: 'auto' | number = 0; + + @Input('tick-interval') + get tickInterval() { return this._tickInterval; } + set tickInterval(v) { + this._tickInterval = (v == 'auto') ? v : coerceNumberProperty(v, this._tickInterval); + } + + /** The size of a tick interval as a percentage of the size of the track. */ + private _tickIntervalPercent: number = 0; - /** Indicator for if the value has been set or not. */ - private _isInitialized: boolean = false; + get tickIntervalPercent() { return this._tickIntervalPercent; } + + /** The percentage of the slider that coincides with the value. */ + private _percent: number = 0; + + get percent() { return this._clamp(this._percent); } /** Value of the slider. */ - private _value: number = 0; + private _value: number = null; + + @Input() + get value() { + // If the value needs to be read and it is still uninitialized, initialize it to the min. + if (this._value === null) { + this.value = this._min; + } + return this._value; + } + set value(v: number) { + this._value = coerceNumberProperty(v, this._value); + this._percent = this._calculatePercentage(this._value); + } + + /** The miniumum value that the slider can have. */ + private _min: number = 0; @Input() - @HostBinding('attr.aria-valuemin') get min() { return this._min; } - set min(v: number) { - // This has to be forced as a number to handle the math later. - this._min = Number(v); + this._min = coerceNumberProperty(v, this._min); // If the value wasn't explicitly set by the user, set it to the min. - if (!this._isInitialized) { + if (this._value === null) { this.value = this._min; } - this.snapThumbToValue(); - this._updateTickSeparation(); + this._percent = this._calculatePercentage(this.value); } + /** The maximum value that the slider can have. */ + private _max: number = 100; + @Input() - @HostBinding('attr.aria-valuemax') get max() { return this._max; } - set max(v: number) { - this._max = Number(v); - this.snapThumbToValue(); - this._updateTickSeparation(); + this._max = coerceNumberProperty(v, this._max); + this._percent = this._calculatePercentage(this.value); } - @Input() - @HostBinding('attr.aria-valuenow') - get value() { - return this._value; + get trackFillFlexBasis() { + return this.percent * 100 + '%'; } - set value(v: number) { - // Only set the value to a valid number. v is casted to an any as we know it will come in as a - // string but it is labeled as a number which causes parseFloat to not accept it. - if (isNaN(parseFloat( v))) { - return; - } + get ticksMarginLeft() { + return this.tickIntervalPercent / 2 * 100 + '%'; + } - this._value = Number(v); - this._isInitialized = true; - this._controlValueAccessorChangeFn(this._value); - this.snapThumbToValue(); + get ticksContainerMarginLeft() { + return '-' + this.ticksMarginLeft; + } + + get ticksBackgroundSize() { + return this.tickIntervalPercent * 100 + '% 2px'; } @Output() change = new EventEmitter(); @@ -178,117 +199,81 @@ export class MdSlider implements AfterContentInit, ControlValueAccessor { this._renderer = new SliderRenderer(elementRef); } - /** - * Once the slider has rendered, grab the dimensions and update the position of the thumb and - * fill track. - * TODO: internal - */ - ngAfterContentInit() { + _onMouseenter() { + if (this.disabled) { + return; + } + + // We save the dimensions of the slider here so we can use them to update the spacing of the + // ticks and determine where on the slider click and slide events happen. this._sliderDimensions = this._renderer.getSliderDimensions(); - // This needs to be called after content init because the value can be set to the min if the - // value itself isn't set. If this happens, the control value accessor needs to be updated. - this._controlValueAccessorChangeFn(this.value); - this.snapThumbToValue(); - this._updateTickSeparation(); + this._updateTickIntervalPercent(); } - /** TODO: internal */ - onClick(event: MouseEvent) { + _onClick(event: MouseEvent) { if (this.disabled) { return; } - this.isActive = true; - this.isSliding = false; + this._isActive = true; + this._isSliding = false; this._renderer.addFocus(); - this.updateValueFromPosition(event.clientX); - this.snapThumbToValue(); + this._updateValueFromPosition(event.clientX); this._emitValueIfChanged(); } - /** TODO: internal */ - onSlide(event: HammerInput) { + _onSlide(event: HammerInput) { if (this.disabled) { return; } // Prevent the slide from selecting anything else. event.preventDefault(); - this.updateValueFromPosition(event.center.x); + this._updateValueFromPosition(event.center.x); } - /** TODO: internal */ - onSlideStart(event: HammerInput) { + _onSlideStart(event: HammerInput) { if (this.disabled) { return; } event.preventDefault(); - this.isSliding = true; - this.isActive = true; + this._isSliding = true; + this._isActive = true; this._renderer.addFocus(); - this.updateValueFromPosition(event.center.x); + this._updateValueFromPosition(event.center.x); } - /** TODO: internal */ - onSlideEnd() { - this.isSliding = false; - this.snapThumbToValue(); + _onSlideEnd() { + this._isSliding = false; this._emitValueIfChanged(); } - /** TODO: internal */ - onResize() { - this.isSliding = true; - this._sliderDimensions = this._renderer.getSliderDimensions(); - // Skip updating the value and position as there is no new placement. - this._renderer.updateThumbAndFillPosition(this._percent, this._sliderDimensions.width); - } - - /** TODO: internal */ - onBlur() { - this.isActive = false; + _onBlur() { + this._isActive = false; this.onTouched(); } - /** - * When the value changes without a physical position, the percentage needs to be recalculated - * independent of the physical location. - * This is also used to move the thumb to a snapped value once sliding is done. - */ - updatePercentFromValue() { - this._percent = this.calculatePercentage(this.value); - } - /** * Calculate the new value from the new physical location. The value will always be snapped. */ - updateValueFromPosition(pos: number) { + private _updateValueFromPosition(pos: number) { + if (!this._sliderDimensions) { + return; + } + let offset = this._sliderDimensions.left; let size = this._sliderDimensions.width; // The exact value is calculated from the event and used to find the closest snap value. - this._percent = this.clamp((pos - offset) / size); - let exactValue = this.calculateValue(this._percent); + let percent = this._clamp((pos - offset) / size); + let exactValue = this._calculateValue(percent); // This calculation finds the closest step by finding the closest whole number divisible by the // step relative to the min. let closestValue = Math.round((exactValue - this.min) / this.step) * this.step + this.min; // The value needs to snap to the min and max. - this.value = this.clamp(closestValue, this.min, this.max); - this._renderer.updateThumbAndFillPosition(this._percent, this._sliderDimensions.width); - } - - /** - * Snaps the thumb to the current value. - * Called after a click or drag event is over. - */ - snapThumbToValue() { - this.updatePercentFromValue(); - if (this._sliderDimensions) { - let renderedPercent = this.clamp(this._percent); - this._renderer.updateThumbAndFillPosition(renderedPercent, this._sliderDimensions.width); - } + this.value = this._clamp(closestValue, this.min, this.max); } /** Emits a change event if the current value is different from the last emitted value. */ @@ -298,97 +283,52 @@ export class MdSlider implements AfterContentInit, ControlValueAccessor { event.source = this; event.value = this.value; this.change.emit(event); + this._controlValueAccessorChangeFn(this.value); this._lastEmittedValue = this.value; } } /** - * Calculates the separation in pixels of tick marks. If there is no tick interval or the interval - * is set to something other than a number or 'auto', nothing happens. + * Updates the amount of space between ticks as a percentage of the width of the slider. */ - private _updateTickSeparation() { - if (!this._sliderDimensions) { + private _updateTickIntervalPercent() { + if (!this.tickInterval) { return; } - if (this._tickInterval == 'auto') { - this._updateAutoTickSeparation(); - } else if (Number(this._tickInterval)) { - this._updateTickSeparationFromInterval(); - } - } - - /** - * Calculates the optimal separation in pixels of tick marks based on the minimum auto tick - * separation constant. - */ - private _updateAutoTickSeparation() { - // We're looking for the multiple of step for which the separation between is greater than the - // minimum tick separation. - let sliderWidth = this._sliderDimensions.width; - - // This is the total "width" of the slider in terms of values. - let valueWidth = this.max - this.min; - - // Calculate how many values exist within 1px on the slider. - let valuePerPixel = valueWidth / sliderWidth; - - // Calculate how many values exist in the minimum tick separation (px). - let valuePerSeparation = valuePerPixel * MIN_AUTO_TICK_SEPARATION; - - // Calculate how many steps exist in this separation. This will be the lowest value you can - // multiply step by to get a separation that is greater than or equal to the minimum tick - // separation. - let stepsPerSeparation = Math.ceil(valuePerSeparation / this.step); - - // Get the percentage of the slider for which this tick would be located so we can then draw - // it on the slider. - let tickPercentage = this.calculatePercentage((this.step * stepsPerSeparation) + this.min); - // The pixel value of the tick is the percentage * the width of the slider. Use this to draw - // the ticks on the slider. - this._renderer.drawTicks(sliderWidth * tickPercentage); - } - - /** - * Calculates the separation of tick marks by finding the pixel value of the tickInterval. - */ - private _updateTickSeparationFromInterval() { - // Force tickInterval to be a number so it can be used in calculations. - let interval: number = this._tickInterval; - // Calculate the first value a tick will be located at by getting the step at which the interval - // lands and adding that to the min. - let tickValue = (this.step * interval) + this.min; - - // The percentage of the step on the slider is needed in order to calculate the pixel offset - // from the beginning of the slider. This offset is the tick separation. - let tickPercentage = this.calculatePercentage(tickValue); - this._renderer.drawTicks(this._sliderDimensions.width * tickPercentage); + if (this.tickInterval == 'auto') { + let pixelsPerStep = this._sliderDimensions.width * this.step / (this.max - this.min); + let stepsPerTick = Math.ceil(MIN_AUTO_TICK_SEPARATION / pixelsPerStep); + let pixelsPerTick = stepsPerTick * this.step; + this._tickIntervalPercent = pixelsPerTick / (this._sliderDimensions.width); + } else { + this._tickIntervalPercent = this.tickInterval * this.step / (this.max - this.min); + } } /** * Calculates the percentage of the slider that a value is. */ - calculatePercentage(value: number) { + private _calculatePercentage(value: number) { return (value - this.min) / (this.max - this.min); } /** * Calculates the value a percentage of the slider corresponds to. */ - calculateValue(percentage: number) { - return this.min + (percentage * (this.max - this.min)); + private _calculateValue(percentage: number) { + return this.min + percentage * (this.max - this.min); } /** * Return a number between two numbers. */ - clamp(value: number, min = 0, max = 1) { + private _clamp(value: number, min = 0, max = 1) { return Math.max(min, Math.min(value, max)); } /** * Implemented as part of ControlValueAccessor. - * TODO: internal */ writeValue(value: any) { this.value = value; @@ -396,7 +336,6 @@ export class MdSlider implements AfterContentInit, ControlValueAccessor { /** * Implemented as part of ControlValueAccessor. - * TODO: internal */ registerOnChange(fn: (value: any) => void) { this._controlValueAccessorChangeFn = fn; @@ -404,15 +343,14 @@ export class MdSlider implements AfterContentInit, ControlValueAccessor { /** * Implemented as part of ControlValueAccessor. - * TODO: internal */ registerOnTouched(fn: any) { this.onTouched = fn; } /** - * Implemented as part of ControlValueAccessor - */ + * Implemented as part of ControlValueAccessor. + */ setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; } @@ -438,21 +376,6 @@ export class SliderRenderer { return trackElement.getBoundingClientRect(); } - /** - * Update the physical position of the thumb and fill track on the slider. - */ - updateThumbAndFillPosition(percent: number, width: number) { - // A container element that is used to avoid overwriting the transform on the thumb itself. - let thumbPositionElement = - this._sliderElement.querySelector('.md-slider-thumb-position'); - let fillTrackElement = this._sliderElement.querySelector('.md-slider-track-fill'); - - let position = Math.round(percent * width); - - fillTrackElement.style.width = `${position}px`; - applyCssTransform(thumbPositionElement, `translateX(${position}px)`); - } - /** * Focuses the native element. * Currently only used to allow a blur event to fire but will be used with keyboard input later. @@ -460,38 +383,6 @@ export class SliderRenderer { addFocus() { this._sliderElement.focus(); } - - /** - * Draws ticks onto the tick container. - */ - drawTicks(tickSeparation: number) { - let sliderTrackContainer = - this._sliderElement.querySelector('.md-slider-track-container'); - let tickContainerWidth = sliderTrackContainer.getBoundingClientRect().width; - let tickContainer = this._sliderElement.querySelector('.md-slider-tick-container'); - // An extra element for the last tick is needed because the linear gradient cannot be told to - // always draw a tick at the end of the gradient. To get around this, there is a second - // container for ticks that has a single tick mark on the very right edge. - let lastTickContainer = - this._sliderElement.querySelector('.md-slider-last-tick-container'); - // Subtract 1 from the tick separation to center the tick. - // TODO: Evaluate the rendering performance of using repeating background gradients. - tickContainer.style.background = `repeating-linear-gradient(to right, black, black 2px, ` + - `transparent 2px, transparent ${tickSeparation - 1}px)`; - // Add a tick to the very end by starting on the right side and adding a 2px black line. - lastTickContainer.style.background = `linear-gradient(to left, black, black 2px, transparent ` + - `2px, transparent)`; - - if (tickContainerWidth % tickSeparation < (tickSeparation / 2)) { - // If the second to last tick is too close (a separation of less than half the normal - // separation), don't show it by decreasing the width of the tick container element. - tickContainer.style.width = tickContainerWidth - tickSeparation + 'px'; - } else { - // If there is enough space for the second-to-last tick, restore the default width of the - // tick container. - tickContainer.style.width = ''; - } - } } diff --git a/stylelint-config.json b/stylelint-config.json index 28d5a47e51b1..17f3c7292822 100644 --- a/stylelint-config.json +++ b/stylelint-config.json @@ -28,7 +28,9 @@ "property-case": "lower", - "declaration-block-no-duplicate-properties": true, + "declaration-block-no-duplicate-properties": [ true, { + "ignore": ["consecutive-duplicates-with-different-values"] + }], "declaration-block-no-ignored-properties": true, "declaration-block-trailing-semicolon": "always", "declaration-block-single-line-max-declarations": 1,