Repository: exadel-inc/esl
Pull request #1919 (2023-09-07 09:37:32)

feat(swipe-target): add angle to swipe event, simplify API
Closes: #1809

codeclimate[bot]: Code Climate has analyzed commit 5b33b5a6 and detected **0 issues** on this pull request.

The test coverage on the diff in this pull request is **100.0%** (**50%** is the threshold).

This pull request will bring the total coverage in the repository to **50.4%** (**0.6%** change).

View more on [Code Climate](https://codeclimate.com/github/exadel-inc/esl/pull/1919).


Patch:
diff --git a/pages/src/esl-swipe-demo/esl-swipe-demo-area.less b/pages/src/esl-swipe-demo/esl-swipe-demo-area.less
index c1d08dbe9..720b5c272 100644
--- a/pages/src/esl-swipe-demo/esl-swipe-demo-area.less
+++ b/pages/src/esl-swipe-demo/esl-swipe-demo-area.less
@@ -1,6 +1,7 @@
 @import (reference) '../common/variables.less';
 
 esl-d-swipe-area {
+  touch-action: none;
   display: flex;
   flex-direction: column;
   width: 100%;
diff --git a/pages/src/esl-swipe-demo/esl-swipe-demo-area.ts b/pages/src/esl-swipe-demo/esl-swipe-demo-area.ts
index eb189525e..f512bbde6 100644
--- a/pages/src/esl-swipe-demo/esl-swipe-demo-area.ts
+++ b/pages/src/esl-swipe-demo/esl-swipe-demo-area.ts
@@ -8,14 +8,19 @@ export class ESLDemoSwipeArea extends ESLBaseElement {
   static override is = 'esl-d-swipe-area';
 
   @listen({
-    event: 'swipe swipe:left swipe:right swipe:up swipe:down',
+    event: 'swipe',
     target: ESLSwipeGestureTarget.for
   })
   onSwipe(e: ESLSwipeGestureEvent): void {
     const $logItem = document.createElement('div');
     $logItem.className = 'log-item';
-    const {type, direction, distanceX, distanceY} = e;
-    $logItem.innerHTML = `<code>ESLSwipeGestureEvent</code> ( ${type} ): <i>${direction} ( x: ${distanceX} , y: ${distanceY})</i>`;
+    const {type, direction, distanceX, distanceY, distance, angle} = e;
+    $logItem.innerHTML = `<code>ESLSwipeGestureEvent</code> ( ${type} ): <i>${direction} (
+      x: ${distanceX},
+      y: ${distanceY},
+      distance: ${distance},
+      angle: ${angle}
+    )</i>`;
     this.appendChild($logItem);
     this.scrollTo({top: this.scrollHeight, behavior: 'smooth'});
     setTimeout(() => $logItem.classList.add('removed'), 2500);
diff --git a/pages/views/draft/swipe-demo.njk b/pages/views/draft/swipe-demo.njk
index 4620aead4..45857619d 100644
--- a/pages/views/draft/swipe-demo.njk
+++ b/pages/views/draft/swipe-demo.njk
@@ -12,7 +12,7 @@ tags: [draft]
     </p>
     {% code 'ts', 'code-block' %}
       @listen({
-        event: 'swipe swipe:left swipe:right swipe:up swipe:down',
+        event: 'swipe',
         target: ESLSwipeGestureTarget.for
       })
       onSwipe(e: ESLSwipeGestureEvent): void {
diff --git a/src/modules/esl-event-listener/README.md b/src/modules/esl-event-listener/README.md
index 1f8810ca7..b042930f1 100644
--- a/src/modules/esl-event-listener/README.md
+++ b/src/modules/esl-event-listener/README.md
@@ -580,8 +580,7 @@ ESLEventUtils.subscribe(host, {
 
 `ESLSwipeGestureTarget.for` creates a synthetic target that produces `swipe` events. It detects `pointerdown` and 
 `pointerup` events and based on the distance (`threshold`) between start and end points and time (`timeout`) between 
-`pointerdown` and `pointerup` events, triggers `swipe`, `swipe:left`, `swipe:right`, `swipe:up`, and `swipe:down` 
-events on target element.
+`pointerdown` and `pointerup` events, triggers `swipe` event on target element.
 
 ```typescript
 ESLSwipeGestureTarget.for(el: Element, settings?: ESLSwipeGestureSetting): ESLSwipeGestureTarget;
@@ -592,6 +591,9 @@ ESLSwipeGestureTarget.for(el: Element, settings?: ESLSwipeGestureSetting): ESLSw
 - `el` - `Element` The element to listen for swipe events on.
 - `settings` - optional settings (`ESLSwipeGestureSetting`)
 
+**Note**: `ESLSwipeGestureTarget` uses Pointer Events API and requires corresponding `touch-action` CSS 
+property to be specified on the target element.
+
 Usage example:
 
 ```typescript
@@ -601,7 +603,7 @@ ESLEventUtils.subscribe(host, {
 }, onSwipe);
 // or
 ESLSwipeGestureTarget.subscribe(host, {
-  event: 'swipe:left',
+  event: 'swipe',
   target: (host) => ESLSwipeGestureTarget.for(host.el, {
     threshold: '30px',
     timeout: 1000
diff --git a/src/modules/esl-event-listener/core/targets/swipe.target.event.ts b/src/modules/esl-event-listener/core/targets/swipe.target.event.ts
index 39f539a0c..eb5284a86 100644
--- a/src/modules/esl-event-listener/core/targets/swipe.target.event.ts
+++ b/src/modules/esl-event-listener/core/targets/swipe.target.event.ts
@@ -8,7 +8,7 @@ export type SwipeDirection = 'left' | 'right' | 'up' | 'down';
 /**
  * Event names that could be triggered by {@link ESLSwipeGestureTarget}
  */
-export type SwipeEventName = 'swipe' | 'swipe:left' | 'swipe:right' | 'swipe:up' | 'swipe:down';
+export type SwipeEventName = 'swipe';
 
 /**
  * Describes swipe information provided with {@link ESLSwipeGestureEvent}
@@ -16,14 +16,20 @@ export type SwipeEventName = 'swipe' | 'swipe:left' | 'swipe:right' | 'swipe:up'
 export interface ESLSwipeGestureEventInfo {
   /** Swipe direction {@link SwipeDirection} */
   direction: SwipeDirection;
-  /** Distance between the points where pointerdown and pointerup events occurred along the x axis */
+  /** Distance between the points where pointerdown and pointerup events occurred along the x-axis */
   distanceX: number;
-  /** Distance between the points where pointerdown and pointerup events occurred along the y axis */
+  /** Distance between the points where pointerdown and pointerup events occurred along the y-axis */
   distanceY: number;
+  /** Direct distance between the points where pointerdown and pointerup events occurred */
+  distance: number;
+  /** Clockwise angle between positive y-axis and swipe */
+  angle: number;
   /** Original pointerdown event */
   startEvent: PointerEvent;
   /** Original pointerup event */
   endEvent: PointerEvent;
+  /** Time between pointerdown and pointerup events */
+  duration: number;
 }
 
 /**
@@ -35,8 +41,11 @@ export class ESLSwipeGestureEvent extends UIEvent implements ESLSwipeGestureEven
   public readonly direction: SwipeDirection;
   public readonly distanceX: number;
   public readonly distanceY: number;
+  public readonly distance: number;
+  public readonly angle: number;
   public readonly endEvent: PointerEvent;
   public readonly startEvent: PointerEvent;
+  public readonly duration: number;
 
   protected constructor(eventName: SwipeEventName, target: Element, swipeInfo: ESLSwipeGestureEventInfo) {
     super(eventName, {bubbles: true, cancelable: true});
diff --git a/src/modules/esl-event-listener/core/targets/swipe.target.ts b/src/modules/esl-event-listener/core/targets/swipe.target.ts
index cb8b68e81..f3ceac666 100644
--- a/src/modules/esl-event-listener/core/targets/swipe.target.ts
+++ b/src/modules/esl-event-listener/core/targets/swipe.target.ts
@@ -8,7 +8,7 @@ import {ESLEventListener} from '../listener';
 import {ESLSwipeGestureEvent} from './swipe.target.event';
 
 import type {CSSSize} from '../../../esl-utils/dom/units';
-import type {SwipeDirection, ESLSwipeGestureEventInfo, SwipeEventName} from './swipe.target.event';
+import type {SwipeDirection, SwipeEventName, ESLSwipeGestureEventInfo} from './swipe.target.event';
 import type {ESLDomElementTarget} from '../../../esl-utils/abstract/dom-target';
 
 export {ESLSwipeGestureEvent};
@@ -21,15 +21,6 @@ export interface ESLSwipeGestureSetting {
   timeout?: number;
 }
 
-/**
- * Diff between events coordinates and timestamp
- */
-interface EventsDiff {
-  x: number;
-  y: number;
-  time: number;
-}
-
 /**
  * Synthetic target class that produces swipe events
  */
@@ -56,7 +47,6 @@ export class ESLSwipeGestureTarget extends SyntheticEventTarget {
   protected readonly config: Required<ESLSwipeGestureSetting>;
 
   protected startEvent: PointerEvent;
-  protected isGestureStarted: boolean = false;
 
   protected constructor(
     protected readonly target: Element,
@@ -73,19 +63,35 @@ export class ESLSwipeGestureTarget extends SyntheticEventTarget {
   @bind
   protected handleStart(e: PointerEvent): void {
     this.startEvent = e;
-    this.isGestureStarted = true;
+    this.target.setPointerCapture && this.target.setPointerCapture(e.pointerId);
   }
 
   /**
-   * @param e - pointer event (pointerdown)
-   * @returns diff between pointerdown and pointer coordinates and timestamp {@link EventsDiff}
+   * @param endEvent - pointer event (pointerdown)
+   * @returns diff between pointerdown and pointerup coordinates and timestamp {@link ESLSwipeGestureEventInfo}
    */
-  protected eventDiff(e: PointerEvent): EventsDiff {
-    return {
-      x: this.startEvent.clientX - e.clientX,
-      y: this.startEvent.clientY - e.clientY,
-      time: e.timeStamp - this.startEvent.timeStamp
-    };
+  protected resolveEventDetails(endEvent: PointerEvent): ESLSwipeGestureEventInfo {
+    const {startEvent} = this;
+    const duration = endEvent.timeStamp - startEvent.timeStamp;
+    const distanceX = Math.round(endEvent.clientX - startEvent.clientX);
+    const distanceY = Math.round(endEvent.clientY - startEvent.clientY);
+    const distance = Math.round(Math.hypot(distanceX, distanceY, 2));
+    const angle = Math.round((Math.atan2(distanceY, distanceX) * 180 / Math.PI + 90 + 360) % 360);
+    const direction = this.resolveDirection(distanceX, distanceY);
+
+    return {direction, distanceX, distanceY, distance, angle, duration, startEvent, endEvent};
+  }
+
+  /**
+   * Returns swipe direction based on distance between swipe start and end points
+   * @returns direction of swipe {@link SwipeDirection}
+   * @param distanceX - distance between swipe start and end points on X axis
+   * @param distanceY - distance between swipe start and end points on Y axis
+   */
+  protected resolveDirection(distanceX: number, distanceY: number): SwipeDirection {
+    if (Math.abs(distanceX) >= Math.abs(distanceY)) return distanceX < 0 ? 'left' : 'right';
+
+    return distanceY < 0 ? 'up' : 'down';
   }
 
   /**
@@ -95,47 +101,25 @@ export class ESLSwipeGestureTarget extends SyntheticEventTarget {
    */
   @bind
   protected handleEnd(e: PointerEvent): void {
-    // if the user released on a different target, cancel!
-    if (!this.isGestureStarted || (this.startEvent?.target !== e.target)) return;
-
-    const eventsDiff = this.eventDiff(e);
-    const direction = this.resolveDirection(eventsDiff);
-    if (direction) {
-      const swipeInfo: ESLSwipeGestureEventInfo = {
-        direction,
-        distanceX: Math.abs(eventsDiff.x),
-        distanceY: Math.abs(eventsDiff.y),
-        startEvent: this.startEvent,
-        endEvent: e
-      };
-
-      // fire `swipe` event on the element that started the swipe
-      this.dispatchEvent(ESLSwipeGestureEvent.fromConfig('swipe', this.target, swipeInfo));
-      // fire `swipe:${dir}` event on the element that started the swipe
-      this.dispatchEvent(ESLSwipeGestureEvent.fromConfig(`swipe:${direction}`, this.target, swipeInfo));
-    }
-
-    // Mark swipe as completed
-    this.isGestureStarted = false;
-  }
+    this.target.releasePointerCapture && this.target.releasePointerCapture(e.pointerId);
 
-  /**
-   * Returns swipe direction based on distance between swipe start and end points
-   * @param diff - diff between pointerdown and pointer coordinates and timestamp {@link EventsDiff}
-   * @returns direction of swipe {@link SwipeDirection}
-   */
-  protected resolveDirection(diff: EventsDiff): SwipeDirection | null {
+    const eventDetails = this.resolveEventDetails(e);
     const swipeThreshold = (resolveCSSSize(this.config.threshold) || resolveCSSSize(ESLSwipeGestureTarget.defaultConfig.threshold)!);
 
-    if (Math.abs(diff.x) > Math.abs(diff.y) && Math.abs(diff.x) > swipeThreshold && diff.time < this.config.timeout) {
-      return diff.x > 0 ? 'left' : 'right';
-    }
-    if (Math.abs(diff.y) > swipeThreshold && diff.time < this.config.timeout) {
-      return diff.y > 0 ? 'up' : 'down';
-    }
+    // return if swipe took too long or distance is too short
+    if (!this.isGestureAcceptable(eventDetails, swipeThreshold)) return;
 
-    // Marker that there was not enough move characteristic to consider pointer move as touch swipe
-    return null;
+    // fire `swipe` event on the element that started the swipe
+    this.dispatchEvent(ESLSwipeGestureEvent.fromConfig('swipe', this.target, eventDetails));
+  }
+
+  /**
+   * Checks if swipe gesture is acceptable based on distance and timeout
+   * @param diff - diff between pointerdown and pointerup coordinates and timestamp {@link ESLSwipeGestureEventInfo}
+   * @param swipeThreshold - threshold for swipe distance
+   */
+  protected isGestureAcceptable(diff: ESLSwipeGestureEventInfo, swipeThreshold: number): boolean {
+    return (diff.duration < this.config.timeout && (Math.abs(diff.distanceX) >= swipeThreshold) || (Math.abs(diff.distanceY) >= swipeThreshold));
   }
 
   /**


Test patch:
diff --git a/src/modules/esl-event-listener/test/targets/swipe.test.ts b/src/modules/esl-event-listener/test/targets/swipe.test.ts
index 30faa17b1..0b76d9310 100644
--- a/src/modules/esl-event-listener/test/targets/swipe.test.ts
+++ b/src/modules/esl-event-listener/test/targets/swipe.test.ts
@@ -69,30 +69,34 @@ describe('ESLSwipeGestureTarget EventTarget', () => {
 
     test('ESLSwipeGestureTarget detects left swipe', async () => {
       $el.dispatchEvent(Object.assign(new Event('pointerdown'), {clientX: 100, clientY: 100}));
-      $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 50, clientY: 105}));
+      $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 50, clientY: 100}));
       expect(listener).lastCalledWith(expect.any(ESLSwipeGestureEvent));
       expect(listener).lastCalledWith(expect.objectContaining({direction: 'left'}));
+      expect(listener).lastCalledWith(expect.objectContaining({angle: 270}));
     });
 
     test('ESLSwipeGestureTarget detects right swipe', async () => {
       $el.dispatchEvent(Object.assign(new Event('pointerdown'), {clientX: 100, clientY: 100}));
-      $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 150, clientY: 105}));
+      $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 150, clientY: 100}));
       expect(listener).lastCalledWith(expect.any(ESLSwipeGestureEvent));
       expect(listener).lastCalledWith(expect.objectContaining({direction: 'right'}));
+      expect(listener).lastCalledWith(expect.objectContaining({angle: 90}));
     });
 
     test('ESLSwipeGestureTarget detects up swipe', async () => {
       $el.dispatchEvent(Object.assign(new Event('pointerdown'), {clientX: 100, clientY: 100}));
-      $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 105, clientY: 50}));
+      $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 100, clientY: 50}));
       expect(listener).lastCalledWith(expect.any(ESLSwipeGestureEvent));
       expect(listener).lastCalledWith(expect.objectContaining({direction: 'up'}));
+      expect(listener).lastCalledWith(expect.objectContaining({angle: 0}));
     });
 
     test('ESLSwipeGestureTarget detects down swipe', async () => {
       $el.dispatchEvent(Object.assign(new Event('pointerdown'), {clientX: 100, clientY: 100}));
-      $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 105, clientY: 150}));
+      $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 100, clientY: 150}));
       expect(listener).lastCalledWith(expect.any(ESLSwipeGestureEvent));
       expect(listener).lastCalledWith(expect.objectContaining({direction: 'down'}));
+      expect(listener).lastCalledWith(expect.objectContaining({angle: 180}));
     });
 
     test('ESLSwipeGestureTarget ignores short horizontal swipes', async () => {
@@ -122,14 +126,12 @@ describe('ESLSwipeGestureTarget EventTarget', () => {
     test('ESLSwipeGestureTarget processes diagonal swipes', async () => {
       $el.dispatchEvent(Object.assign(new Event('pointerdown'), {clientX: 100, clientY: 100}));
       $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 150, clientY: 150}));
-      expect(listener).toHaveBeenCalledWith(expect.objectContaining({direction: 'down'}));
       expect(listener).toHaveBeenCalledWith(expect.objectContaining({direction: 'right'}));
     });
 
     test('ESLSwipeGestureTarget processes diagonal swipes', async () => {
       $el.dispatchEvent(Object.assign(new Event('pointerdown'), {clientX: 100, clientY: 100}));
       $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 50, clientY: 50}));
-      expect(listener).toHaveBeenCalledWith(expect.objectContaining({direction: 'up'}));
       expect(listener).toHaveBeenCalledWith(expect.objectContaining({direction: 'left'}));
     });
   });