Repository: exadel-inc/esl
Pull request #722 (2021-12-15 00:14:42)

feat(esl-scrollbar): ESL Scrollbar v2.0.0 + `EventUtils` improvements
ESL Scrollbar v2.0.0

- Add option to continuously scroll scrollbar when pointer pressed and does not release (enabled by default)
- Improvements for touch events support + touch drug support
- API updates: inner listeners and pointer processing mechanism changed 
(Performance testing: **passed**)

- Improvements and bugfixes for EventUtils helper

Closes: #508 
Closes: #675


Patch:
diff --git a/pages/views/examples/scrollbar.njk b/pages/views/examples/scrollbar.njk
index 77b3c0b4c..9e5786032 100644
--- a/pages/views/examples/scrollbar.njk
+++ b/pages/views/examples/scrollbar.njk
@@ -26,7 +26,7 @@ aside:
   <div class="p-4 esl-scrollable-content">
     <div style="height: 500px">
       <h2>Simple vertical scroll example</h2>
-      {{ lorem.paragraphs(7) }}
+      {{ lorem.paragraphs(25) }}
     </div>
   </div>
   <esl-scrollbar class="scrollbar-blue" target="::prev"></esl-scrollbar>
@@ -34,9 +34,9 @@ aside:
 
 <section class="border bg-white relative-block">
   <div class="p-4 esl-scrollable-content">
-    <div style="width: 1900px;">
+    <div style="width: 3200px;">
       <h2>Simple horizontal scroll example</h2>
-      {{ lorem.paragraphs(3) }}
+      {{ lorem.paragraphs(4) }}
     </div>
   </div>
   <esl-scrollbar class="scrollbar-blue" target="::prev" horizontal></esl-scrollbar>
diff --git a/src/modules/esl-scrollbar/README.md b/src/modules/esl-scrollbar/README.md
index 306f50dec..38ec47aa7 100644
--- a/src/modules/esl-scrollbar/README.md
+++ b/src/modules/esl-scrollbar/README.md
@@ -1,6 +1,6 @@
 # [ESL](../../../) Scrollbar
 
-Version: *1.3.0*
+Version: *2.0.0*
 
 Authors: *Yuliya Adamskaya*
 
@@ -60,6 +60,7 @@ Use the full module path to import it
 
 - `track-class` (default `scrollbar-track`) - class(es) that will be added to the scroll track
 
+- `no-continuous-scroll` \[boolean] (default `false`) - disable continuous scroll when the mouse button is pressed on the scrollbar
 
 ### Readonly Attributes
 
diff --git a/src/modules/esl-scrollbar/core/esl-scrollbar.shape.ts b/src/modules/esl-scrollbar/core/esl-scrollbar.shape.ts
index ffbf5076b..9b84e9a95 100644
--- a/src/modules/esl-scrollbar/core/esl-scrollbar.shape.ts
+++ b/src/modules/esl-scrollbar/core/esl-scrollbar.shape.ts
@@ -17,6 +17,9 @@ export interface ESLScrollbarTagShape extends ESLBaseElementShape<ESLScrollbar>
   /** track inner element class. 'scrollbar-track' by default. */
   'track-class'?: string;
 
+  /** Disable continuous scroll when the mouse button is pressed on the scrollbar */
+  'no-continuous-scroll'?: boolean;
+
   /** Children are not allowed for ESLScrollbar*/
   children?: never[];
 }
diff --git a/src/modules/esl-scrollbar/core/esl-scrollbar.ts b/src/modules/esl-scrollbar/core/esl-scrollbar.ts
index bcb8b0f0c..30cf3685e 100644
--- a/src/modules/esl-scrollbar/core/esl-scrollbar.ts
+++ b/src/modules/esl-scrollbar/core/esl-scrollbar.ts
@@ -20,6 +20,8 @@ export class ESLScrollbar extends ESLBaseElement {
 
   /** Horizontal scroll orientation marker */
   @boolAttr() public horizontal: boolean;
+  /** Disable continuous scroll when the mouse pressed on scrollbar */
+  @boolAttr() public noContinuousScroll: boolean;
 
   /** Target element {@link TraversingQuery} selector. Parent element by default */
   @attr({defaultValue: '::parent'}) public target: string;
@@ -42,11 +44,14 @@ export class ESLScrollbar extends ESLBaseElement {
   protected $scrollbarTrack: HTMLElement;
 
   protected _$target: HTMLElement | null;
+  protected _pointerShift: number;
+  protected _pointerPosition: number;
   protected _initialPosition: number;
-  protected _initialMousePosition: number;
 
-  protected deferredRefresh = rafDecorator(() => this.refresh());
-  protected _resizeObserver = new ResizeObserver(this.deferredRefresh);
+  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() {
@@ -64,6 +69,7 @@ export class ESLScrollbar extends ESLBaseElement {
   @ready
   protected disconnectedCallback() {
     this.unbindEvents();
+    this._scrollTimer && window.clearTimeout(this._scrollTimer);
   }
 
   protected attributeChangedCallback(attrName: string, oldVal: string, newVal: string) {
@@ -87,7 +93,7 @@ export class ESLScrollbar extends ESLBaseElement {
     this.unbindTargetEvents();
     this._$target = content;
     this.bindTargetEvents();
-    this.deferredRefresh();
+    this._deferredRefresh();
   }
 
   protected render() {
@@ -102,8 +108,8 @@ export class ESLScrollbar extends ESLBaseElement {
   }
 
   protected bindEvents() {
-    this.addEventListener('click', this._onClick);
-    this.$scrollbarThumb.addEventListener('mousedown', this._onMouseDown);
+    window.MouseEvent && this.addEventListener('mousedown', this._onPointerDown);
+    window.TouchEvent && this.addEventListener('touchstart', this._onPointerDown);
     window.addEventListener('esl:refresh', this._onRefresh);
   }
 
@@ -131,12 +137,12 @@ export class ESLScrollbar extends ESLBaseElement {
         .filter((el) => el instanceof Element)
         .forEach((el: Element) => this._resizeObserver.unobserve(el));
     });
-    if (contentChanges.length) this.deferredRefresh();
+    if (contentChanges.length) this._deferredRefresh();
   }
 
   protected unbindEvents() {
-    this.removeEventListener('click', this._onClick);
-    this.$scrollbarThumb.removeEventListener('mousedown', this._onMouseDown);
+    window.MouseEvent && this.removeEventListener('mousedown', this._onPointerDown);
+    window.TouchEvent && this.removeEventListener('touchstart', this._onPointerDown);
     this.unbindTargetEvents();
     window.removeEventListener('esl:refresh', this._onRefresh);
   }
@@ -192,14 +198,14 @@ export class ESLScrollbar extends ESLBaseElement {
     this.update();
   }
 
-  /** Normalize position value (between 0.0 and 1.0) */
+  /** Normalizes position value (between 0.0 and 1.0) */
   protected normalizePosition(position: number) {
     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);
   }
 
-  /** Scroll target element to passed position */
+  /** Scrolls target element to passed position */
   protected scrollTargetTo(pos: number) {
     if (!this.$target) return;
     this.$target.scrollTo({
@@ -208,7 +214,7 @@ export class ESLScrollbar extends ESLBaseElement {
     });
   }
 
-  /** Update thumb size and position */
+  /** Updates thumb size and position */
   public update() {
     this.$$fire('change:scroll', {bubbles: false});
     if (!this.$scrollbarThumb || !this.$scrollbarTrack) return;
@@ -221,7 +227,7 @@ export class ESLScrollbar extends ESLBaseElement {
     Object.assign(this.$scrollbarThumb.style, style);
   }
 
-  /** Update auxiliary markers */
+  /** Updates auxiliary markers */
   public updateMarkers() {
     const {position, thumbSize} =  this;
     this.toggleAttribute('at-start', thumbSize < 1 && position <= 0);
@@ -229,50 +235,80 @@ export class ESLScrollbar extends ESLBaseElement {
     this.toggleAttribute('inactive', thumbSize >= 1);
   }
 
-  /** Refresh scroll state and position */
+  /** Refreshes scroll state and position */
   public refresh() {
     this.update();
     this.updateMarkers();
   }
 
+  /** Returns position from MouseEvent coordinates (not normalized) */
+  public toPosition(event: MouseEvent | TouchEvent) {
+    const {horizontal, thumbOffset, trackOffset} = this;
+    const point = EventUtils.normalizeCoordinates(event, this.$scrollbarTrack);
+    const pointPosition = horizontal ? point.x : point.y;
+    const freeTrackArea = trackOffset - thumbOffset; // size of free track px
+    const clickPositionNoOffset = pointPosition - thumbOffset / 2;
+    return clickPositionNoOffset / freeTrackArea;
+  }
+
   // Event listeners
-  /** `mousedown` event to track thumb drag start */
+  /** Handles `mousedown` / `touchstart` event to manage thumb drag start and scroll clicks */
   @bind
-  protected _onMouseDown(event: MouseEvent) {
-    this.toggleAttribute('dragging', true);
-    this.$target?.style.setProperty('scroll-behavior', 'auto');
-
+  protected _onPointerDown(event: MouseEvent | TouchEvent) {
     this._initialPosition = this.position;
-    this._initialMousePosition = this.horizontal ? event.clientX : event.clientY;
+    this._pointerPosition = this.toPosition(event);
+    this._pointerShift = this._pointerPosition - this._initialPosition;
 
-    // Attach drag listeners
-    window.addEventListener('mousemove', this._onMouseMove);
-    window.addEventListener('mouseup', this._onMouseUp);
-    window.addEventListener('click', this._onBodyClick, {capture: true});
+    if (event.target === this.$scrollbarThumb) {
+      this._onThumbPointerDown(event); // Drag start handler
+    } else {
+      this._onPointerDownTick(true); // Continuous scroll and click handler
+    }
+
+    // Subscribe inverse handlers
+    EventUtils.isMouseEvent(event) && window.addEventListener('mouseup', this._onPointerUp);
+    EventUtils.isTouchEvent(event) && window.addEventListener('touchend', this._onPointerUp, {passive: false});
 
     // Prevents default text selection, etc.
     event.preventDefault();
   }
 
-  /** Set position on drug */
-  protected _dragToCoordinate(mousePosition: number) {
-    const positionChange = mousePosition - this._initialMousePosition;
-    const scrollableAreaHeight = this.trackOffset - this.thumbOffset;
-    const absChange = scrollableAreaHeight ? (positionChange / scrollableAreaHeight) : 0;
-    this.position = this._initialPosition + absChange;
+  /** Handles a scroll click / continuous scroll*/
+  @bind
+  protected _onPointerDownTick(first: boolean) {
+    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: MouseEvent | TouchEvent) {
+    this.toggleAttribute('dragging', true);
+    this.$target?.style.setProperty('scroll-behavior', 'auto');
+    // Attach drag listeners
+    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 drug */
+  protected _onPointerDrag(event: MouseEvent | TouchEvent) {
+    this.position = this.toPosition(event) - this._pointerShift;
     this.updateMarkers();
   }
-  protected _deferredDragToCoordinate = rafDecorator(this._dragToCoordinate);
 
   /** `mousemove` document handler for thumb drag event. Active only if drag action is active */
   @bind
-  protected _onMouseMove(event: MouseEvent) {
+  protected _onPointerMove(event: MouseEvent | TouchEvent) {
     if (!this.dragging) return;
-
     // Request position update
-    this._deferredDragToCoordinate(this.horizontal ? event.clientX : event.clientY);
-
+    this._deferredDrag(event);
     // Prevents default text selection, etc.
     event.preventDefault();
     event.stopPropagation();
@@ -280,13 +316,20 @@ export class ESLScrollbar extends ESLBaseElement {
 
   /** `mouseup` short-time document handler for drag end action */
   @bind
-  protected _onMouseUp() {
+  protected _onPointerUp(event: MouseEvent | TouchEvent) {
+    this._scrollTimer && window.clearTimeout(this._scrollTimer);
     this.toggleAttribute('dragging', false);
     this.$target?.style.removeProperty('scroll-behavior');
 
     // Unbind drag listeners
-    window.removeEventListener('mousemove', this._onMouseMove);
-    window.removeEventListener('mouseup', this._onMouseUp);
+    if (EventUtils.isMouseEvent(event)) {
+      window.removeEventListener('mousemove', this._onPointerMove);
+      window.removeEventListener('mouseup', this._onPointerMove);
+    }
+    if (EventUtils.isTouchEvent(event)) {
+      window.removeEventListener('touchmove', this._onPointerMove);
+      window.removeEventListener('touchend', this._onPointerMove);
+    }
   }
 
   /** Body `click` short-time handler to prevent clicks event on thumb drag. Handles capture phase */
@@ -297,21 +340,6 @@ export class ESLScrollbar extends ESLBaseElement {
     window.removeEventListener('click', this._onBodyClick, {capture: true});
   }
 
-  /** Handler for track clicks. Move scroll to selected position */
-  @bind
-  protected _onClick(event: MouseEvent) {
-    if (event.target !== this.$scrollbarTrack && event.target !== this) return;
-    const clickCoordinates = EventUtils.normalizeCoordinates(event, this.$scrollbarTrack);
-    const clickPosition = this.horizontal ? clickCoordinates.x : clickCoordinates.y;
-
-    const freeTrackArea = this.trackOffset - this.thumbOffset; // px
-    const clickPositionNoOffset = clickPosition - this.thumbOffset / 2;
-    const newPosition = clickPositionNoOffset / freeTrackArea;  // abs % to track
-
-    this.position = Math.min(this.position + this.thumbSize,
-      Math.max(this.position - this.thumbSize, newPosition));
-  }
-
   /**
    * Handler for refresh events to update the scroll.
    * @param event - instance of 'resize' or 'scroll' or 'esl:refresh' event.
@@ -321,7 +349,7 @@ export class ESLScrollbar extends ESLBaseElement {
     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();
+    this._deferredRefresh();
   }
 }
 
diff --git a/src/modules/esl-utils/dom/events.ts b/src/modules/esl-utils/dom/events.ts
index 4bfa02338..65d99e132 100644
--- a/src/modules/esl-utils/dom/events.ts
+++ b/src/modules/esl-utils/dom/events.ts
@@ -26,9 +26,19 @@ export abstract class EventUtils {
     return targets ? targets[0] : e.target;
   }
 
+  /** Check if the passed event is {@link MouseEvent} */
+  public static isMouseEvent(event: Event): event is MouseEvent {
+    return window.MouseEvent && event instanceof MouseEvent;
+  }
+
+  /** Check if the passed event is {@link TouchEvent} */
+  public static isTouchEvent(event: Event): event is TouchEvent {
+    return window.TouchEvent && event instanceof TouchEvent;
+  }
+
   /** Normalize TouchEvent or PointerEvent */
-  public static normalizeTouchPoint(event: TouchEvent | PointerEvent): Point {
-    const source = (event instanceof TouchEvent) ? event.changedTouches[0] : event;
+  public static normalizeTouchPoint(event: TouchEvent | PointerEvent | MouseEvent): Point {
+    const source = EventUtils.isTouchEvent(event) ? event.changedTouches[0] : event;
     return {
       x: source.pageX,
       y: source.pageY
@@ -36,13 +46,14 @@ export abstract class EventUtils {
   }
 
   /** Normalize MouseEvent */
-  public static normalizeCoordinates(event: MouseEvent, elem: HTMLElement): Point {
+  public static normalizeCoordinates(event: TouchEvent | PointerEvent | MouseEvent, elem: HTMLElement): Point {
+    const source = EventUtils.isTouchEvent(event) ? event.changedTouches[0] : event;
     const props = elem.getBoundingClientRect();
-    const top = props.top + window.pageYOffset;
-    const left = props.left + window.pageXOffset;
+    const top = props.top + window.scrollY;
+    const left = props.left + window.scrollX;
     return {
-      x: event.pageX - left,
-      y: event.pageY - top
+      x: source.pageX - left,
+      y: source.pageY - top
     };
   }
 


Test patch:
diff --git a/src/modules/esl-utils/dom/test/events.test.ts b/src/modules/esl-utils/dom/test/events.test.ts
index 28c2ec87b..b3f526251 100644
--- a/src/modules/esl-utils/dom/test/events.test.ts
+++ b/src/modules/esl-utils/dom/test/events.test.ts
@@ -97,13 +97,13 @@ describe('EventUtils', () => {
       jest.spyOn(elem, 'getBoundingClientRect').mockReturnValue(boundingClientRect);
 
       Object.assign(window, {
-        pageXOffset: 100,
-        pageYOffset: 200,
+        scrollX: 100,
+        scrollY: 200,
       });
 
       expect(EventUtils.normalizeCoordinates(event, elem)).toEqual({
-        x:  pageX - (boundingClientRect.left + window.pageXOffset),
-        y:  pageY - (boundingClientRect.top + window.pageYOffset),
+        x:  pageX - (boundingClientRect.left + window.scrollX),
+        y:  pageY - (boundingClientRect.top + window.scrollY),
       });
     });
   });