From deb940f0300265ef2d44cd5a8bd31da1329315f0 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 2 Dec 2016 13:33:20 -0800 Subject: [PATCH] feat(slider): vertical mode (#1878) * Addressed comments. * PercentPipe was adding extra space before '%', so replaced it. * remove CommonModule from imports. * fix(slider): keyboard support. * prevent keyboard interaction with disabled slider. * fix comment * switch to event.keyCode * added tests * comment why default: return; * fix(slider): support for rtl and inverted sliders. * clean up demo html file * fixed tests and lint issues * added tests * started work * swap left/right arrow behavior in rtl * fixed lint issues * sorta working * works (except end tick is sometimes hidden). all the tests are probably broken T-T * fix tick glitches * all translate, no margin for tick placement * make sure the last tick appears on top of fill * fix tests * fixed test issues on mobile * fix mobile bug where slide doesn't work until after click * swap the meaning of normal and inverted for vertical sliders * vertical mode tests * fix vertical slider in ie11 * fixed lint issue --- src/demo-app/slider/slider-demo.html | 8 +- src/lib/slider/slider.html | 2 +- src/lib/slider/slider.scss | 307 +++++++++++++++++++-------- src/lib/slider/slider.spec.ts | 163 ++++++++++---- src/lib/slider/slider.ts | 116 +++++++--- 5 files changed, 436 insertions(+), 160 deletions(-) diff --git a/src/demo-app/slider/slider-demo.html b/src/demo-app/slider/slider-demo.html index 4b6c6547856..aba8619d3c6 100644 --- a/src/demo-app/slider/slider-demo.html +++ b/src/demo-app/slider/slider-demo.html @@ -35,7 +35,13 @@ Label

Inverted slider

- + + +

Vertical slider

+ + +

Inverted vertical slider

+ diff --git a/src/lib/slider/slider.html b/src/lib/slider/slider.html index e39f29ed400..2d0f628a4d8 100644 --- a/src/lib/slider/slider.html +++ b/src/lib/slider/slider.html @@ -3,7 +3,7 @@
-
+
{{value}} diff --git a/src/lib/slider/slider.scss b/src/lib/slider/slider.scss index 2b2c3f36f86..d26066cf6c2 100644 --- a/src/lib/slider/slider.scss +++ b/src/lib/slider/slider.scss @@ -23,142 +23,283 @@ $md-slider-tick-size: 2px !default; md-slider { display: inline-block; - box-sizing: border-box; position: relative; - height: $md-slider-thickness; - min-width: $md-slider-min-size; + box-sizing: border-box; padding: $md-slider-padding; outline: none; vertical-align: middle; } .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; -} - -.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; -} - -[dir='rtl'] .md-slider-has-ticks.md-slider-active .md-slider-track, -[dir='rtl'] .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; -} - -.md-slider-inverted .md-slider-track { - flex-direction: row-reverse; + position: absolute; } .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-sliding .md-slider-track-fill { - transition: none; + position: absolute; + transform-origin: 0 0; + transition: transform $swift-ease-out-duration $swift-ease-out-timing-function; } .md-slider-ticks-container { position: absolute; left: 0; top: 0; - height: $md-slider-track-thickness; - width: 100%; overflow: hidden; } -[dir='rtl'] .md-slider-ticks-container { - // translateZ(0) prevents chrome bug where overflow: hidden; doesn't work. - transform: translateZ(0) rotate(180deg); -} - .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-has-ticks.md-slider-active .md-slider-ticks, -.md-slider-has-ticks:hover .md-slider-ticks { - opacity: 1; -} - .md-slider-thumb-container { - flex: 0 0 auto; - position: relative; - width: 0; - height: 0; + position: absolute; + z-index: 1; + transition: transform $swift-ease-out-duration $swift-ease-out-timing-function; } .md-slider-thumb { position: absolute; - left: -$md-slider-thumb-size / 2; - top: -$md-slider-thumb-size / 2; + right: -$md-slider-thumb-size / 2; + bottom: -$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-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; + display: none; align-items: center; justify-content: center; position: absolute; - 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: 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; font-weight: bold; opacity: 0; - transform: rotate(-45deg); transition: opacity 300ms $swift-ease-in-out-timing-function; } -.md-slider-active .md-slider-thumb-label-text { - opacity: 1; + +// Slider sliding state. +.md-slider-sliding { + .md-slider-track-fill, + .md-slider-thumb-container { + transition: none; + } +} + + +// Slider with ticks. +.md-slider-has-ticks { + .md-slider-track::after { + content: ''; + position: absolute; + border: 0 solid $md-slider-tick-color; + opacity: 0; + transition: opacity 300ms $swift-ease-in-out-timing-function; + } + + &.md-slider-active, + &:hover { + .md-slider-track::after { + opacity: 1; + } + + .md-slider-ticks { + opacity: 1; + } + } +} + + +// Slider with thumb label. +.md-slider-thumb-label-showing { + .md-slider-thumb-label { + display: flex; + } +} + + +// Inverted slider. +.md-slider-axis-inverted { + .md-slider-track-fill { + transform-origin: 100% 100%; + } +} + + +// Active slider. +.md-slider-active { + .md-slider-thumb { + transform: scale($md-slider-thumb-focus-scale); + } + + &.md-slider-thumb-label-showing .md-slider-thumb { + transform: scale(0); + } + + .md-slider-thumb-label { + border-radius: 50% 50% 0; + } + + .md-slider-thumb-label-text { + opacity: 1; + } +} + + +// Horizontal slider. +.md-slider-horizontal { + height: $md-slider-thickness; + min-width: $md-slider-min-size; + + .md-slider-track { + height: $md-slider-track-thickness; + top: ($md-slider-thickness - $md-slider-track-thickness) / 2; + left: $md-slider-padding; + right: $md-slider-padding; + } + + .md-slider-track::after { + height: $md-slider-track-thickness; + border-left-width: $md-slider-tick-size; + right: 0; + } + + .md-slider-track-fill { + height: $md-slider-track-thickness; + width: 100%; + transform: scaleX(0); + } + + .md-slider-ticks-container { + height: $md-slider-track-thickness; + width: 100%; + } + + .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%; + } + + .md-slider-thumb-container { + width: 100%; + height: 0; + top: 50%; + } + + .md-slider-thumb-label { + right: -$md-slider-thumb-label-size / 2; + top: -($md-slider-thumb-label-size + $md-slider-thumb-arrow-gap); + transform: translateY($md-slider-thumb-label-size / 2 + $md-slider-thumb-arrow-gap) scale(0.4) + rotate(45deg); + } + + .md-slider-thumb-label-text { + transform: rotate(-45deg); + } + + &.md-slider-active { + .md-slider-thumb-label { + transform: rotate(45deg); + } + } +} + + +// Vertical slider. +.md-slider-vertical { + width: $md-slider-thickness; + min-height: $md-slider-min-size; + + .md-slider-track { + width: $md-slider-track-thickness; + top: $md-slider-padding; + bottom: $md-slider-padding; + left: ($md-slider-thickness - $md-slider-track-thickness) / 2; + } + + .md-slider-track::after { + width: $md-slider-track-thickness; + border-top-width: $md-slider-tick-size; + bottom: 0; + } + + .md-slider-track-fill { + height: 100%; + width: $md-slider-track-thickness; + transform: scaleY(0); + } + + .md-slider-ticks-container { + width: $md-slider-track-thickness; + height: 100%; + } + + .md-slider-ticks { + background: repeating-linear-gradient(to bottom, $md-slider-tick-color, + $md-slider-tick-color $md-slider-tick-size, transparent 0, transparent) repeat; + width: $md-slider-track-thickness; + height: 100%; + } + + .md-slider-thumb-container { + height: 100%; + width: 0; + left: 50%; + } + + .md-slider-thumb-label { + bottom: -$md-slider-thumb-label-size / 2; + left: -($md-slider-thumb-label-size + $md-slider-thumb-arrow-gap); + transform: translateX($md-slider-thumb-label-size / 2 + $md-slider-thumb-arrow-gap) + scale(0.4) rotate(-45deg); + } + + .md-slider-thumb-label-text { + transform: rotate(45deg); + } + + &.md-slider-active { + .md-slider-thumb-label { + transform: rotate(-45deg); + } + } +} + + +// Slider in RTL languages. +[dir='rtl'] { + .md-slider-track::after { + left: 0; + right: auto; + } + + .md-slider-horizontal { + .md-slider-track-fill { + transform-origin: 100% 100%; + } + + &.md-slider-axis-inverted { + .md-slider-track-fill { + transform-origin: 0 0; + } + } + } } diff --git a/src/lib/slider/slider.spec.ts b/src/lib/slider/slider.spec.ts index b22ebc66c76..951562e7101 100644 --- a/src/lib/slider/slider.spec.ts +++ b/src/lib/slider/slider.spec.ts @@ -38,6 +38,7 @@ describe('MdSlider', () => { SliderWithValueGreaterThanMax, SliderWithChangeHandler, SliderWithDirAndInvert, + VerticalSlider, ], providers: [ {provide: HAMMER_GESTURE_CONFIG, useFactory: () => { @@ -109,21 +110,21 @@ describe('MdSlider', () => { }); it('should update the track fill on click', () => { - expect(trackFillElement.style.flexBasis).toBe('0%'); + expect(trackFillElement.style.transform).toBe('scaleX(0)'); dispatchClickEventSequence(sliderNativeElement, 0.39); fixture.detectChanges(); - expect(trackFillElement.style.flexBasis).toBe('39%'); + expect(trackFillElement.style.transform).toBe('scaleX(0.39)'); }); it('should update the track fill on slide', () => { - expect(trackFillElement.style.flexBasis).toBe('0%'); + expect(trackFillElement.style.transform).toBe('scaleX(0)'); dispatchSlideEventSequence(sliderNativeElement, 0, 0.86, gestureConfig); fixture.detectChanges(); - expect(trackFillElement.style.flexBasis).toBe('86%'); + expect(trackFillElement.style.transform).toBe('scaleX(0.86)'); }); it('should add the md-slider-active class on click', () => { @@ -276,7 +277,7 @@ describe('MdSlider', () => { fixture.detectChanges(); // The closest snap is halfway on the slider. - expect(trackFillElement.style.flexBasis).toBe('50%'); + expect(trackFillElement.style.transform).toBe('scaleX(0.5)'); }); it('should snap the fill to the nearest value on slide', () => { @@ -284,7 +285,7 @@ describe('MdSlider', () => { fixture.detectChanges(); // The closest snap is at the halfway point on the slider. - expect(trackFillElement.style.flexBasis).toBe('50%'); + expect(trackFillElement.style.transform).toBe('scaleX(0.5)'); }); it('should adjust fill and ticks on mouse enter when min changes', () => { @@ -294,11 +295,11 @@ describe('MdSlider', () => { dispatchMouseenterEvent(sliderNativeElement); fixture.detectChanges(); - expect(trackFillElement.style.flexBasis).toBe('75%'); + expect(trackFillElement.style.transform).toBe('scaleX(0.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%'); + expect(ticksElement.style.transform).toContain('translateX(37.5%)'); + expect(ticksContainerElement.style.transform).toBe('translateX(-37.5%)'); }); it('should adjust fill and ticks on mouse enter when max changes', () => { @@ -311,11 +312,11 @@ describe('MdSlider', () => { dispatchMouseenterEvent(sliderNativeElement); fixture.detectChanges(); - expect(trackFillElement.style.flexBasis).toBe('50%'); + expect(trackFillElement.style.transform).toBe('scaleX(0.5)'); 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%'); + expect(ticksElement.style.transform).toContain('translateX(25%)'); + expect(ticksContainerElement.style.transform).toBe('translateX(-25%)'); }); }); @@ -390,7 +391,7 @@ describe('MdSlider', () => { fixture.detectChanges(); // The closest step is at 75% of the slider. - expect(trackFillElement.style.flexBasis).toBe('75%'); + expect(trackFillElement.style.transform).toBe('scaleX(0.75)'); }); it('should set the correct step value on slide', () => { @@ -405,7 +406,7 @@ describe('MdSlider', () => { fixture.detectChanges(); // The closest snap is at the end of the slider. - expect(trackFillElement.style.flexBasis).toBe('100%'); + expect(trackFillElement.style.transform).toBe('scaleX(1)'); }); }); @@ -434,8 +435,8 @@ describe('MdSlider', () => { // 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%'); + expect(ticksElement.style.transform).toContain('translateX(15%)'); + expect(ticksContainerElement.style.transform).toBe('translateX(-15%)'); }); }); @@ -465,8 +466,8 @@ describe('MdSlider', () => { // 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%'); + expect(ticksElement.style.transform).toContain('translateX(9%)'); + expect(ticksContainerElement.style.transform).toBe('translateX(-9%)'); }); }); @@ -638,7 +639,7 @@ describe('MdSlider', () => { it('should initialize based on bound value', () => { expect(sliderInstance.value).toBe(50); - expect(trackFillElement.style.flexBasis).toBe('50%'); + expect(trackFillElement.style.transform).toBe('scaleX(0.5)'); }); it('should update when bound value changes', () => { @@ -646,7 +647,7 @@ describe('MdSlider', () => { fixture.detectChanges(); expect(sliderInstance.value).toBe(75); - expect(trackFillElement.style.flexBasis).toBe('75%'); + expect(trackFillElement.style.transform).toBe('scaleX(0.75)'); }); }); @@ -676,7 +677,7 @@ describe('MdSlider', () => { }); it('should set the fill to the min value', () => { - expect(trackFillElement.style.flexBasis).toBe('0%'); + expect(trackFillElement.style.transform).toBe('scaleX(0)'); }); }); @@ -706,7 +707,7 @@ describe('MdSlider', () => { }); it('should set the fill to the max value', () => { - expect(trackFillElement.style.flexBasis).toBe('100%'); + expect(trackFillElement.style.transform).toBe('scaleX(1)'); }); }); @@ -892,25 +893,25 @@ describe('MdSlider', () => { expect(sliderInstance.value).toBe(30); }); - it('should decrement inverted slider by 1 on right arrow pressed', () => { + it('should increment inverted slider by 1 on right arrow pressed', () => { testComponent.invert = true; - sliderInstance.value = 100; fixture.detectChanges(); dispatchKeydownEvent(sliderNativeElement, RIGHT_ARROW); fixture.detectChanges(); - expect(sliderInstance.value).toBe(99); + expect(sliderInstance.value).toBe(1); }); - it('should increment inverted slider by 1 on left arrow pressed', () => { + it('should decrement inverted slider by 1 on left arrow pressed', () => { testComponent.invert = true; + sliderInstance.value = 100; fixture.detectChanges(); dispatchKeydownEvent(sliderNativeElement, LEFT_ARROW); fixture.detectChanges(); - expect(sliderInstance.value).toBe(1); + expect(sliderInstance.value).toBe(99); }); it('should decrement RTL slider by 1 on right arrow pressed', () => { @@ -934,35 +935,96 @@ describe('MdSlider', () => { expect(sliderInstance.value).toBe(1); }); - it('should increment inverted RTL slider by 1 on right arrow pressed', () => { - testComponent.dir = 'rtl'; - testComponent.invert = true; - fixture.detectChanges(); - - dispatchKeydownEvent(sliderNativeElement, RIGHT_ARROW); - fixture.detectChanges(); - - expect(sliderInstance.value).toBe(1); - }); - - it('should decrement inverted RTL slider by 1 on left arrow pressed', () => { + it('should decrement inverted RTL slider by 1 on right arrow pressed', () => { testComponent.dir = 'rtl'; testComponent.invert = true; sliderInstance.value = 100; fixture.detectChanges(); - dispatchKeydownEvent(sliderNativeElement, LEFT_ARROW); + dispatchKeydownEvent(sliderNativeElement, RIGHT_ARROW); fixture.detectChanges(); expect(sliderInstance.value).toBe(99); }); + + it('should increment inverted RTL slider by 1 on left arrow pressed', () => { + testComponent.dir = 'rtl'; + testComponent.invert = true; + fixture.detectChanges(); + + dispatchKeydownEvent(sliderNativeElement, LEFT_ARROW); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(1); + }); + }); + + describe('vertical slider', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderTrackElement: HTMLElement; + let trackFillElement: HTMLElement; + let sliderInstance: MdSlider; + let testComponent: VerticalSlider; + + beforeEach(() => { + fixture = TestBed.createComponent(VerticalSlider); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider)); + sliderInstance = sliderDebugElement.injector.get(MdSlider); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderTrackElement = sliderNativeElement.querySelector('.md-slider-track'); + trackFillElement = sliderNativeElement.querySelector('.md-slider-track-fill'); + }); + + it('updates value on click', () => { + dispatchClickEventSequence(sliderNativeElement, 0.3); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(70); + }); + + it('updates value on click in inverted mode', () => { + testComponent.invert = true; + fixture.detectChanges(); + + dispatchClickEventSequence(sliderNativeElement, 0.3); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(30); + }); + + it('should update the track fill on click', () => { + expect(trackFillElement.style.transform).toBe('scaleY(0)'); + + dispatchClickEventSequence(sliderNativeElement, 0.39); + fixture.detectChanges(); + + expect(trackFillElement.style.transform).toBe('scaleY(0.61)'); + }); + + it('should update the track fill on click in inverted mode', () => { + testComponent.invert = true; + fixture.detectChanges(); + + expect(trackFillElement.style.transform).toBe('scaleY(0)'); + + dispatchClickEventSequence(sliderNativeElement, 0.39); + fixture.detectChanges(); + + expect(trackFillElement.style.transform).toBe('scaleY(0.39)'); + }); }); }); // 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-horizontal { min-width: 116px !important; } + .md-slider-vertical { min-height: 116px !important; } .md-slider-track-fill { transition: none !important; } `; @@ -1062,6 +1124,14 @@ class SliderWithDirAndInvert { invert = false; } +@Component({ + template: ``, + styles: [styles], +}) +class VerticalSlider { + invert = false; +} + /** * Dispatches a click event sequence (consisting of moueseenter, click) from an element. * Note: The mouse event truncates the position for the click. @@ -1072,8 +1142,8 @@ class SliderWithDirAndInvert { 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); + let y = dimensions.top + (dimensions.height * percentage); dispatchMouseenterEvent(sliderElement); @@ -1110,9 +1180,10 @@ function dispatchSlideEvent(sliderElement: HTMLElement, percent: number, let trackElement = sliderElement.querySelector('.md-slider-track'); let dimensions = trackElement.getBoundingClientRect(); let x = dimensions.left + (dimensions.width * percent); + let y = dimensions.top + (dimensions.height * percent); gestureConfig.emitEventForElement('slide', sliderElement, { - center: { x: x }, + center: { x: x, y: y }, srcEvent: { preventDefault: jasmine.createSpy('preventDefault') } }); } @@ -1128,11 +1199,12 @@ function dispatchSlideStartEvent(sliderElement: HTMLElement, percent: number, let trackElement = sliderElement.querySelector('.md-slider-track'); let dimensions = trackElement.getBoundingClientRect(); let x = dimensions.left + (dimensions.width * percent); + let y = dimensions.top + (dimensions.height * percent); dispatchMouseenterEvent(sliderElement); gestureConfig.emitEventForElement('slidestart', sliderElement, { - center: { x: x }, + center: { x: x, y: y }, srcEvent: { preventDefault: jasmine.createSpy('preventDefault') } }); } @@ -1148,9 +1220,10 @@ function dispatchSlideEndEvent(sliderElement: HTMLElement, percent: number, let trackElement = sliderElement.querySelector('.md-slider-track'); let dimensions = trackElement.getBoundingClientRect(); let x = dimensions.left + (dimensions.width * percent); + let y = dimensions.top + (dimensions.height * percent); gestureConfig.emitEventForElement('slideend', sliderElement, { - center: { x: x }, + center: { x: x, y: y }, srcEvent: { preventDefault: jasmine.createSpy('preventDefault') } }); } diff --git a/src/lib/slider/slider.ts b/src/lib/slider/slider.ts index 103a6b455d5..ef72dbd47c8 100644 --- a/src/lib/slider/slider.ts +++ b/src/lib/slider/slider.ts @@ -76,9 +76,11 @@ export class MdSliderChange { '[class.md-slider-active]': '_isActive', '[class.md-slider-disabled]': 'disabled', '[class.md-slider-has-ticks]': 'tickInterval', - '[class.md-slider-inverted]': 'invert', + '[class.md-slider-horizontal]': '!vertical', + '[class.md-slider-axis-inverted]': 'invertAxis', '[class.md-slider-sliding]': '_isSliding', '[class.md-slider-thumb-label-showing]': 'thumbLabel', + '[class.md-slider-vertical]': 'vertical', }, templateUrl: 'slider.html', styleUrls: ['slider.css'], @@ -202,34 +204,80 @@ export class MdSlider implements ControlValueAccessor { /** Whether the slider is inverted. */ @Input() get invert() { return this._invert; } - set invert(value: boolean) { this._invert = coerceBooleanProperty(value); } + set invert(value: any) { this._invert = coerceBooleanProperty(value); } private _invert = false; + /** Whether the slider is vertical. */ + @Input() + get vertical() { return this._vertical; } + set vertical(value: any) { this._vertical = coerceBooleanProperty(value); } + private _vertical = false; + + /** + * Whether the axis of the slider is inverted. + * (i.e. whether moving the thumb in the positive x or y direction decreases the slider's value). + */ + get invertAxis() { + // Standard non-inverted mode for a vertical slider should be dragging the thumb from bottom to + // top. However from a y-axis standpoint this is inverted. + return this.vertical ? !this.invert : this.invert; + } + + /** + * Whether mouse events should be converted to a slider position by calculating their distance + * from the right or bottom edge of the slider as opposed to the top or left. + */ + get invertMouseCoords() { + return (this.direction == 'rtl' && !this.vertical) ? !this.invertAxis : this.invertAxis; + } + /** CSS styles for the track fill element. */ get trackFillStyles(): { [key: string]: string } { + let axis = this.vertical ? 'Y' : 'X'; return { - 'flexBasis': `${this.percent * 100}%` + 'transform': `scale${axis}(${this.percent})` }; } /** CSS styles for the ticks container element. */ get ticksContainerStyles(): { [key: string]: string } { + let axis = this.vertical ? 'Y' : 'X'; + // For a horizontal slider in RTL languages we push the ticks container off the left edge + // instead of the right edge to avoid causing a horizontal scrollbar to appear. + let sign = !this.vertical && this.direction == 'rtl' ? '' : '-'; + let offset = this.tickIntervalPercent / 2 * 100; return { - 'marginLeft': `${this.direction == 'rtl' ? '' : '-'}${this.tickIntervalPercent / 2 * 100}%` + 'transform': `translate${axis}(${sign}${offset}%)` }; } /** CSS styles for the ticks element. */ - get ticksStyles() { - let styles: { [key: string]: string } = { - 'backgroundSize': `${this.tickIntervalPercent * 100}% 2px` + get ticksStyles(): { [key: string]: string } { + let tickSize = this.tickIntervalPercent * 100; + let backgroundSize = this.vertical ? `2px ${tickSize}%` : `${tickSize}% 2px`; + let axis = this.vertical ? 'Y' : 'X'; + // Depending on the direction we pushed the ticks container, push the ticks the opposite + // direction to re-center them but clip off the end edge. In RTL languages we need to flip the + // ticks 180 degrees so we're really cutting off the end edge abd not the start. + let sign = !this.vertical && this.direction == 'rtl' ? '-' : ''; + let rotate = !this.vertical && this.direction == 'rtl' ? ' rotate(180deg)' : ''; + return { + 'backgroundSize': backgroundSize, + // Without translateZ ticks sometimes jitter as the slider moves on Chrome & Firefox. + 'transform': `translateZ(0) translate${axis}(${sign}${tickSize / 2}%)${rotate}` + }; + } + + get thumbContainerStyles(): { [key: string]: string } { + let axis = this.vertical ? 'Y' : 'X'; + // For a horizontal slider in RTL languages we push the thumb container off the left edge + // instead of the right edge to avoid causing a horizontal scrollbar to appear. + let invertOffset = + (this.direction == 'rtl' && !this.vertical) ? !this.invertAxis : this.invertAxis; + let offset = (invertOffset ? this.percent : 1 - this.percent) * 100; + return { + 'transform': `translate${axis}(-${offset}%)` }; - if (this.direction == 'rtl') { - styles['marginRight'] = `-${this.tickIntervalPercent / 2 * 100}%`; - } else { - styles['marginLeft'] = `${this.tickIntervalPercent / 2 * 100}%`; - } - return styles; } /** The language direction for this slider element. */ @@ -262,7 +310,7 @@ export class MdSlider implements ControlValueAccessor { this._isActive = true; this._isSliding = false; this._renderer.addFocus(); - this._updateValueFromPosition(event.clientX); + this._updateValueFromPosition({x: event.clientX, y: event.clientY}); this._emitValueIfChanged(); } @@ -273,7 +321,7 @@ export class MdSlider implements ControlValueAccessor { // Prevent the slide from selecting anything else. event.preventDefault(); - this._updateValueFromPosition(event.center.x); + this._updateValueFromPosition({x: event.center.x, y: event.center.y}); } _onSlideStart(event: HammerInput) { @@ -281,11 +329,14 @@ export class MdSlider implements ControlValueAccessor { return; } + // Simulate mouseenter in case this is a mobile device. + this._onMouseenter(); + event.preventDefault(); this._isSliding = true; this._isActive = true; this._renderer.addFocus(); - this._updateValueFromPosition(event.center.x); + this._updateValueFromPosition({x: event.center.x, y: event.center.y}); } _onSlideEnd() { @@ -315,13 +366,21 @@ export class MdSlider implements ControlValueAccessor { this.value = this.min; break; case LEFT_ARROW: - this._increment(this._isLeftMin() ? -1 : 1); + // NOTE: For a sighted user it would make more sense that when they press an arrow key on an + // inverted slider the thumb moves in that direction. However for a blind user, nothing + // about the slider indicates that it is inverted. They will expect left to be decrement, + // regardless of how it appears on the screen. For speakers ofRTL languages, they probably + // expect left to mean increment. Therefore we flip the meaning of the side arrow keys for + // RTL. For inverted sliders we prefer a good a11y experience to having it "look right" for + // sighted users, therefore we do not swap the meaning. + this._increment(this.direction == 'rtl' ? 1 : -1); break; case UP_ARROW: this._increment(1); break; case RIGHT_ARROW: - this._increment(this._isLeftMin() ? 1 : -1); + // See comment on LEFT_ARROW about the conditions under which we flip the meaning. + this._increment(this.direction == 'rtl' ? -1 : 1); break; case DOWN_ARROW: this._increment(-1); @@ -335,11 +394,6 @@ export class MdSlider implements ControlValueAccessor { event.preventDefault(); } - /** Whether the left side of the slider is the minimum value. */ - private _isLeftMin() { - return (this.direction == 'rtl') == this.invert; - } - /** Increments the slider by the given number of steps (negative number decrements). */ private _increment(numSteps: number) { this.value = this._clamp(this.value + this.step * numSteps, this.min, this.max); @@ -348,17 +402,18 @@ export class MdSlider implements ControlValueAccessor { /** * Calculate the new value from the new physical location. The value will always be snapped. */ - private _updateValueFromPosition(pos: number) { + private _updateValueFromPosition(pos: {x: number, y: number}) { if (!this._sliderDimensions) { return; } - let offset = this._sliderDimensions.left; - let size = this._sliderDimensions.width; + let offset = this.vertical ? this._sliderDimensions.top : this._sliderDimensions.left; + let size = this.vertical ? this._sliderDimensions.height : this._sliderDimensions.width; + let posComponent = this.vertical ? pos.y : pos.x; // The exact value is calculated from the event and used to find the closest snap value. - let percent = this._clamp((pos - offset) / size); - if (!this._isLeftMin()) { + let percent = this._clamp((posComponent - offset) / size); + if (this.invertMouseCoords) { percent = 1 - percent; } let exactValue = this._calculateValue(percent); @@ -391,10 +446,11 @@ export class MdSlider implements ControlValueAccessor { } if (this.tickInterval == 'auto') { - let pixelsPerStep = this._sliderDimensions.width * this.step / (this.max - this.min); + let trackSize = this.vertical ? this._sliderDimensions.height : this._sliderDimensions.width; + let pixelsPerStep = trackSize * 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); + this._tickIntervalPercent = pixelsPerTick / trackSize; } else { this._tickIntervalPercent = this.tickInterval * this.step / (this.max - this.min); }