diff --git a/src/components/slider/slider.html b/src/components/slider/slider.html index 0d126665dc3d..caf03c84addb 100644 --- a/src/components/slider/slider.html +++ b/src/components/slider/slider.html @@ -5,6 +5,8 @@
+
+
diff --git a/src/components/slider/slider.scss b/src/components/slider/slider.scss index e575fcb612a6..fb57d8fad810 100644 --- a/src/components/slider/slider.scss +++ b/src/components/slider/slider.scss @@ -95,6 +95,13 @@ md-slider *::after { background-color: md-color($md-accent); } +.md-slider-tick-container, .md-slider-last-tick-container { + position: absolute; + left: 0; + right: 0; + height: 100%; +} + .md-slider-thumb-container { position: absolute; left: 0; diff --git a/src/components/slider/slider.spec.ts b/src/components/slider/slider.spec.ts index 8185540f294d..b22bd0a96ed5 100644 --- a/src/components/slider/slider.spec.ts +++ b/src/components/slider/slider.spec.ts @@ -25,6 +25,8 @@ describe('MdSlider', () => { SliderWithMinAndMax, SliderWithValue, SliderWithStep, + SliderWithAutoTickInterval, + SliderWithSetTickInterval ], }); @@ -434,6 +436,86 @@ describe('MdSlider', () => { expect(Math.round(trackFillDimensions.width)).toBe(Math.round(thumbPosition)); }); }); + + describe('slider with auto ticks', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let tickContainer: HTMLElement; + let lastTickContainer: HTMLElement; + + beforeEach(async(() => { + builder.createAsync(SliderWithAutoTickInterval).then(f => { + fixture = f; + fixture.detectChanges(); + + 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'); + }); + })); + + 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 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'); + }); + }); + + describe('slider with set tick interval', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let tickContainer: HTMLElement; + let lastTickContainer: HTMLElement; + + beforeEach(async(() => { + builder.createAsync(SliderWithSetTickInterval).then(f => { + fixture = f; + fixture.detectChanges(); + + 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'); + }); + })); + + 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 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)'); + }); + }); }); // The transition has to be removed in order to test the updated positions without setTimeout. @@ -480,6 +562,16 @@ class SliderWithValue { } }) class SliderWithStep { } +@Component({ + template: `` +}) +class SliderWithAutoTickInterval { } + +@Component({ + template: `` +}) +class SliderWithSetTickInterval { } + /** * Dispatches a click event from an element. * Note: The mouse event truncates the position for the click. diff --git a/src/components/slider/slider.ts b/src/components/slider/slider.ts index 10ea4cc7469f..317249f5b6a2 100644 --- a/src/components/slider/slider.ts +++ b/src/components/slider/slider.ts @@ -12,6 +12,12 @@ import {BooleanFieldValue} from '@angular2-material/core/annotations/field-value import {applyCssTransform} from '@angular2-material/core/style/apply-transform'; import {MdGestureConfig} from '@angular2-material/core/core'; +/** + * Visually, a 30px separation between tick marks looks best. This is very subjective but it is + * the default separation we chose. + */ +const MIN_AUTO_TICK_SEPARATION = 30; + @Component({ moduleId: module.id, selector: 'md-slider', @@ -53,6 +59,12 @@ export class MdSlider implements AfterContentInit { /** 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') private _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. @@ -122,6 +134,7 @@ export class MdSlider implements AfterContentInit { ngAfterContentInit() { this._sliderDimensions = this._renderer.getSliderDimensions(); this.snapToValue(); + this._updateTickSeparation(); } /** TODO: internal */ @@ -186,7 +199,7 @@ export class MdSlider implements AfterContentInit { * This is also used to move the thumb to a snapped value once sliding is done. */ updatePercentFromValue() { - this._percent = (this.value - this.min) / (this.max - this.min); + this._percent = this.calculatePercentage(this.value); } /** @@ -198,7 +211,7 @@ export class MdSlider implements AfterContentInit { // 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.min + (this._percent * (this.max - this.min)); + let exactValue = this.calculateValue(this._percent); // This calculation finds the closest step by finding the closest whole number divisible by the // step relative to the min. @@ -217,6 +230,80 @@ export class MdSlider implements AfterContentInit { this._renderer.updateThumbAndFillPosition(this._percent, this._sliderDimensions.width); } + /** + * 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. + */ + private _updateTickSeparation() { + 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); + } + + /** + * Calculates the percentage of the slider that a value is. + */ + 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)); + } + /** * Return a number between two numbers. */ @@ -267,6 +354,32 @@ export class SliderRenderer { addFocus() { this._sliderElement.focus(); } + + /** + * Draws ticks onto the tick container. + */ + drawTicks(tickSeparation: number) { + let tickContainer = this._sliderElement.querySelector('.md-slider-tick-container'); + let tickContainerWidth = tickContainer.getBoundingClientRect().width; + // 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 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. + if (tickContainerWidth % tickSeparation < (tickSeparation / 2)) { + tickContainer.style.width = tickContainerWidth - tickSeparation + 'px'; + } + } } export const MD_SLIDER_DIRECTIVES = [MdSlider]; diff --git a/src/demo-app/slider/slider-demo.html b/src/demo-app/slider/slider-demo.html index a71fd84a34e8..e4c39cf435c1 100644 --- a/src/demo-app/slider/slider-demo.html +++ b/src/demo-app/slider/slider-demo.html @@ -26,3 +26,7 @@

Slider with step defined

{{slider5.value}} + +

Slider with set tick interval

+ +