Repository: exadel-inc/esl
Pull request #1841 (2023-07-31 16:29:18)

feat(swipe-target): Add SwipeEventTarget
Add SwipeEventTarget to subscribe `swipe`, `swipe:left`, `swipe:right`, `swipe:up`, `swipe:down` events using `ESLEventUtils.subscribe` or `@listen` descriptor

Closes: #1809 


github-actions[bot]: **CLA Assistant Lite bot:** Thank you for your submission, we really appreciate it. We ask that you sign our  <a href="https://github.com/exadel-inc/esl/blob/main/CLA.md">Contributor Licence Agreement</a> before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.
   - - -
   I have read the CLA Document and I hereby sign the CLA
   - - -
   <sub>You can retrigger this bot by commenting **recheck** in this Pull Request</sub>
grechihinrhp: I have read the CLA Document and I hereby sign the CLA
ala-n: Thanks a lot for your contribution!
TS Doc, README update, and the test page currently are missing for release state.
Tech review is in progress
If it will be easier for you and review to make these updates separately from the main part of the code, fill free to let me know to create an epic branch.
codeclimate[bot]: Analysis results are not available for those commits

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

This pull request will bring the total coverage in the repository to **48.9%** (**-0.3%** change).

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

codeclimate[bot]: Analysis results are not available for those commits

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

This pull request will bring the total coverage in the repository to **48.8%** (**-0.4%** change).

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

codeclimate[bot]: Analysis results are not available for those commits

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

This pull request will bring the total coverage in the repository to **48.8%** (**-0.4%** change).

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

codeclimate[bot]: Analysis results are not available for those commits

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

This pull request will bring the total coverage in the repository to **48.8%** (**-0.4%** change).

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

codeclimate[bot]: Analysis results are not available for those commits

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

This pull request will bring the total coverage in the repository to **49.1%** (**-0.1%** change).

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


Patch:
diff --git a/src/modules/esl-event-listener/README.md b/src/modules/esl-event-listener/README.md
index 8169475c7..1f8810ca7 100644
--- a/src/modules/esl-event-listener/README.md
+++ b/src/modules/esl-event-listener/README.md
@@ -572,6 +572,43 @@ ESLEventUtils.subscribe(host, {
 }, onResize);
 ```
 
+<a name="-esleventutilswipe"></a>
+
+### ⚡ `ESLSwipeGestureTarget.for`
+
+`ESLSwipeGestureTarget.for` is a simple and easy-to-use way to listen for swipe events on any element.
+
+`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.
+
+```typescript
+ESLSwipeGestureTarget.for(el: Element, settings?: ESLSwipeGestureSetting): ESLSwipeGestureTarget;
+```
+
+**Parameters**:
+
+- `el` - `Element` The element to listen for swipe events on.
+- `settings` - optional settings (`ESLSwipeGestureSetting`)
+
+Usage example:
+
+```typescript
+ESLEventUtils.subscribe(host, {
+  event: 'swipe',
+  target: ESLSwipeGestureTarget.for(el)
+}, onSwipe);
+// or
+ESLSwipeGestureTarget.subscribe(host, {
+  event: 'swipe:left',
+  target: (host) => ESLSwipeGestureTarget.for(host.el, {
+    threshold: '30px',
+    timeout: 1000
+  })
+}, onSwipe);
+```
+
 ---
 
 ## <a name="embedded-behavior-of-eslbaseelement-eslmixinelement">Embedded behavior of `ESLBaseElement` / `ESLMixinElement`</a>
diff --git a/src/modules/esl-event-listener/core/targets/swipe.event.ts b/src/modules/esl-event-listener/core/targets/swipe.event.ts
new file mode 100644
index 000000000..c475ebd7c
--- /dev/null
+++ b/src/modules/esl-event-listener/core/targets/swipe.event.ts
@@ -0,0 +1,47 @@
+import {overrideEvent} from '../../../esl-utils/dom/events/misc';
+
+/**
+ * Swipe directions that could be provided in {@link ESLSwipeGestureEvent}
+ */
+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';
+
+/**
+ * Describes swipe information provided with {@link ESLSwipeGestureEvent}
+ */
+export interface ESLSwipeGestureEventInfo {
+  target: Element;
+  /** Swipe direction {@link SwipeDirection} */
+  direction: SwipeDirection;
+  /** 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 */
+  distanceY: number;
+  /** Original pointerdown event */
+  startEvent: PointerEvent;
+  /** Original pointerup event */
+  endEvent: PointerEvent;
+}
+
+/**
+ * Creates swipe event dispatched by {@link ESLSwipeGestureTarget}
+ */
+export class ESLSwipeGestureEvent extends UIEvent {
+  public override readonly target: Element;
+  public swipeInfo: ESLSwipeGestureEventInfo;
+
+  protected constructor(eventName: SwipeEventName, swipeInfo: ESLSwipeGestureEventInfo) {
+    super(eventName, {bubbles: true, cancelable: true});
+    this.swipeInfo = swipeInfo;
+    overrideEvent(this, 'target', swipeInfo.target);
+  }
+
+  /** Creates {@link ESLSwipeGestureEvent} from {@link ESLSwipeGestureTarget} */
+  public static fromConfig(eventName: SwipeEventName, swipeInfo: ESLSwipeGestureEventInfo): ESLSwipeGestureEvent {
+    return new ESLSwipeGestureEvent(eventName, swipeInfo);
+  }
+}
diff --git a/src/modules/esl-event-listener/core/targets/swipe.target.ts b/src/modules/esl-event-listener/core/targets/swipe.target.ts
new file mode 100644
index 000000000..79d87e001
--- /dev/null
+++ b/src/modules/esl-event-listener/core/targets/swipe.target.ts
@@ -0,0 +1,167 @@
+import {SyntheticEventTarget} from '../../../esl-utils/dom/events/target';
+import {ESLMixinElement} from '../../../esl-mixin-element/ui/esl-mixin-element';
+import {resolveDomTarget} from '../../../esl-utils/abstract/dom-target';
+import {bind} from '../../../esl-utils/decorators/bind';
+import {ESLEventUtils} from '../api';
+import {resolveCSSSize} from '../../../esl-utils/dom/units';
+import {ESLSwipeGestureEvent} from './swipe.event';
+
+import type {CSSSize} from '../../../esl-utils/dom/units';
+import type {SwipeDirection, ESLSwipeGestureEventInfo, SwipeEventName} from './swipe.event';
+import type {ESLDomElementTarget} from '../../../esl-utils/abstract/dom-target';
+
+/**
+ * Describes parsed configuration of {@link ESLSwipeGestureTarget}
+ */
+interface SwipeEventTargetConfig {
+  threshold: CSSSize;
+  timeout: number;
+}
+
+/**
+ * Describes settings object that could be passed to {@link ESLSwipeGestureTarget.for} as optional parameter
+ */
+export interface ESLSwipeGestureSetting {
+  threshold?: CSSSize;
+  timeout?: number;
+}
+
+/**
+ * Diff between events coordinates and timestamp
+ */
+interface EventsDiff {
+  x: number;
+  y: number;
+  time: number;
+}
+
+/**
+ * Synthetic target class that produces swipe events
+ */
+export class ESLSwipeGestureTarget extends SyntheticEventTarget {
+  protected static defaultConfig: SwipeEventTargetConfig = {
+    threshold: '20px',
+    timeout: 500
+  };
+
+  protected startEvent: PointerEvent;
+  protected config: SwipeEventTargetConfig;
+  protected target: Element;
+  protected isGestureStarted: boolean = false;
+
+  protected constructor(target: ESLDomElementTarget, settings: ESLSwipeGestureSetting) {
+    super();
+    this.target = resolveDomTarget(target);
+    this.config = Object.assign({}, ESLSwipeGestureTarget.defaultConfig, settings);
+  }
+
+  /**
+   * @param $target - a target element to observe pointer events to detect a gesture
+   * @param settings - optional config override (will be merged with a default one if passed) {@link ESLSwipeGestureSetting}.
+   * @returns Returns the instance of ESLSwipeGestureTarget {@link ESLSwipeGestureTarget}.
+   */
+  public static for($target: ESLDomElementTarget, settings?: ESLSwipeGestureSetting): ESLSwipeGestureTarget {
+    if ($target instanceof ESLMixinElement) return ESLSwipeGestureTarget.for($target.$host, settings);
+
+    return new ESLSwipeGestureTarget($target, settings || {});
+  }
+
+  /**
+   * Saves swipe start event target, time when swipe started, pointerdown event and coordinates.
+   * @param e - pointer event
+   */
+  @bind
+  protected handleStart(e: PointerEvent): void {
+    this.startEvent = e;
+    this.isGestureStarted = true;
+  }
+
+  /**
+   * @param e - pointer event (pointerdown)
+   * @returns diff between pointerdown and pointer coordinates and timestamp {@link EventsDiff}
+   */
+  protected eventDiff(e: PointerEvent): EventsDiff {
+    return {
+      x: this.startEvent.clientX - e.clientX,
+      y: this.startEvent.clientY - e.clientY,
+      time: e.timeStamp - this.startEvent.timeStamp
+    };
+  }
+
+  /**
+   * Triggers swipe event {@link SwipeEventName} with details {@link ESLSwipeGestureEvent} when pointerup event
+   * occurred and threshold and distance match configuration
+   * @param e - pointer event
+   */
+  @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 = {
+        target: this.target,
+        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', swipeInfo));
+      // fire `swipe:${dir}` event on the element that started the swipe
+      this.dispatchEvent(ESLSwipeGestureEvent.fromConfig(`swipe:${direction}` as SwipeEventName, swipeInfo));
+    }
+
+    // Mark swipe as completed
+    this.isGestureStarted = false;
+  }
+
+  /**
+   * 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 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';
+    }
+
+    // Marker that there was not enough move characteristic to consider pointer move as touch swipe
+    return null;
+  }
+
+  /**
+   * Subscribes to pointerup and pointerdown event
+   */
+  public override addEventListener(callback: EventListener): void;
+  public override addEventListener(event: SwipeEventName, callback: EventListener): void;
+  public override addEventListener(event: any, callback: EventListener = event): void {
+    super.addEventListener(event, callback);
+    if (this.getEventListeners().length > 1) return;
+
+    const {target} = this;
+    ESLEventUtils.subscribe(this, {event: 'pointerdown', capture: false, target}, this.handleStart);
+    ESLEventUtils.subscribe(this, {event: 'pointerup', capture: false, target}, this.handleEnd);
+  }
+
+  /**
+   * Unsubscribes from the observed target {@link Element} changes
+   */
+  public override removeEventListener(callback: EventListener): void;
+  public override removeEventListener(event: SwipeEventName, callback: EventListener): void;
+  public override removeEventListener(event: any, callback: EventListener = event): void {
+    super.removeEventListener(event, callback);
+    if (this.getEventListeners().length > 0) return;
+
+    ESLEventUtils.unsubscribe(this);
+  }
+}
diff --git a/src/modules/esl-utils/dom/units.ts b/src/modules/esl-utils/dom/units.ts
new file mode 100644
index 000000000..82a5c2ef4
--- /dev/null
+++ b/src/modules/esl-utils/dom/units.ts
@@ -0,0 +1,30 @@
+export type CSSSize = `${number}${'' | 'px' | 'vh' | 'vw'}`;
+
+/**
+ * @param value - CSS value string in px, vh, vw or without units. {@link CSSSize}
+ * @returns number in pixels.
+ */
+export const resolveCSSSize = (value: CSSSize): number | null => {
+  let units = 'px';
+
+  ['px', 'vh', 'vw'].forEach((item) => {
+    if (value.indexOf(item) > 0) {
+      units = item;
+    }
+  });
+
+  const num = parseFloat(value.replace(units, ''));
+
+  if (!num) {
+    return null;
+  }
+
+  if (units === 'vh') {
+    return Math.round((num / 100) * document.documentElement.clientHeight); // get percentage of viewport height in pixels
+  }
+  if (units === 'vw') {
+    return Math.round((num / 100) * document.documentElement.clientWidth); // get percentage of viewport width in pixels
+  }
+
+  return num;
+};


Test patch:
diff --git a/src/modules/esl-utils/dom/test/units.test.ts b/src/modules/esl-utils/dom/test/units.test.ts
new file mode 100644
index 000000000..af1fbc567
--- /dev/null
+++ b/src/modules/esl-utils/dom/test/units.test.ts
@@ -0,0 +1,37 @@
+import {resolveCSSSize} from '../units';
+import type {CSSSize} from '../units';
+
+describe('resolveCSSSize tests', () => {
+  test('resolveCSSSize called with value \'25px\' should return 25', () => {
+    const result = resolveCSSSize('25px');
+    expect(result).toStrictEqual(25);
+  });
+
+  test('resolveCSSSize called with value \'25vw\' should return 25', () => {
+    jest.spyOn(document.documentElement, 'clientWidth', 'get').mockImplementation(() => 100);
+    const result = resolveCSSSize('25vw');
+    expect(result).toStrictEqual(25);
+  });
+
+  test('resolveCSSSize called with value \'25vh\' should return 25', () => {
+    jest.spyOn(document.documentElement, 'clientHeight', 'get').mockImplementation(() => 100);
+    const result = resolveCSSSize('25vh');
+    expect(result).toStrictEqual(25);
+  });
+
+  test('resolveCSSSize called with value \'25\' should return 25 as a number', () => {
+    const result = resolveCSSSize('25');
+    expect(result).toStrictEqual(25);
+  });
+
+  test('resolveCSSSize called with value \'undefined\' should return null', () => {
+    const result = resolveCSSSize(('undefined' as CSSSize));
+    expect(result).toStrictEqual(null);
+  });
+
+  test('resolveCSSSize called with value \'.5vw\' should return 1 (rounded value)', () => {
+    jest.spyOn(document.documentElement, 'clientWidth', 'get').mockImplementation(() => 100);
+    const result = resolveCSSSize('.5vw');
+    expect(result).toStrictEqual(1);
+  });
+});