import { ExportNs } from '../../esl-utils/environment/export-ns' ; import { ESLBaseElement , attr , boolAttr } from '../../esl-base-element/core' ; import { bind } from '../../esl-utils/decorators/bind' ; import { ready } from '../../esl-utils/decorators/ready' ; import { rafDecorator } from '../../esl-utils/async/raf' ; import { EventUtils } from '../../esl-utils/dom/events' ; import { isRelativeNode } from '../../esl-utils/dom/traversing' ; import { TraversingQuery } from '../../esl-traversing-query/core' ; import { RTLUtils } from '../../esl-utils/dom/rtl' ; author Yuliya Adamskaya
  @ ExportNs ( 'Scrollbar' ) export class ESLScrollbar extends ESLBaseElement { public static is = 'esl-scrollbar' ; /** Horizontal scroll orientation marker */ @ boolAttr ( ) public horizontal : boolean ; /** Disable continuous scroll when the mouse pressed on scrollbar */ @ boolAttr ( ) public noContinuousScroll : boolean ; /** Target element  TraversingQuery selector. Parent element by default  @ attr ( { defaultValue : '::parent' } ) public target : string ; /** Custom class for thumb element. 'scrollbar-thumb' by default */ @ attr ( { defaultValue : 'scrollbar-thumb' } ) public thumbClass : string ; /** Custom class for track element area. 'scrollbar-track' by default */ @ attr ( { defaultValue : 'scrollbar-track' } ) public trackClass : string ; readonly @ boolAttr ( { readonly : true } ) public dragging : boolean ; readonly @ boolAttr ( { readonly : true } ) public inactive : boolean ; readonly @ boolAttr ( { readonly : true } ) public atStart : boolean ; readonly @ boolAttr ( { readonly : true } ) public atEnd : boolean ; protected $scrollbarThumb : HTMLElement ; protected $scrollbarTrack : HTMLElement ; protected _$target : HTMLElement | null ; protected _pointerPosition : number ; protected _initialPosition : number ; protected _initialMousePosition : number ; protected _deferredDrag = rafDecorator ( ( e ) => this . _onPointerDrag ( e ) ) ; protected _deferredRefresh = rafDecorator ( ( ) => this . refresh ( ) ) ; protected _scrollTimer : number = 0 ; protected _resizeObserver = new ResizeObserver ( this . _deferredRefresh ) ; protected _mutationObserver = new MutationObserver ( ( rec ) => this . updateContentObserve ( rec ) ) ; static get observedAttributes ( ) : string [ ] { return [ 'target' , 'horizontal' ] ; } @ ready protected connectedCallback ( ) { super . connectedCallback ( ) ; this . findTarget ( ) ; this . render ( ) ; this . bindEvents ( ) ; } @ ready protected disconnectedCallback ( ) { this . unbindEvents ( ) ; this . _scrollTimer && window . clearTimeout ( this . _scrollTimer ) ; } protected attributeChangedCallback ( attrName , oldVal , newVal ) { if ( ! this . connected && oldVal === newVal ) return ; if ( attrName === 'target' ) this . findTarget ( ) ; if ( attrName === 'horizontal' ) this . refresh ( ) ; } protected findTarget ( ) { this . $target = this . target ? TraversingQuery . first ( this . target , this ) as HTMLElement : null ; } /** Target element to observe and scroll */ public get $target ( ) : HTMLElement | null { return this . _$target || null ; } public set $target ( content ) { this . unbindTargetEvents ( ) ; this . _$target = content ; this . bindTargetEvents ( ) ; this . _deferredRefresh ( ) ; } protected render ( ) { this . innerHTML = '' ; this . $scrollbarTrack = document . createElement ( 'div' ) ; this . $scrollbarTrack . className = this . trackClass ; this . $scrollbarThumb = document . createElement ( 'div' ) ; this . $scrollbarThumb . className = this . thumbClass ; this . $scrollbarTrack . appendChild ( this . $scrollbarThumb ) ; this . appendChild ( this . $scrollbarTrack ) ; } protected bindEvents ( ) { window . MouseEvent && this . addEventListener ( 'mousedown' , this . _onPointerDown ) ; window . TouchEvent && this . addEventListener ( 'touchstart' , this . _onPointerDown ) ; window . addEventListener ( 'esl:refresh' , this . _onRefresh ) ; } protected bindTargetEvents ( ) { if ( ! this . $target ) return ; if ( document . documentElement === this . $target ) { window . addEventListener ( 'resize' , this . _onRefresh , { passive : true } ) ; window . addEventListener ( 'scroll' , this . _onRefresh , { passive : true } ) ; } else { this . _resizeObserver . observe ( this . $target ) ; this . _mutationObserver . observe ( this . $target , { childList : true } ) ; Array . from ( this . $target . children ) . forEach ( ( el ) => this . _resizeObserver . observe ( el ) ) ; this . $target . addEventListener ( 'scroll' , this . _onRefresh , { passive : true } ) ; } } protected updateContentObserve ( recs = [ ] ) { if ( ! this . $target ) return ; const contentChanges = recs . filter ( ( rec ) => rec . type === 'childList' ) ; contentChanges . forEach ( ( rec ) => { Array . from ( rec . addedNodes ) . filter ( ( el ) => el instanceof Element ) . forEach ( ( el ) => this . _resizeObserver . observe ( el ) ) ; Array . from ( rec . removedNodes ) . filter ( ( el ) => el instanceof Element ) . forEach ( ( el ) => this . _resizeObserver . unobserve ( el ) ) ; } ) ; if ( contentChanges . length ) this . _deferredRefresh ( ) ; } protected unbindEvents ( ) { window . MouseEvent && this . removeEventListener ( 'mousedown' , this . _onPointerDown ) ; window . TouchEvent && this . removeEventListener ( 'touchstart' , this . _onPointerDown ) ; this . unbindTargetEvents ( ) ; window . removeEventListener ( 'esl:refresh' , this . _onRefresh ) ; } protected unbindTargetEvents ( ) { if ( ! this . $target ) return ; if ( document . documentElement === this . $target ) { window . removeEventListener ( 'resize' , this . _onRefresh ) ; window . removeEventListener ( 'scroll' , this . _onRefresh ) ; } else { this . _resizeObserver . disconnect ( ) ; this . _mutationObserver . disconnect ( ) ; this . $target . removeEventListener ( 'scroll' , this . _onRefresh ) ; } } readonly public get scrollableSize ( ) : number { if ( ! this . $target ) return 0 ; return this . horizontal ? this . $target . scrollWidth - this . $target . clientWidth : this . $target . scrollHeight - this . $target . clientHeight ; } readonly public get trackOffset ( ) : number { return this . horizontal ? this . $scrollbarTrack . offsetWidth : this . $scrollbarTrack . offsetHeight ; } readonly public get thumbOffset ( ) : number { return this . horizontal ? this . $scrollbarThumb . offsetWidth : this . $scrollbarThumb . offsetHeight ; } readonly public get thumbSize ( ) : number { if ( ! this . $target || ! this . $target . scrollWidth || ! this . $target . scrollHeight ) return 1 ; const areaSize = this . horizontal ? this . $target . clientWidth : this . $target . clientHeight ; const scrollSize = this . horizontal ? this . $target . scrollWidth : this . $target . scrollHeight ; return Math . min ( ( areaSize + 1 ) / scrollSize , 1 ) ; } /** Relative position value (between 0.0 and 1.0) */ public get position ( ) : number { if ( ! this . $target ) return 0 ; const scrollOffset = this . horizontal ? RTLUtils . normalizeScrollLeft ( this . $target ) : this . $target . scrollTop ; return this . scrollableSize ? ( scrollOffset / this . scrollableSize ) : 0 ; } public set position ( position ) { this . scrollTargetTo ( this . scrollableSize * this . normalizePosition ( position ) ) ; this . update ( ) ; } /** Normalizes position value (between 0.0 and 1.0) */ protected normalizePosition ( position ) { const relativePosition = Math . min ( 1 , Math . max ( 0 , position ) ) ; if ( this . $target && ! RTLUtils . isRtl ( this . $target ) ) return relativePosition ; return RTLUtils . scrollType === 'negative' ? ( relativePosition - 1 ) : ( 1 - relativePosition ) ; } /** Scrolls target element to passed position */ protected scrollTargetTo ( pos ) { if ( ! this . $target ) return ; this . $target . scrollTo ( { [ this . horizontal ? 'left' : 'top' ] : pos , behavior : this . dragging ? 'auto' : 'smooth' } ) ; } /** Updates thumb size and position */ public update ( ) { this . $$fire ( 'change:scroll' , { bubbles : false } ) ; if ( ! this . $scrollbarThumb || ! this . $scrollbarTrack ) return ; const thumbSize = this . trackOffset * this . thumbSize ; const thumbPosition = ( this . trackOffset - thumbSize ) * this . position ; const style = { [ this . horizontal ? 'left' : 'top' ] : `${ thumbPosition }px` , [ this . horizontal ? 'width' : 'height' ] : `${ thumbSize }px` } ; Object . assign ( this . $scrollbarThumb . style , style ) ; } /** Updates auxiliary markers */ public updateMarkers ( ) { const { position , thumbSize } = this ; this . toggleAttribute ( 'at-start' , thumbSize < 1 && position <= 0 ) ; this . toggleAttribute ( 'at-end' , thumbSize < 1 && position >= 1 ) ; this . toggleAttribute ( 'inactive' , thumbSize >= 1 ) ; } /** Refreshes scroll state and position */ public refresh ( ) { this . update ( ) ; this . updateMarkers ( ) ; } /** Returns position from MouseEvent coordinates (not normalized) */ public toPosition ( event ) { const { horizontal , thumbOffset , trackOffset } = this ; const point = EventUtils . normalizeCoordinates ( event , this . $scrollbarTrack ) ; const pointPosition = horizontal ? point . x : point . y ; const freeTrackArea = trackOffset - thumbOffset ; const clickPositionNoOffset = pointPosition - thumbOffset / 2 ; return clickPositionNoOffset / freeTrackArea ; } /** Handles `mousedown` / `touchstart` event to manage thumb drag start and scroll clicks */ @ bind protected _onPointerDown ( event ) { this . _initialPosition = this . position ; this . _pointerPosition = this . toPosition ( event ) ; const point = EventUtils . normalizeTouchPoint ( event ) ; this . _initialMousePosition = this . horizontal ? point . x : point . y ; if ( event . target === this . $scrollbarThumb ) { this . _onThumbPointerDown ( event ) ; } else { this . _onPointerDownTick ( true ) ; } EventUtils . isMouseEvent ( event ) && window . addEventListener ( 'mouseup' , this . _onPointerUp ) ; EventUtils . isTouchEvent ( event ) && window . addEventListener ( 'touchend' , this . _onPointerUp , { passive : false } ) ; event . preventDefault ( ) ; } /** Handles a scroll click / continuous scroll*/ @ bind protected _onPointerDownTick ( first ) { this . _scrollTimer && window . clearTimeout ( this . _scrollTimer ) ; const position = this . position ; const allowedOffset = ( first ? 1 : 1.5 ) * this . thumbSize ; this . position = Math . min ( position + allowedOffset , Math . max ( position - allowedOffset , this . _pointerPosition ) ) ; if ( this . position === this . _pointerPosition || this . noContinuousScroll ) return ; this . _scrollTimer = window . setTimeout ( this . _onPointerDownTick , 400 ) ; } /** Handles thumb drag start */ @ bind protected _onThumbPointerDown ( event ) { this . toggleAttribute ( 'dragging' , true ) ; this . $target ?. style . setProperty ( 'scroll-behavior' , 'auto' ) ; window . addEventListener ( 'click' , this . _onBodyClick , { capture : true } ) ; EventUtils . isMouseEvent ( event ) && window . addEventListener ( 'mousemove' , this . _onPointerMove ) ; EventUtils . isTouchEvent ( event ) && window . addEventListener ( 'touchmove' , this . _onPointerMove , { passive : false } ) ; } /** Sets position on drag */ protected _onPointerDrag ( event ) { const point = EventUtils . normalizeTouchPoint ( event ) ; const mousePosition = this . horizontal ? point . x : point . y ; const positionChange = mousePosition - this . _initialMousePosition ; const scrollableAreaHeight = this . trackOffset - this . thumbOffset ; const absChange = scrollableAreaHeight ? ( positionChange / scrollableAreaHeight ) : 0 ; this . position = this . _initialPosition + absChange ; this . updateMarkers ( ) ; } /** `mousemove` document handler for thumb drag event. Active only if drag action is active */ @ bind protected _onPointerMove ( event ) { if ( ! this . dragging ) return ; this . _deferredDrag ( event ) ; event . preventDefault ( ) ; event . stopPropagation ( ) ; } /** `mouseup` short-time document handler for drag end action */ @ bind protected _onPointerUp ( event ) { this . _scrollTimer && window . clearTimeout ( this . _scrollTimer ) ; this . toggleAttribute ( 'dragging' , false ) ; this . $target ?. style . removeProperty ( 'scroll-behavior' ) ; if ( EventUtils . isMouseEvent ( event ) ) { window . removeEventListener ( 'mousemove' , this . _onPointerMove ) ; window . removeEventListener ( 'mouseup' , this . _onPointerUp ) ; } if ( EventUtils . isTouchEvent ( event ) ) { window . removeEventListener ( 'touchmove' , this . _onPointerMove ) ; window . removeEventListener ( 'touchend' , this . _onPointerUp ) ; } } /** Body `click` short-time handler to prevent clicks event on thumb drag. Handles capture phase */ @ bind protected _onBodyClick ( event ) { event . stopImmediatePropagation ( ) ; window . removeEventListener ( 'click' , this . _onBodyClick , { capture : true } ) ; } param event @ bind protected _onRefresh ( event ) { const target = event . target as HTMLElement ; if ( event . type === 'scroll' && this . dragging ) return ; if ( event . type === 'esl:refresh' && ! isRelativeNode ( target . parentNode , this . $target ) ) return ; this . _deferredRefresh ( ) ; } } declare global { export interface ESLLibrary { Scrollbar : typeof ESLScrollbar ; } export interface HTMLElementTagNameMap { 'esl-scrollbar' : ESLScrollbar ; } }