import { html , TemplateResult } from '@spectrum-web-components/base' ; import { classMap , ifDefined , styleMap , } from '@spectrum-web-components/base/src/directives.js' ; import { Slider } from './Slider.js' ; import { Controller , SliderHandle , SliderNormalization , } from './SliderHandle.js' ; interface HandleReference { handle : HTMLElement ; input : HTMLInputElement ; } interface HandleComponents extends HandleReference { model : SliderHandle ; } interface RangeAndClamp { range : { min : number ; max : number ; } ; clamp : { min : number ; max : number ; } ; } interface ModelValue extends RangeAndClamp { name : string ; value : number ; normalizedValue : number ; step : number ; highlight : boolean ; ariaLabel ? : string ; normalization : SliderNormalization ; handle : SliderHandle ; } interface InputWithModel extends HTMLInputElement { model : ModelValue ; } interface DataFromPointerEvent { resolvedInput : boolean ; input : InputWithModel ; model ? : ModelValue ; } export interface HandleValueDictionary { [ key ] : number ; } export class HandleController implements Controller { private observer ! : MutationObserver ; private host ! : Slider ; private handles : Map < string , SliderHandle > = new Map ( ) ; private model : ModelValue [ ] = [ ] ; private handleOrder : string [ ] = [ ] ; private draggingHandle ? : SliderHandle ; private handleRefMap ? : WeakMap < SliderHandle , HandleReference > ; constructor ( host ) { this . host = host ; } public get values ( ) : HandleValueDictionary { const result = { } ; for ( const model of this . handles . values ( ) ) { result [ model . handleName ] = model . value ; } return result ; } public get size ( ) : number { return this . handles . size ; } public inputForHandle ( handle ) { if ( this . handles . has ( handle . handleName ) ) { const { input } = this . getHandleElements ( handle ) ; return input ; } throw new Error ( `No input for handle "${ handle . name }"` ) ; } public requestUpdate ( ) { this . host . requestUpdate ( ) ; } param handle public setValueFromHandle ( handle ) { const elements = this . getHandleElements ( handle ) ; if ( ! elements ) return ; const { input } = elements ; if ( input . valueAsNumber === handle . value ) { if ( handle . dragging ) { handle . dispatchInputEvent ( ) ; } } else { input . valueAsNumber = handle . value ; handle . value = input . valueAsNumber ; this . requestUpdate ( ) ; } handle . value = input . valueAsNumber ; } public handleHasChanged ( handle ) { if ( handle !== this . host ) { this . requestUpdate ( ) ; } } public formattedValueForHandle ( model ) { const { handle } = model ; const numberFormat = handle . numberFormat ?? this . host . numberFormat ; return handle . getAriaHandleText ( model . value , numberFormat ) ; } public get formattedValues ( ) : Map < string , string > { const result = new Map < string , string > ( ) ; for ( const model of this . model ) { result . set ( model . name , this . formattedValueForHandle ( model ) ) ; } return result ; } public get focusElement ( ) : HTMLElement { const { input } = this . getActiveHandleElements ( ) ; if ( this . host . editable && ! ( input as InputWithModel ) . model . handle . dragging ) { return this . host . numberField ; } return input ; } protected handleOrientation = ( ) : void => { this . updateBoundingRect ( ) ; } ; public hostConnected ( ) { if ( ! this . observer ) { this . observer = new MutationObserver ( this . extractModelFromLightDom ) ; } this . observer . observe ( this . host , { subtree : true , childList : true } ) ; this . extractModelFromLightDom ( ) ; if ( 'orientation' in screen ) { screen . orientation . addEventListener ( 'change' , this . handleOrientation ) ; } else { window . addEventListener ( 'orientationchange' , this . handleOrientation ) ; } } public hostDisconnected ( ) { this . observer . disconnect ( ) ; if ( 'orientation' in screen ) { screen . orientation . removeEventListener ( 'change' , this . handleOrientation ) ; } else { window . removeEventListener ( 'orientationchange' , this . handleOrientation ) ; } } public hostUpdate ( ) { this . updateModel ( ) ; } private waitForUpgrade ( handle ) { if ( handle instanceof SliderHandle ) { return false ; } handle . addEventListener ( 'sp-slider-handle-ready' , ( ) => this . extractModelFromLightDom ( ) , { once : true , passive : true } ) ; return true ; } private extractModelFromLightDom = ( ) : void => { let handles = [ ... this . host . querySelectorAll ( '[slot="handle"]' ) , ] as SliderHandle [ ] ; if ( handles . length === 0 ) { handles = [ this . host as SliderHandle ] ; } if ( handles . some ( ( h ) => this . waitForUpgrade ( h ) ) ) { return ; } this . handles = new Map ( ) ; this . handleOrder = [ ] ; handles . forEach ( ( handle , index ) => { if ( ! handle . handleName ?. length ) { handle . name = `handle${ index + 1 }` ; } this . handles . set ( handle . handleName , handle ) ; this . handleOrder . push ( handle . handleName ) ; handle . handleController = this ; } ) ; this . requestUpdate ( ) ; } ; public get activeHandle ( ) : string { return this . handleOrder [ this . handleOrder . length - 1 ] ; } public get activeHandleInputId ( ) : string { const active = this . activeHandle ; const index = this . model . findIndex ( ( model ) => model . name === active ) ; return `input-${ index }` ; } public activateHandle ( name ) { const index = this . handleOrder . findIndex ( ( item ) => item === name ) ; if ( index >= 0 ) { this . handleOrder . splice ( index , 1 ) ; } this . handleOrder . push ( name ) ; } private getActiveHandleElements ( ) { const name = this . activeHandle ; const handleSlider = this . handles . get ( name ) as SliderHandle ; const elements = this . getHandleElements ( handleSlider ) as HandleReference ; return { model : handleSlider , ... elements } ; } private getHandleElements ( sliderHandle ) { if ( ! this . handleRefMap ) { this . handleRefMap = new WeakMap ( ) ; const inputNodes = this . host . shadowRoot . querySelectorAll ( '.handle > input' ) ; for ( const inputNode of inputNodes ) { const input = inputNode as HTMLInputElement ; const handle = input . parentElement as HTMLElement ; const model = this . handles . get ( handle . getAttribute ( 'name' ) as string ) ; if ( model ) { this . handleRefMap . set ( model , { input , handle } ) ; } } } const components = this . handleRefMap . get ( sliderHandle ) as HandleReference ; return components ; } private clearHandleComponentCache ( ) { delete this . handleRefMap ; } private _boundingClientRect ? : DOMRect ; private get boundingClientRect ( ) : DOMRect { if ( ! this . _boundingClientRect ) { this . _boundingClientRect = this . host . track . getBoundingClientRect ( ) ; } return this . _boundingClientRect ; } private updateBoundingRect ( ) { delete this . _boundingClientRect ; } /**
     * Return the `input` and `model` associated with the event and
     * whether the `input` is a `resolvedInput` meaning it was acquired
     * from the `model` rather than the event.
     */ protected extractDataFromEvent ( event ) { if ( ! this . _activePointerEventData ) { let input = ( event . target as Element ) . querySelector ( ':scope > .input' ) as InputWithModel ; const resolvedInput = ! input ; const model = input ? input . model : this . model . find ( ( item ) => item . name === this . activeHandle ) ; if ( ! input && ! ! model ) { input = model . handle . focusElement as InputWithModel ; } this . _activePointerEventData = { input , model , resolvedInput , } ; } return this . _activePointerEventData ; } private _activePointerEventData ! : DataFromPointerEvent | undefined ; public handlePointerdown ( event ) { const { resolvedInput , model } = this . extractDataFromEvent ( event ) ; if ( ! model || this . host . disabled || event . button !== 0 ) { event . preventDefault ( ) ; return ; } this . host . track . setPointerCapture ( event . pointerId ) ; this . updateBoundingRect ( ) ; this . host . labelEl . click ( ) ; this . draggingHandle = model . handle ; model . handle . dragging = true ; this . activateHandle ( model . name ) ; if ( resolvedInput ) { this . handlePointermove ( event ) ; } this . requestUpdate ( ) ; } public handlePointerup ( event ) { const { input , model } = this . extractDataFromEvent ( event ) ; delete this . _activePointerEventData ; if ( ! model ) return ; this . host . labelEl . click ( ) ; model . handle . highlight = false ; delete this . draggingHandle ; model . handle . dragging = false ; this . requestUpdate ( ) ; this . host . track . releasePointerCapture ( event . pointerId ) ; this . dispatchChangeEvent ( input , model . handle ) ; } public handlePointermove ( event ) { const { input , model } = this . extractDataFromEvent ( event ) ; if ( ! model ) return ; if ( ! this . draggingHandle ) { return ; } event . stopPropagation ( ) ; input . value = this . calculateHandlePosition ( event , model ) . toString ( ) ; model . handle . value = parseFloat ( input . value ) ; this . requestUpdate ( ) ; } /**
     * Keep the slider value property in sync with the input element's value
     */ private onInputChange = ( event ) : void => { const input = event . target as InputWithModel ; input . model . handle . value = input . valueAsNumber ; this . requestUpdate ( ) ; this . dispatchChangeEvent ( input , input . model . handle ) ; } ; private onInputFocus = ( event ) : void => { const input = event . target as InputWithModel ; let isFocusVisible ; try { isFocusVisible = input . matches ( ':focus-visible' ) || this . host . matches ( '.focus-visible' ) ; } catch ( error ) { isFocusVisible = this . host . matches ( '.focus-visible' ) ; } input . model . handle . highlight = isFocusVisible ; this . requestUpdate ( ) ; } ; private onInputBlur = ( event ) : void => { const input = event . target as InputWithModel ; input . model . handle . highlight = false ; this . requestUpdate ( ) ; } ; private onInputKeydown = ( event ) : void => { const input = event . target as InputWithModel ; input . model . handle . highlight = true ; this . requestUpdate ( ) ; } ; private dispatchChangeEvent ( input , handle ) { input . valueAsNumber = handle . value ; const changeEvent = new Event ( 'change' , { bubbles : true , composed : true , } ) ; handle . dispatchEvent ( changeEvent ) ; } param return private calculateHandlePosition ( event , model ) { const rect = this . boundingClientRect ; const minOffset = rect . left ; const offset = event . clientX ; const size = rect . width ; const normalized = ( offset - minOffset ) / size ; const value = model . normalization . fromNormalized ( normalized , model . range . min , model . range . max ) ; return this . host . isLTR ? value : model . range . max - value ; } public renderHandle ( model , index , zIndex , isMultiHandle ) { const classes = { handle : true , dragging : this . draggingHandle ?. handleName === model . name , 'handle-highlight' : model . highlight , } ; const style = { [ this . host . isLTR ? 'left' : 'right' ] : `${ model . normalizedValue * 100 }%` , 'z-index' : zIndex . toString ( ) , 'background-color' : `var(--spectrum-slider-handle-background-color-${ index }, var(--spectrum-slider-handle-default-background-color))` , 'border-color' : `var(--spectrum-slider-handle-border-color-${ index }, var(-spectrum-slider-handle-default-border-color))` , } ; const ariaLabelledBy = isMultiHandle ? `label input-${ index }` : 'label' ; return html `
            <div
                class=${ classMap ( classes ) }
                name=${ model . name }
                style=${ styleMap ( style ) }
                role="presentation"
            >
                <input
                    type="range"
                    class="input"
                    id="input-${ index }"
                    min=${ model . clamp . min }
                    max=${ model . clamp . max }
                    step=${ model . step }
                    value=${ model . value }
                    aria-disabled=${ ifDefined ( this . host . disabled ? 'true' : undefined ) }
                    tabindex=${ ifDefined ( this . host . editable ? - 1 : undefined ) }
                    aria-label=${ ifDefined ( model . ariaLabel ) }
                    aria-labelledby=${ ariaLabelledBy }
                    aria-valuetext=${ this . formattedValueForHandle ( model ) }
                    @change=${ this . onInputChange }
                    @focus=${ this . onInputFocus }
                    @blur=${ this . onInputBlur }
                    @keydown=${ this . onInputKeydown }
                    .model=${ model }
                />
            </div>
        ` ; } public render ( ) { this . clearHandleComponentCache ( ) ; return this . model . map ( ( model , index ) => { const zIndex = this . handleOrder . indexOf ( model . name ) + 1 ; return this . renderHandle ( model , index , zIndex , this . model . length > 1 ) ; } ) ; } returns public trackSegments ( ) { const values = this . model . map ( ( model ) => model . normalizedValue ) ; values . sort ( ( a , b ) => a - b ) ; values . unshift ( 0 ) ; return values . map ( ( value , index , array ) => [ value , array [ index + 1 ] ?? 1 , ] ) ; } private updateModel ( ) { const handles = [ ... this . handles . values ( ) ] ; const getRangeAndClamp = ( index ) : RangeAndClamp => { const handle = handles [ index ] ; const previous = handles [ index - 1 ] ; const next = handles [ index + 1 ] ; const min = typeof handle . min === 'number' ? handle . min : ( this . host . min as number ) ; const max = typeof handle . max === 'number' ? handle . max : ( this . host . max as number ) ; const result = { range : { min : min , max : max } , clamp : { min : min , max : max } , } ; if ( handle . min === 'previous' ) { if ( previous ) { for ( let j = index - 1 ; j >= 0 ; j -- ) { const item = handles [ j ] ; if ( typeof item . min === 'number' ) { result . range . min = item . min ; break ; } } result . clamp . min = Math . max ( previous . value , result . range . min ) ; } else { console . warn ( 'First slider handle cannot have attribute min="previous"' ) ; } } if ( handle . max === 'next' ) { if ( next ) { for ( let j = index + 1 ; j < handles . length ; j ++ ) { const item = handles [ j ] ; if ( typeof item . max === 'number' ) { result . range . max = item . max ; break ; } } result . clamp . max = Math . min ( next . value , result . range . max ) ; } else { console . warn ( 'Last slider handle cannot have attribute max="next"' ) ; } } return result ; } ; const modelValues = handles . map ( ( handle , index ) => { const rangeAndClamp = getRangeAndClamp ( index ) ; const { toNormalized } = handle . normalization ; const clampedValue = Math . max ( Math . min ( handle . value , rangeAndClamp . clamp . max ) , rangeAndClamp . clamp . min ) ; const normalizedValue = toNormalized ( clampedValue , rangeAndClamp . range . min , rangeAndClamp . range . max ) ; const model = { name : handle . handleName , value : clampedValue , normalizedValue , highlight : handle . highlight , step : handle . step ?? this . host . step , normalization : handle . normalization , handle , ariaLabel : handle !== this . host && handle ?. label . length > 0 ? handle . label : undefined , ... rangeAndClamp , } ; return model ; } ) ; this . model = modelValues ; } public async handleUpdatesComplete ( ) { const updates = [ ... this . handles . values ( ) ] . filter ( ( handle ) => handle !== this . host ) . map ( ( handle ) => handle . updateComplete ) ; await Promise . all ( updates ) ; } }