import { coerceBooleanProperty } from '@angular/cdk/coercion' ; import { AfterViewInit , ChangeDetectionStrategy , ChangeDetectorRef , Component , DoCheck , ElementRef , EventEmitter , Input , OnInit , Output , ViewChild } from '@angular/core' ; import { ColorService } from '../../services/color/index' ; @ Component ( { selector : 'ux-slider' , templateUrl : './slider.component.html' , changeDetection : ChangeDetectionStrategy . OnPush , host : { '[class.disabled]' : 'disabled' , } } ) export class SliderComponent implements OnInit , AfterViewInit , DoCheck { /** A single number or a SliderValue object, depending on the slider type specified. */ @ Input ( ) value : SliderValue | number = 0 ; /** A set of options to customize the appearance and behavior of the slider. */ @ Input ( ) set options ( options ) { this . _options = options ; this . updateOptions ( ) ; } /** Whether the slider is disabled. */ @ Input ( ) set disabled ( disabled ) { this . _disabled = coerceBooleanProperty ( disabled ) ; } get disabled ( ) : boolean { return this . _disabled ; } get options ( ) : SliderOptions { return this . _options ; } /** Emits when the `value` changes. */ @ Output ( ) valueChange : EventEmitter < SliderValue | number > = new EventEmitter < SliderValue | number > ( ) ; @ ViewChild ( 'lowerTooltip' , { static : true } ) lowerTooltip : ElementRef ; @ ViewChild ( 'upperTooltip' , { static : true } ) upperTooltip : ElementRef ; @ ViewChild ( 'track' , { static : true } ) track : ElementRef ; private _value : SliderValue | number ; private _disabled : boolean = false ; _options : SliderOptions ; sliderType = SliderType ; sliderStyle = SliderStyle ; sliderSize = SliderSize ; sliderSnap = SliderSnap ; sliderThumb = SliderThumb ; sliderTickType = SliderTickType ; sliderThumbEvent = SliderThumbEvent ; sliderCalloutTrigger = SliderCalloutTrigger ; tracks = { lower : { size : 0 , color : '' } , middle : { size : 0 , color : '' } , upper : { size : 0 , color : '' } } ; tooltips = { lower : { visible : false , position : 0 , label : '' } , upper : { visible : false , position : 0 , label : '' } } ; thumbs = { lower : { hover : false , drag : false , position : 0 , order : 100 , value : null as number } , upper : { hover : false , drag : false , position : 0 , order : 101 , value : null as number } } ; ticks : SliderTick [ ] = [ ] ; defaultOptions : SliderOptions ; constructor ( colorService , private _changeDetectorRef ) { this . defaultOptions = { type : SliderType . Value , handles : { style : SliderStyle . Button , callout : { trigger : SliderCalloutTrigger . None , background : colorService . getColor ( 'grey2' ) . toHex ( ) , color : '#fff' , formatter : ( value ) : string | number => value } , keyboard : { major : 5 , minor : 1 } , aria : { thumb : 'Slider value' , lowerThumb : 'Slider lower value' , upperThumb : 'Slider upper value' } } , track : { height : SliderSize . Wide , min : 0 , max : 100 , ticks : { snap : SliderSnap . None , major : { show : true , steps : 10 , labels : true , formatter : ( value ) : string | number => value } , minor : { show : true , steps : 5 , labels : false , formatter : ( value ) : string | number => value } } , colors : { } } } ; } ngOnInit ( ) { this . updateValues ( ) ; this . setThumbState ( SliderThumb . Lower , false , false ) ; this . setThumbState ( SliderThumb . Upper , false , false ) ; this . valueChange . next ( this . clone ( this . value ) ) ; } ngDoCheck ( ) { if ( this . detectValueChange ( this . value , this . _value ) ) { this . updateValues ( ) ; this . _value = this . clone ( this . value ) ; } } ngAfterViewInit ( ) { setTimeout ( ( ) => { this . updateTooltipPosition ( SliderThumb . Lower ) ; this . updateTooltipPosition ( SliderThumb . Upper ) ; this . _changeDetectorRef . markForCheck ( ) ; } ) ; } snapToNearestTick ( thumb , snapTarget , forwards ) { if ( this . disabled ) { return ; } const { value } = this . getThumbState ( thumb ) ; const closest = this . getTickDistances ( value , thumb , snapTarget ) . filter ( tick => tick . value !== value ) . find ( tick => forwards ? tick . value > value : tick . value < value ) ; if ( closest ) { return this . setThumbValue ( thumb , this . validateValue ( thumb , closest . value ) ) ; } const step = snapTarget === SliderSnap . Major ? this . options . handles . keyboard . major : this . options . handles . keyboard . minor ; this . setThumbValue ( thumb , this . validateValue ( thumb , value + ( forwards ? step : - step ) ) ) ; } snapToEnd ( thumb , forwards ) { this . setThumbValue ( thumb , this . validateValue ( thumb , forwards ? this . options . track . max : this . options . track . min ) ) ; } getThumbValue ( thumb ) { return this . getThumbState ( thumb ) . value ; } getFormattedValue ( thumb ) { return this . options . handles . callout . formatter ( this . getThumbState ( thumb ) . value ) ; } private getThumbState ( thumb ) { return thumb === SliderThumb . Lower ? this . thumbs . lower : this . thumbs . upper ; } private setThumbState ( thumb , hover , drag ) { if ( thumb === SliderThumb . Lower ) { this . thumbs . lower . hover = hover ; this . thumbs . lower . drag = drag ; } else { this . thumbs . upper . hover = hover ; this . thumbs . upper . drag = drag ; } this . updateTooltips ( thumb ) ; } thumbEvent ( thumb , event ) { if ( this . disabled ) { return ; } const state = this . getThumbState ( thumb ) ; switch ( event ) { case SliderThumbEvent . DragStart : state . drag = true ; break ; case SliderThumbEvent . DragEnd : state . drag = false ; break ; case SliderThumbEvent . MouseOver : state . hover = true ; break ; case SliderThumbEvent . MouseLeave : state . hover = false ; break ; case SliderThumbEvent . None : state . drag = false ; state . hover = false ; break ; } this . setThumbState ( thumb , state . hover , state . drag ) ; } getAriaValueText ( thumb ) { const value = this . getThumbValue ( thumb ) ; const tick = this . ticks . find ( _tick => _tick . value === value ) ; if ( tick && tick . label ) { return tick . label ; } return this . getFormattedValue ( thumb ) ; } private updateTooltips ( thumb ) { let visible = false ; const state = this . getThumbState ( thumb ) ; switch ( this . options . handles . callout . trigger ) { case SliderCalloutTrigger . Persistent : visible = true ; break ; case SliderCalloutTrigger . Drag : visible = state . drag ; break ; case SliderCalloutTrigger . Hover : visible = state . hover || state . drag ; break ; case SliderCalloutTrigger . Dynamic : visible = true ; break ; } this . getTooltip ( thumb ) . visible = visible ; this . updateTooltipText ( thumb ) ; this . updateTooltipPosition ( thumb ) ; } private updateTooltipText ( thumb ) { const state = this . getThumbState ( thumb ) ; const tooltip = this . getTooltip ( thumb ) ; tooltip . label = this . getFormattedValue ( thumb ) . toString ( ) ; } private getTooltipElement ( thumb ) { return thumb === SliderThumb . Lower ? this . lowerTooltip : this . upperTooltip ; } private getTooltip ( thumb ) { return thumb === SliderThumb . Lower ? this . tooltips . lower : this . tooltips . upper ; } private updateTooltipPosition ( thumb ) { const tooltip = this . getTooltip ( thumb ) ; if ( tooltip . visible === false ) { return ; } const tooltipElement = this . getTooltipElement ( thumb ) ; let thumbWidth ; if ( this . _options . handles . style === SliderStyle . Button ) { thumbWidth = this . _options . track . height === SliderSize . Narrow ? 16 : 24 ; } else { thumbWidth = 2 ; } const tooltipWidth = tooltipElement . nativeElement . offsetWidth ; const tooltipPosition = Math . ceil ( ( tooltipWidth - thumbWidth ) / 2 ) ; tooltip . position = - tooltipPosition ; if ( this . _options . type === SliderType . Range && this . _options . handles . callout . trigger === SliderCalloutTrigger . Dynamic ) { this . preventTooltipOverlap ( tooltip ) ; } } private preventTooltipOverlap ( tooltip ) { const trackWidth = this . track . nativeElement . offsetWidth ; const lower = ( trackWidth / 100 ) * this . thumbs . lower . position ; const upper = ( trackWidth / 100 ) * this . thumbs . upper . position ; const lowerWidth = this . lowerTooltip . nativeElement . offsetWidth / 2 ; const upperWidth = this . upperTooltip . nativeElement . offsetWidth / 2 ; const diff = ( lower + lowerWidth ) - ( upper - upperWidth ) ; if ( diff > 0 ) { if ( tooltip === this . tooltips . lower && this . thumbs . lower . drag === false ) { tooltip . position -= ( diff / 2 ) ; } else if ( tooltip === this . tooltips . upper && this . thumbs . upper . drag === false ) { tooltip . position += ( diff / 2 ) ; } } } private clamp ( value , min , max ) { return Math . min ( Math . max ( value , min ) , max ) ; } updateThumbPosition ( event , thumb ) { if ( this . disabled ) { return ; } const eventPosition = event instanceof MouseEvent ? event . clientX : event . touches && event . touches . length > 0 ? event . touches [ 0 ] . clientX : null ; if ( eventPosition === null ) { return ; } const mouseX = window . pageXOffset + eventPosition ; const trackBounds = this . track . nativeElement . getBoundingClientRect ( ) ; const position = this . clamp ( mouseX - trackBounds . left , 0 , trackBounds . width ) ; const fraction = ( position / trackBounds . width ) ; let value = ( ( this . _options . track . max - this . _options . track . min ) * fraction ) + this . _options . track . min ; value = this . validateValue ( thumb , value ) ; value = this . snapToTick ( value , thumb ) ; this . setThumbValue ( thumb , value ) ; this . updateOrder ( thumb ) ; this . updateValues ( ) ; this . updateTooltipText ( thumb ) ; this . updateTooltipPosition ( SliderThumb . Lower ) ; this . updateTooltipPosition ( SliderThumb . Upper ) ; this . _changeDetectorRef . markForCheck ( ) ; } private updateOrder ( thumb ) { const lower = thumb === SliderThumb . Lower ? 101 : 100 ; const upper = thumb === SliderThumb . Lower ? 100 : 101 ; this . thumbs . lower . order = lower ; this . thumbs . upper . order = upper ; } private getTickDistances ( value , thumb , snapTarget ) { if ( snapTarget === SliderSnap . None ) { return [ ] ; } let ticks ; switch ( snapTarget ) { case SliderSnap . Minor : ticks = this . ticks . filter ( tick => tick . type === SliderTickType . Minor ) ; break ; case SliderSnap . Major : ticks = this . ticks . filter ( tick => tick . type === SliderTickType . Major ) ; break ; default : ticks = this . ticks . slice ( 0 ) ; } let lowerLimit = this . _options . track . min ; let upperLimit = this . _options . track . max ; if ( this . _options . type === SliderType . Range && thumb === SliderThumb . Lower ) { upperLimit = this . thumbs . upper . value ; } if ( this . _options . type === SliderType . Range && thumb === SliderThumb . Upper ) { lowerLimit = this . thumbs . lower . value ; } const range = ticks . filter ( tick => tick . value >= lowerLimit && tick . value <= upperLimit ) ; if ( range . length === 0 ) { return [ ] ; } return range . sort ( ( tickOne , tickTwo ) => { const tickOneDelta = Math . max ( tickOne . value , value ) - Math . min ( tickOne . value , value ) ; const tickTwoDelta = Math . max ( tickTwo . value , value ) - Math . min ( tickTwo . value , value ) ; return tickOneDelta - tickTwoDelta ; } ) ; } private snapToTick ( value , thumb ) { const tickDistances = this . getTickDistances ( value , thumb , this . _options . track . ticks . snap ) ; if ( tickDistances . length === 0 ) { return value ; } return tickDistances [ 0 ] . value ; } private validateValue ( thumb , value ) { if ( this . _options . type === SliderType . Value ) { return Math . max ( Math . min ( value , this . _options . track . max ) , this . _options . track . min ) ; } if ( value > this . _options . track . max ) { return thumb === SliderThumb . Lower ? Math . min ( this . _options . track . max , this . thumbs . upper . value ) : this . _options . track . max ; } if ( value < this . _options . track . min ) { return thumb === SliderThumb . Upper ? Math . max ( this . _options . track . min , this . thumbs . lower . value ) : this . _options . track . min ; } if ( thumb === SliderThumb . Lower ) { if ( this . thumbs . upper . value === null ) { return value ; } return value <= this . thumbs . upper . value ? value : this . thumbs . upper . value ; } if ( thumb === SliderThumb . Upper ) { if ( this . thumbs . lower . value === null ) { return value ; } return value >= this . thumbs . lower . value ? value : this . thumbs . lower . value ; } } private updateOptions ( ) { this . _options = this . deepMerge ( this . _options || { } , this . defaultOptions ) ; this . updateTrackColors ( ) ; this . updateTicks ( ) ; this . updateValues ( ) ; } private updateValues ( ) { if ( this . value === undefined || this . value === null ) { this . value = 0 ; } let lowerValue = typeof this . value === 'number' ? this . value : this . value . low ; let upperValue = typeof this . value === 'number' ? this . value : this . value . high ; lowerValue = this . validateValue ( SliderThumb . Lower , Number ( lowerValue . toFixed ( 4 ) ) ) ; upperValue = this . validateValue ( SliderThumb . Upper , Number ( upperValue . toFixed ( 4 ) ) ) ; const lowerPosition = ( ( ( lowerValue - this . _options . track . min ) / ( this . _options . track . max - this . _options . track . min ) ) * 100 ) ; const upperPosition = ( ( ( upperValue - this . _options . track . min ) / ( this . _options . track . max - this . _options . track . min ) ) * 100 ) ; this . thumbs . lower . position = lowerPosition ; this . thumbs . upper . position = upperPosition ; this . tracks . lower . size = lowerPosition ; this . tracks . middle . size = upperPosition - lowerPosition ; this . tracks . upper . size = this . _options . type === SliderType . Value ? 100 - lowerPosition : 100 - upperPosition ; this . setValue ( lowerValue , upperValue ) ; } private setValue ( low , high ? ) { this . thumbs . lower . value = low ; this . thumbs . upper . value = high ; const previousValue = this . clone ( this . _value ) ; this . value = this . _options . type === SliderType . Value ? low : { low , high } ; if ( this . detectValueChange ( this . value , previousValue ) ) { this . valueChange . emit ( this . clone ( this . value ) ) ; this . updateTooltipText ( SliderThumb . Lower ) ; this . updateTooltipText ( SliderThumb . Upper ) ; } else { this . valueChange . emit ( this . clone ( this . value ) ) ; } } private setThumbValue ( thumb , value ) { this . getThumbState ( thumb ) . value = value ; this . setValue ( this . thumbs . lower . value , this . thumbs . upper . value ) ; } private updateTicks ( ) { const majorOptions = this . _options . track . ticks . major ; const minorOptions = this . _options . track . ticks . minor ; if ( majorOptions . show === false && minorOptions . show === false ) { this . ticks = [ ] ; } const majorTicks = this . getTicks ( majorOptions , SliderTickType . Major ) . filter ( tick => tick . showTicks ) ; const minorTicks = this . getTicks ( minorOptions , SliderTickType . Minor ) . filter ( tick => tick . showTicks ) ; this . ticks = this . unionTicks ( majorTicks , minorTicks ) ; } private updateTrackColors ( ) { const { lower , range , higher } = this . _options . track . colors ; this . tracks . lower . color = this . getTrackColorStyle ( lower ) ; this . tracks . middle . color = this . getTrackColorStyle ( range ) ; this . tracks . upper . color = this . getTrackColorStyle ( higher ) ; } /** Map the color value to the correct CSS color value */ private getTrackColorStyle ( color ) { return Array . isArray ( color ) ? `linear-gradient(to right, ${ color . join ( ', ' ) })` : color ; } private getSteps ( steps ) { if ( steps instanceof Array ) { return steps ; } const output = [ ] ; for ( let idx = this . _options . track . min ; idx <= this . _options . track . max ; idx += steps ) { output . push ( idx ) ; } return output ; } private getTicks ( options , type ) { const steps = this . getSteps ( options . steps ) ; const min = this . _options . track . min ; const max = this . _options . track . max ; return steps . map ( step => { return { showTicks : options . show , showLabels : options . labels , type , position : ( ( step - min ) / ( max - min ) ) * 100 , value : step , label : options . formatter ( step ) } ; } ) . filter ( tick => tick . position >= 0 && tick . position <= 100 ) ; } private unionTicks ( majorTicks , minorTicks ) { return majorTicks . concat ( minorTicks ) . filter ( ( tick , index , array ) => tick . type === SliderTickType . Major || ! array . find ( tk => tk . type === SliderTickType . Major && tk . position === tick . position ) ) . sort ( ( t1 , t2 ) => t1 . value - t2 . value ) ; } private deepMerge < T > ( destination , source ) { for ( const prop in source ) { if ( ! destination . hasOwnProperty ( prop ) ) { destination [ prop ] = source [ prop ] ; continue ; } if ( typeof destination [ prop ] !== 'object' ) { continue ; } if ( destination [ prop ] instanceof Array ) { continue ; } destination [ prop ] = this . deepMerge ( destination [ prop ] , source [ prop ] ) ; } return destination ; } private detectValueChange ( value1 , value2 ) { if ( this . isSliderValue ( value1 ) && this . isSliderValue ( value2 ) ) { const obj1 = value1 as SliderValue ; const obj2 = value2 as SliderValue ; return obj1 . low !== obj2 . low || obj1 . high !== obj2 . high ; } return value1 !== value2 ; } param value private isSliderValue ( value ) { if ( typeof value !== 'object' ) { return false ; } return 'low' in value && 'high' in value ; } private clone ( value ) { if ( typeof value !== 'object' ) { return value ; } const instance = { ... value } ; value = undefined ; return instance ; } static ngAcceptInputType_disabled : boolean | string ; } export enum SliderType { Value , Range } export enum SliderStyle { Button , Line } export enum SliderSize { Narrow , Wide } export enum SliderCalloutTrigger { None , Hover , Drag , Persistent , Dynamic } export interface SliderValue { low : number ; high : number ; } export enum SliderSnap { None , Minor , Major , All } export enum SliderTickType { Minor , Major } export interface SliderOptions { type ? : SliderType ; handles ? : SliderHandleOptions ; track ? : SliderTrackOptions ; } export interface SliderHandleOptions { style ? : SliderStyle ; callout ? : SliderCallout ; keyboard ? : SliderKeyboardOptions ; aria ? : SliderAriaOptions ; } export interface SliderAriaOptions { thumb ? : string ; lowerThumb ? : string ; upperThumb ? : string ; } export interface SliderKeyboardOptions { major ? : number ; minor ? : number ; } export interface SliderTrackOptions { height ? : SliderSize ; min ? : number ; max ? : number ; ticks ? : SliderTicksOptions ; colors ? : SliderTrackColors ; } export interface SliderTicksOptions { snap ? : SliderSnap ; major ? : SliderTickOptions ; minor ? : SliderTickOptions ; } export interface SliderTickOptions { show ? : boolean ; steps ? : number | number [ ] ; labels ? : boolean ; formatter ? : ( value : number ) => string | number ; } export interface SliderTick { showTicks : boolean ; showLabels : boolean ; type : SliderTickType ; position : number ; value : number ; label : string | number ; } export interface SliderTrackColors { lower ? : string | string [ ] ; range ? : string | string [ ] ; higher ? : string | string [ ] ; } export interface SliderCallout { trigger ? : SliderCalloutTrigger ; background ? : string ; color ? : string ; formatter ? : ( value : number ) => string | number ; } export enum SliderThumbEvent { None , MouseOver , MouseLeave , DragStart , DragEnd } export enum SliderThumb { Lower , Upper }