From 8354750a45102b32e5baecd57e6a358955adcbab Mon Sep 17 00:00:00 2001 From: Ivey Padgett Date: Thu, 14 Jul 2016 14:43:00 -0700 Subject: [PATCH] feat(md-slider): initial version for md-slider (#779) --- src/components/slider/slider.html | 15 + src/components/slider/slider.scss | 143 +++++++ src/components/slider/slider.spec.ts | 425 +++++++++++++++++++ src/components/slider/slider.ts | 253 +++++++++++ src/components/slider/test-gesture-config.ts | 39 ++ src/demo-app/demo-app/demo-app.html | 1 + src/demo-app/demo-app/routes.ts | 2 + src/demo-app/slider/slider-demo.html | 22 + src/demo-app/slider/slider-demo.ts | 10 + src/demo-app/system-config.ts | 1 + test/karma.config.ts | 1 + 11 files changed, 912 insertions(+) create mode 100644 src/components/slider/slider.html create mode 100644 src/components/slider/slider.scss create mode 100644 src/components/slider/slider.spec.ts create mode 100644 src/components/slider/slider.ts create mode 100644 src/components/slider/test-gesture-config.ts create mode 100644 src/demo-app/slider/slider-demo.html create mode 100644 src/demo-app/slider/slider-demo.ts diff --git a/src/components/slider/slider.html b/src/components/slider/slider.html new file mode 100644 index 000000000000..e53edc4cb68d --- /dev/null +++ b/src/components/slider/slider.html @@ -0,0 +1,15 @@ +
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/slider/slider.scss b/src/components/slider/slider.scss new file mode 100644 index 000000000000..f9bd5fad869e --- /dev/null +++ b/src/components/slider/slider.scss @@ -0,0 +1,143 @@ +@import 'default-theme'; +@import '_variables'; + +// This refers to the thickness of the slider. On a horizontal slider this is the height, on a +// vertical slider this is the width. +$md-slider-thickness: 20px !default; +$md-slider-min-size: 128px !default; +$md-slider-padding: 8px !default; + +$md-slider-track-height: 2px !default; +$md-slider-thumb-size: 20px !default; + +$md-slider-thumb-default-scale: 0.7 !default; +$md-slider-thumb-focus-scale: 1 !default; + +// TODO(iveysaur): Find an implementation to hide the track under a disabled thumb. +$md-slider-off-color: rgba(black, 0.26); +$md-slider-focused-color: rgba(black, 0.38); +$md-slider-disabled-color: rgba(black, 0.26); + +/** + * 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); +} + +/** + * 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); + width: $width; + height: $height; + border-radius: max($width, $height); +} + +md-slider { + height: $md-slider-thickness; + min-width: $md-slider-min-size; + position: relative; + padding: 0; + display: inline-block; + outline: none; + vertical-align: middle; +} + +md-slider *, +md-slider *::after { + box-sizing: border-box; +} + +/** + * 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; +} + +/** + * Holds the isActive and isDragging classes as well as helps with positioning the children. + * Cannot be merged with .md-slider-wrapper. + */ +.md-slider-container { + position: relative; +} + +.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-track { + position: absolute; + left: 0; + right: 0; + height: 100%; + background-color: $md-slider-off-color; +} + +.md-slider-track-fill { + transition-duration: $swift-ease-out-duration; + transition-timing-function: $swift-ease-out-timing-function; + transition-property: width, height; + background-color: md-color($md-accent); +} + +.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; +} + +.md-slider-thumb { + z-index: 1; + + @include slider-thumb-position($md-slider-thumb-size, $md-slider-thumb-size); + 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); + border-width: 3px; + border-style: solid; + transition: inherit; + background-color: md-color($md-accent); + border-color: md-color($md-accent); +} + +.md-slider-dragging .md-slider-thumb-position, +.md-slider-dragging .md-slider-track-fill { + transition: none; + cursor: default; +} + +.md-slider-active .md-slider-thumb { + transform: scale($md-slider-thumb-focus-scale); +} + +.md-slider-disabled .md-slider-thumb::after { + background-color: $md-slider-disabled-color; + border-color: $md-slider-disabled-color; +} diff --git a/src/components/slider/slider.spec.ts b/src/components/slider/slider.spec.ts new file mode 100644 index 000000000000..1278914fba55 --- /dev/null +++ b/src/components/slider/slider.spec.ts @@ -0,0 +1,425 @@ +import { + addProviders, + inject, + async, +} from '@angular/core/testing'; +import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing'; +import {Component, DebugElement, ViewEncapsulation} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {MdSlider, MD_SLIDER_DIRECTIVES} from './slider'; +import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; +import {TestGestureConfig} from './test-gesture-config'; + +describe('MdSlider', () => { + let builder: TestComponentBuilder; + let gestureConfig: TestGestureConfig; + + beforeEach(() => { + addProviders([ + {provide: HAMMER_GESTURE_CONFIG, useFactory: () => { + gestureConfig = new TestGestureConfig(); + return gestureConfig; + }} + ]); + }); + + beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => { + builder = tcb; + })); + + describe('standard slider', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MdSlider; + let trackFillElement: HTMLElement; + let trackFillDimensions: ClientRect; + let sliderTrackElement: HTMLElement; + let sliderDimensions: ClientRect; + let thumbElement: HTMLElement; + let thumbDimensions: ClientRect; + let thumbWidth: number; + + beforeEach(async(() => { + builder.createAsync(StandardSlider).then(f => { + fixture = f; + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + 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(); + thumbWidth = + sliderNativeElement.querySelector('.md-slider-thumb').getBoundingClientRect().width; + }); + })); + + it('should set the default values', () => { + expect(sliderInstance.value).toBe(0); + expect(sliderInstance.min).toBe(0); + expect(sliderInstance.max).toBe(100); + }); + + it('should update the value on a click', () => { + expect(sliderInstance.value).toBe(0); + dispatchClickEvent(sliderTrackElement, 0.19); + // The expected value is 19 from: percentage * difference of max and min. + let difference = Math.abs(sliderInstance.value - 19); + expect(difference).toBeLessThan(1); + }); + + it('should update the value on a drag', () => { + expect(sliderInstance.value).toBe(0); + dispatchDragEvent(sliderTrackElement, sliderNativeElement, 0, 0.89, gestureConfig); + // The expected value is 89 from: percentage * difference of max and min. + let difference = Math.abs(sliderInstance.value - 89); + expect(difference).toBeLessThan(1); + }); + + it('should set the value as min when dragging before the track', () => { + expect(sliderInstance.value).toBe(0); + dispatchDragEvent(sliderTrackElement, sliderNativeElement, 0, -1.33, gestureConfig); + expect(sliderInstance.value).toBe(0); + }); + + it('should set the value as max when dragging past the track', () => { + expect(sliderInstance.value).toBe(0); + dispatchDragEvent(sliderTrackElement, sliderNativeElement, 0, 1.75, gestureConfig); + expect(sliderInstance.value).toBe(100); + }); + + it('should update the track fill on click', () => { + expect(trackFillDimensions.width).toBe(0); + dispatchClickEvent(sliderTrackElement, 0.39); + + trackFillDimensions = trackFillElement.getBoundingClientRect(); + // The fill should be close to the slider's width * the percentage from the click. + let difference = Math.abs(trackFillDimensions.width - (sliderDimensions.width * 0.39)); + expect(difference).toBeLessThan(1); + }); + + it('should update the thumb position on click', () => { + expect(thumbDimensions.left).toBe(sliderDimensions.left - (thumbWidth / 2)); + dispatchClickEvent(sliderTrackElement, 0.16); + + thumbDimensions = thumbElement.getBoundingClientRect(); + // The thumb's offset is expected to be equal to the slider's offset + 0.16 * the slider's + // width - half the thumb width (to center the thumb). + let offset = sliderDimensions.left + (sliderDimensions.width * 0.16) - (thumbWidth / 2); + let difference = Math.abs(thumbDimensions.left - offset); + expect(difference).toBeLessThan(1); + }); + + it('should update the track fill on drag', () => { + expect(trackFillDimensions.width).toBe(0); + dispatchDragEvent(sliderTrackElement, sliderNativeElement, 0, 0.86, gestureConfig); + + trackFillDimensions = trackFillElement.getBoundingClientRect(); + let difference = Math.abs(trackFillDimensions.width - (sliderDimensions.width * 0.86)); + expect(difference).toBeLessThan(1); + }); + + it('should update the thumb position on drag', () => { + expect(thumbDimensions.left).toBe(sliderDimensions.left - (thumbWidth / 2)); + dispatchDragEvent(sliderTrackElement, sliderNativeElement, 0, 0.27, gestureConfig); + + thumbDimensions = thumbElement.getBoundingClientRect(); + let offset = sliderDimensions.left + (sliderDimensions.width * 0.27) - (thumbWidth / 2); + let difference = Math.abs(thumbDimensions.left - offset); + expect(difference).toBeLessThan(1); + }); + + 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'); + + dispatchClickEvent(sliderNativeElement, 0.23); + fixture.detectChanges(); + + expect(containerElement.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); + fixture.detectChanges(); + + expect(containerElement.classList).toContain('md-slider-active'); + + // Call the `onBlur` handler directly because we cannot simulate a focus event in unit tests. + sliderInstance.onBlur(); + fixture.detectChanges(); + + expect(containerElement.classList).not.toContain('md-slider-active'); + }); + + it('should add and remove the md-slider-dragging class when dragging', () => { + let containerElement = sliderNativeElement.querySelector('.md-slider-container'); + expect(containerElement.classList).not.toContain('md-slider-dragging'); + + dispatchDragStartEvent(sliderNativeElement, 0, gestureConfig); + fixture.detectChanges(); + + expect(containerElement.classList).toContain('md-slider-dragging'); + + dispatchDragEndEvent(sliderNativeElement, 0.34, gestureConfig); + fixture.detectChanges(); + + expect(containerElement.classList).not.toContain('md-slider-dragging'); + }); + }); + + describe('disabled slider', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MdSlider; + + beforeEach(async(() => { + builder.createAsync(DisabledSlider).then(f => { + fixture = f; + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.componentInstance; + }); + })); + + it('should be disabled', () => { + expect(sliderInstance.disabled).toBeTruthy(); + }); + + it('should not change the value on click when disabled', () => { + expect(sliderInstance.value).toBe(0); + dispatchClickEvent(sliderNativeElement, 0.63); + expect(sliderInstance.value).toBe(0); + }); + + it('should not change the value on drag when disabled', () => { + expect(sliderInstance.value).toBe(0); + dispatchDragEvent(sliderNativeElement, 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'); + + dispatchClickEvent(sliderNativeElement, 0.43); + fixture.detectChanges(); + + expect(containerElement.classList).not.toContain('md-slider-active'); + }); + + it('should not add the md-slider-dragging class on drag when disabled', () => { + let containerElement = sliderNativeElement.querySelector('.md-slider-container'); + expect(containerElement.classList).not.toContain('md-slider-dragging'); + + dispatchDragStartEvent(sliderNativeElement, 0.46, gestureConfig); + fixture.detectChanges(); + + expect(containerElement.classList).not.toContain('md-slider-dragging'); + }); + }); + + describe('slider with set min and max', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MdSlider; + let sliderTrackElement: HTMLElement; + + beforeEach(async(() => { + builder.createAsync(SliderWithMinAndMax).then(f => { + fixture = f; + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MdSlider); + sliderTrackElement = sliderNativeElement.querySelector('.md-slider-track'); + }); + })); + + it('should set the default values from the attributes', () => { + expect(sliderInstance.value).toBe(5); + expect(sliderInstance.min).toBe(5); + expect(sliderInstance.max).toBe(15); + }); + + it('should set the correct value on click', () => { + dispatchClickEvent(sliderTrackElement, 0.09); + // 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 = 5 + (0.09 * (15 - 5)); + let difference = Math.abs(sliderInstance.value - value); + expect(difference).toBeLessThan(1); + }); + + it('should set the correct value on drag', () => { + dispatchDragEvent(sliderTrackElement, sliderNativeElement, 0, 0.62, gestureConfig); + // 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 = 5 + (0.62 * (15 - 5)); + let difference = Math.abs(sliderInstance.value - value); + expect(difference).toBeLessThan(1); + }); + }); + + describe('slider with set value', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MdSlider; + let sliderTrackElement: HTMLElement; + + beforeEach(async(() => { + builder.createAsync(SliderWithValue).then(f => { + fixture = f; + fixture.detectChanges(); + + sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MdSlider); + sliderTrackElement = sliderNativeElement.querySelector('.md-slider-track'); + }); + })); + + it('should set the default value from the attribute', () => { + expect(sliderInstance.value).toBe(26); + }); + + it('should set the correct value on click', () => { + dispatchClickEvent(sliderTrackElement, 0.92); + // 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. + let value = 92; + let difference = Math.abs(sliderInstance.value - value); + expect(difference).toBeLessThan(1); + }); + + it('should set the correct value on drag', () => { + dispatchDragEvent(sliderTrackElement, sliderNativeElement, 0, 0.32, gestureConfig); + expect(sliderInstance.value).toBe(32); + }); + }); +}); + +// The transition has to be removed in order to test the updated positions without setTimeout. +@Component({ + directives: [MD_SLIDER_DIRECTIVES], + template: ``, + styles: [` + .md-slider-track-fill, .md-slider-thumb-position { + transition: none !important; + } + `], + encapsulation: ViewEncapsulation.None +}) +class StandardSlider { } + +@Component({ + directives: [MD_SLIDER_DIRECTIVES], + template: `` +}) +class DisabledSlider { } + +@Component({ + directives: [MD_SLIDER_DIRECTIVES], + template: `` +}) +class SliderWithMinAndMax { } + +@Component({ + directives: [MD_SLIDER_DIRECTIVES], + template: `` +}) +class SliderWithValue { } + +/** + * Dispatches a click event from an element. + * @param element The 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 dispatchClickEvent(element: HTMLElement, percentage: number): void { + let dimensions = element.getBoundingClientRect(); + let y = dimensions.top; + let x = dimensions.left + (dimensions.width * percentage); + + let event = document.createEvent('MouseEvent'); + event.initMouseEvent( + 'click', true, true, window, 0, x, y, x, y, false, false, false, false, 0, null); + element.dispatchEvent(event); +} + +/** + * Dispatches a drag event from an element. + * @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 startPercent The percentage of the slider where the drag will begin. + * @param endPercent The percentage of the slider where the drag will end. + * @param gestureConfig The gesture config for the test to handle emitting the drag events. + */ +function dispatchDragEvent(trackElement: HTMLElement, containerElement: HTMLElement, + startPercent: number, endPercent: number, + gestureConfig: TestGestureConfig): void { + let dimensions = trackElement.getBoundingClientRect(); + let startX = dimensions.left + (dimensions.width * startPercent); + let endX = dimensions.left + (dimensions.width * endPercent); + + gestureConfig.emitEventForElement('dragstart', containerElement, { + // The actual event has a center with an x value that the drag listener is looking for. + center: { x: startX }, + // The event needs a source event with a prevent default so we fake one. + srcEvent: { preventDefault: jasmine.createSpy('preventDefault') } + }); + + gestureConfig.emitEventForElement('drag', containerElement, { + center: { x: endX }, + srcEvent: { preventDefault: jasmine.createSpy('preventDefault') } + }); +} + +/** + * Dispatches a dragstart event from an element. + * @param element The element from which the event will be dispatched. + * @param startPercent The percentage of the slider where the drag will begin. + * @param gestureConfig The gesture config for the test to handle emitting the drag events. + */ +function dispatchDragStartEvent(element: HTMLElement, startPercent: number, + gestureConfig: TestGestureConfig): void { + let dimensions = element.getBoundingClientRect(); + let x = dimensions.left + (dimensions.width * startPercent); + + gestureConfig.emitEventForElement('dragstart', element, { + center: { x: x }, + srcEvent: { preventDefault: jasmine.createSpy('preventDefault') } + }); +} + +/** + * Dispatches a dragend event from an element. + * @param element The element from which the event will be dispatched. + * @param endPercent The percentage of the slider where the drag will end. + * @param gestureConfig The gesture config for the test to handle emitting the drag events. + */ +function dispatchDragEndEvent(element: HTMLElement, endPercent: number, + gestureConfig: TestGestureConfig): void { + let dimensions = element.getBoundingClientRect(); + let x = dimensions.left + (dimensions.width * endPercent); + + gestureConfig.emitEventForElement('dragend', element, { + center: { x: x }, + srcEvent: { preventDefault: jasmine.createSpy('preventDefault') } + }); +} diff --git a/src/components/slider/slider.ts b/src/components/slider/slider.ts new file mode 100644 index 000000000000..78d5e8a2b1c8 --- /dev/null +++ b/src/components/slider/slider.ts @@ -0,0 +1,253 @@ +import { + Component, + ElementRef, + HostBinding, + Input, + ViewEncapsulation, + AfterContentInit, +} from '@angular/core'; +import {BooleanFieldValue} from '@angular2-material/core/annotations/field-value'; +import {applyCssTransform} from '@angular2-material/core/style/apply-transform'; + +@Component({ + moduleId: module.id, + selector: 'md-slider', + host: { + 'tabindex': '0', + '(click)': 'onClick($event)', + '(drag)': 'onDrag($event)', + '(dragstart)': 'onDragStart($event)', + '(dragend)': 'onDragEnd()', + '(window:resize)': 'onResize()', + '(blur)': 'onBlur()', + }, + templateUrl: 'slider.html', + styleUrls: ['slider.css'], + encapsulation: ViewEncapsulation.None, +}) +export class MdSlider implements AfterContentInit { + /** 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; + + @Input() + @BooleanFieldValue() + @HostBinding('class.md-slider-disabled') + @HostBinding('attr.aria-disabled') + disabled: boolean = false; + + /** 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; + + /** + * Whether or not the thumb is currently being dragged. + * Used to determine if there should be a transition for the thumb and fill track. + * TODO: internal + */ + isDragging: boolean = false; + + /** + * Whether or not the slider is active (clicked or is being dragged). + * Used to shrink and grow the thumb as according to the Material Design spec. + * TODO: internal + */ + isActive: boolean = false; + + /** Indicator for if the value has been set or not. */ + private _isInitialized: boolean = false; + + /** Value of the slider. */ + private _value: 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); + + // If the value wasn't explicitly set by the user, set it to the min. + if (!this._isInitialized) { + this.value = this._min; + } + } + + @Input() + @HostBinding('attr.aria-valuemax') + get max() { + return this._max; + } + + set max(v: number) { + this._max = Number(v); + } + + @Input() + @HostBinding('attr.aria-valuenow') + get value() { + return this._value; + } + + set value(v: number) { + this._value = Number(v); + this._isInitialized = true; + this.updatePercentFromValue(); + } + + constructor(elementRef: ElementRef) { + 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() { + this._sliderDimensions = this._renderer.getSliderDimensions(); + this._renderer.updateThumbAndFillPosition(this._percent, this._sliderDimensions.width); + } + + /** TODO: internal */ + onClick(event: MouseEvent) { + if (this.disabled) { + return; + } + + this.isActive = true; + this.isDragging = false; + this._renderer.addFocus(); + this.updateValueFromPosition(event.clientX); + } + + /** TODO: internal */ + onDrag(event: HammerInput) { + if (this.disabled) { + return; + } + // Prevent the drag from selecting anything else. + event.preventDefault(); + this.updateValueFromPosition(event.center.x); + } + + /** TODO: internal */ + onDragStart(event: HammerInput) { + if (this.disabled) { + return; + } + + event.preventDefault(); + this.isDragging = true; + this.isActive = true; + this._renderer.addFocus(); + this.updateValueFromPosition(event.center.x); + } + + /** TODO: internal */ + onDragEnd() { + this.isDragging = false; + } + + /** TODO: internal */ + onResize() { + this.isDragging = 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; + } + + /** + * When the value changes without a physical position, the percentage needs to be recalculated + * independent of the physical location. + */ + updatePercentFromValue() { + this._percent = (this.value - this.min) / (this.max - this.min); + } + + /** + * Calculate the new value from the new physical location. + */ + updateValueFromPosition(pos: number) { + let offset = this._sliderDimensions.left; + let size = this._sliderDimensions.width; + this._percent = this.clamp((pos - offset) / size); + this.value = this.min + (this._percent * (this.max - this.min)); + + this._renderer.updateThumbAndFillPosition(this._percent, this._sliderDimensions.width); + } + + /** + * Return a number between two numbers. + */ + clamp(value: number, min = 0, max = 1) { + return Math.max(min, Math.min(value, max)); + } +} + +/** + * Renderer class in order to keep all dom manipulation in one place and outside of the main class. + */ +export class SliderRenderer { + private _sliderElement: HTMLElement; + + constructor(elementRef: ElementRef) { + this._sliderElement = elementRef.nativeElement; + } + + /** + * Get the bounding client rect of the slider track element. + * The track is used rather than the native element to ignore the extra space that the thumb can + * take up. + */ + getSliderDimensions() { + let trackElement = this._sliderElement.querySelector('.md-slider-track'); + return trackElement.getBoundingClientRect(); + } + + /** + * Update the physical position of the thumb and fill track on the slider. + */ + updateThumbAndFillPosition(percent: number, width: number) { + // The actual thumb element. Needed to get the exact width of the thumb for calculations. + let thumbElement = this._sliderElement.querySelector('.md-slider-thumb'); + // 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 thumbWidth = thumbElement.getBoundingClientRect().width; + + let position = percent * width; + // The thumb needs to be shifted to the left by half of the width of itself so that it centers + // on the value. + let thumbPosition = position - (thumbWidth / 2); + + fillTrackElement.style.width = `${position}px`; + applyCssTransform(thumbPositionElement, `translateX(${thumbPosition}px)`); + } + + /** + * Focuses the native element. + * Currently only used to allow a blur event to fire but will be used with keyboard input later. + */ + addFocus() { + this._sliderElement.focus(); + } +} + +export const MD_SLIDER_DIRECTIVES = [MdSlider]; diff --git a/src/components/slider/test-gesture-config.ts b/src/components/slider/test-gesture-config.ts new file mode 100644 index 000000000000..dc308d22242b --- /dev/null +++ b/src/components/slider/test-gesture-config.ts @@ -0,0 +1,39 @@ +import {Injectable} from '@angular/core'; +import {MdGestureConfig} from '@angular2-material/core/gestures/MdGestureConfig'; + +/** + * An extension of MdGestureConfig that exposes the underlying HammerManager instances. + * Tests can use these instances to emit fake gesture events. + */ +@Injectable() +export class TestGestureConfig extends MdGestureConfig { + /** + * A map of Hammer instances to element. + * Used to emit events over instances for an element. + */ + hammerInstances: Map = new Map(); + + /** + * Create a mapping of Hammer instances to element so that events can be emitted during testing. + */ + buildHammer(element: HTMLElement) { + let mc = super.buildHammer(element); + + if (this.hammerInstances.get(element)) { + this.hammerInstances.get(element).push(mc); + } else { + this.hammerInstances.set(element, [mc]); + } + + return mc; + } + + /** + * The Angular event plugin for Hammer creates a new HammerManager instance for each listener, + * so we need to apply our event on all instances to hit the correct listener. + */ + emitEventForElement(eventType: string, element: HTMLElement, eventData: Object) { + let instances = this.hammerInstances.get(element); + instances.forEach(instance => instance.emit(eventType, eventData)); + } +} diff --git a/src/demo-app/demo-app/demo-app.html b/src/demo-app/demo-app/demo-app.html index b4a050770eeb..34595a6fd699 100644 --- a/src/demo-app/demo-app/demo-app.html +++ b/src/demo-app/demo-app/demo-app.html @@ -18,6 +18,7 @@ Progress Bar Radio Sidenav + Slider Slide Toggle Toolbar Tabs diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index 3b3dc2284156..d54146c6e497 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -17,6 +17,7 @@ import {PortalDemo} from '../portal/portal-demo'; import {ProgressBarDemo} from '../progress-bar/progress-bar-demo'; import {ProgressCircleDemo} from '../progress-circle/progress-circle-demo'; import {SlideToggleDemo} from '../slide-toggle/slide-toggle-demo'; +import {SliderDemo} from '../slider/slider-demo'; import {SidenavDemo} from '../sidenav/sidenav-demo'; import {RadioDemo} from '../radio/radio-demo'; import {CardDemo} from '../card/card-demo'; @@ -31,6 +32,7 @@ export const routes: RouterConfig = [ {path: 'radio', component: RadioDemo}, {path: 'sidenav', component: SidenavDemo}, {path: 'slide-toggle', component: SlideToggleDemo}, + {path: 'slider', component: SliderDemo}, {path: 'progress-circle', component: ProgressCircleDemo}, {path: 'progress-bar', component: ProgressBarDemo}, {path: 'portal', component: PortalDemo}, diff --git a/src/demo-app/slider/slider-demo.html b/src/demo-app/slider/slider-demo.html new file mode 100644 index 000000000000..dd3716c75d8c --- /dev/null +++ b/src/demo-app/slider/slider-demo.html @@ -0,0 +1,22 @@ +

Default Slider

+
+ Label + {{slidey.value}} +
+ +

Slider with Min and Max

+
+ + {{slider2.value}} +
+ +

Disabled Slider

+
+ + {{slider3.value}} +
+ +

Slider with set value

+
+ +
diff --git a/src/demo-app/slider/slider-demo.ts b/src/demo-app/slider/slider-demo.ts new file mode 100644 index 000000000000..ec77e356b61e --- /dev/null +++ b/src/demo-app/slider/slider-demo.ts @@ -0,0 +1,10 @@ +import {Component} from '@angular/core'; +import {MD_SLIDER_DIRECTIVES} from '@angular2-material/slider/slider'; + +@Component({ + moduleId: module.id, + selector: 'slider-demo', + templateUrl: 'slider-demo.html', + directives: [MD_SLIDER_DIRECTIVES], +}) +export class SliderDemo { } diff --git a/src/demo-app/system-config.ts b/src/demo-app/system-config.ts index bb7efe92848b..9b61e634616c 100644 --- a/src/demo-app/system-config.ts +++ b/src/demo-app/system-config.ts @@ -15,6 +15,7 @@ const components = [ 'progress-circle', 'radio', 'sidenav', + 'slider', 'slide-toggle', 'button-toggle', 'tabs', diff --git a/test/karma.config.ts b/test/karma.config.ts index bf57e4a2a8e7..e384d192ee09 100644 --- a/test/karma.config.ts +++ b/test/karma.config.ts @@ -25,6 +25,7 @@ export function config(config) { {pattern: 'dist/vendor/zone.js/dist/zone.js', included: true, watched: false}, {pattern: 'dist/vendor/zone.js/dist/async-test.js', included: true, watched: false}, {pattern: 'dist/vendor/zone.js/dist/fake-async-test.js', included: true, watched: false}, + {pattern: 'dist/vendor/hammerjs/hammer.min.js', included: true, watched: false}, {pattern: 'test/karma-test-shim.js', included: true, watched: false},