Repository: exadel-inc/esl
Pull request #1912 (2023-09-01 08:36:12)

refactor(esl-event-listener): swipe listener examples/tests and cleanup


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
new file mode 100644
index 000000000..c1d08dbe9
--- /dev/null
+++ b/pages/src/esl-swipe-demo/esl-swipe-demo-area.less
@@ -0,0 +1,44 @@
+@import (reference) '../common/variables.less';
+
+esl-d-swipe-area {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+  min-width: 200px;
+  min-height: 300px;
+  position: relative;
+  overflow: auto;
+
+  padding: 1rem;
+
+  cursor: grab;
+  color: @body-fg;
+  background-color: @body-bg;
+  border: @body-fg 1px solid;
+  border-radius: 4px;
+  box-shadow: 0 2px 4px 0 fadein(@body-fg, 0.3);
+
+  user-select: none;
+
+  > .log-item {
+    transition: opacity 0.5s linear;
+    margin-bottom: 5px;
+  }
+  > .log-item.removed {
+    opacity: 0;
+  }
+
+  // Placeholder
+  &[placeholder]::before {
+    content: attr(placeholder);
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    color: @body-fg;
+    font-size: 2rem;
+    opacity: 0.4;
+    pointer-events: none;
+  }
+}
diff --git a/pages/src/esl-swipe-demo/esl-swipe-demo-area.ts b/pages/src/esl-swipe-demo/esl-swipe-demo-area.ts
new file mode 100644
index 000000000..eb189525e
--- /dev/null
+++ b/pages/src/esl-swipe-demo/esl-swipe-demo-area.ts
@@ -0,0 +1,24 @@
+import {ESLBaseElement} from '../../../src/modules/esl-base-element/core';
+import {ESLSwipeGestureTarget} from '../../../src/modules/esl-event-listener/core';
+import {listen} from '../../../src/modules/esl-utils/decorators';
+
+import type {ESLSwipeGestureEvent} from '../../../src/modules/esl-event-listener/core';
+
+export class ESLDemoSwipeArea extends ESLBaseElement {
+  static override is = 'esl-d-swipe-area';
+
+  @listen({
+    event: 'swipe swipe:left swipe:right swipe:up swipe:down',
+    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>`;
+    this.appendChild($logItem);
+    this.scrollTo({top: this.scrollHeight, behavior: 'smooth'});
+    setTimeout(() => $logItem.classList.add('removed'), 2500);
+    setTimeout(() => $logItem.remove(), 3000);
+  }
+}
diff --git a/pages/src/localdev.less b/pages/src/localdev.less
index 6e5b91c05..a89950731 100644
--- a/pages/src/localdev.less
+++ b/pages/src/localdev.less
@@ -33,6 +33,7 @@
 
 @import './collection-grid/collection-grid.less';
 @import './esl-media-demo/test-media.less';
+@import './esl-swipe-demo/esl-swipe-demo-area.less';
 @import './esl-share/esl-share.less';
 
 @import '../../src/modules/all.less';
diff --git a/pages/src/localdev.ts b/pages/src/localdev.ts
index 512a5464c..33d9bfb78 100644
--- a/pages/src/localdev.ts
+++ b/pages/src/localdev.ts
@@ -60,6 +60,7 @@ import {ESLDemoSearchPageWrapper} from './search/search';
 import {ESLDemoSidebar} from './navigation/navigation';
 import {ESLDemoAnchorLink} from './anchor/anchor-link';
 import {ESLDemoBanner} from './banner/banner';
+import {ESLDemoSwipeArea} from './esl-swipe-demo/esl-swipe-demo-area';
 
 ESLVSizeCSSProxy.observe();
 
@@ -71,6 +72,7 @@ ESLDemoSearchPageWrapper.register();
 ESLDemoAnchorLink.register();
 ESLDemoBackLink.register();
 ESLDemoBanner.register();
+ESLDemoSwipeArea.register();
 
 // Register ESL Components
 ESLImage.register();
diff --git a/pages/views/draft/swipe-demo.njk b/pages/views/draft/swipe-demo.njk
new file mode 100644
index 000000000..4620aead4
--- /dev/null
+++ b/pages/views/draft/swipe-demo.njk
@@ -0,0 +1,31 @@
+---
+layout: content
+title: Swipe Event Target (Regression)
+name: Swipe Event Target (Regression)
+tags: [draft]
+---
+
+<section class="row">
+  <div class="col-12">
+    <p>
+      <code>ESLSwipeGestureTarget</code> usage example
+    </p>
+    {% code 'ts', 'code-block' %}
+      @listen({
+        event: 'swipe swipe:left swipe:right swipe:up swipe:down',
+        target: ESLSwipeGestureTarget.for
+      })
+      onSwipe(e: ESLSwipeGestureEvent): void {
+        const $logItem = document.createElement('div');
+        $logItem.className = 'log-item';
+        $logItem.textContent = JSON.stringify(e);
+        this.appendChild($logItem);
+      }
+    {% endcode %}
+
+    <div class="d-flex">
+      <esl-d-swipe-area class="m-auto" placeholder="Make swipes in that area"></esl-d-swipe-area>
+    </div>
+
+  </div>
+</section>
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 c475ebd7c..39f539a0c 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
@@ -14,7 +14,6 @@ export type SwipeEventName = 'swipe' | 'swipe:left' | 'swipe:right' | 'swipe:up'
  * 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 */
@@ -30,18 +29,23 @@ export interface ESLSwipeGestureEventInfo {
 /**
  * Creates swipe event dispatched by {@link ESLSwipeGestureTarget}
  */
-export class ESLSwipeGestureEvent extends UIEvent {
+export class ESLSwipeGestureEvent extends UIEvent implements ESLSwipeGestureEventInfo {
   public override readonly target: Element;
-  public swipeInfo: ESLSwipeGestureEventInfo;
 
-  protected constructor(eventName: SwipeEventName, swipeInfo: ESLSwipeGestureEventInfo) {
+  public readonly direction: SwipeDirection;
+  public readonly distanceX: number;
+  public readonly distanceY: number;
+  public readonly endEvent: PointerEvent;
+  public readonly startEvent: PointerEvent;
+
+  protected constructor(eventName: SwipeEventName, target: Element, swipeInfo: ESLSwipeGestureEventInfo) {
     super(eventName, {bubbles: true, cancelable: true});
-    this.swipeInfo = swipeInfo;
-    overrideEvent(this, 'target', swipeInfo.target);
+    overrideEvent(this, 'target', target);
+    Object.assign(this, swipeInfo);
   }
 
   /** Creates {@link ESLSwipeGestureEvent} from {@link ESLSwipeGestureTarget} */
-  public static fromConfig(eventName: SwipeEventName, swipeInfo: ESLSwipeGestureEventInfo): ESLSwipeGestureEvent {
-    return new ESLSwipeGestureEvent(eventName, swipeInfo);
+  public static fromConfig(eventName: SwipeEventName, target: Element, swipeInfo: ESLSwipeGestureEventInfo): ESLSwipeGestureEvent {
+    return new ESLSwipeGestureEvent(eventName, target, 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
index 9c0f7be4e..cb8b68e81 100644
--- a/src/modules/esl-event-listener/core/targets/swipe.target.ts
+++ b/src/modules/esl-event-listener/core/targets/swipe.target.ts
@@ -102,7 +102,6 @@ export class ESLSwipeGestureTarget extends SyntheticEventTarget {
     const direction = this.resolveDirection(eventsDiff);
     if (direction) {
       const swipeInfo: ESLSwipeGestureEventInfo = {
-        target: this.target,
         direction,
         distanceX: Math.abs(eventsDiff.x),
         distanceY: Math.abs(eventsDiff.y),
@@ -111,9 +110,9 @@ export class ESLSwipeGestureTarget extends SyntheticEventTarget {
       };
 
       // fire `swipe` event on the element that started the swipe
-      this.dispatchEvent(ESLSwipeGestureEvent.fromConfig('swipe', swipeInfo));
+      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}` as SwipeEventName, swipeInfo));
+      this.dispatchEvent(ESLSwipeGestureEvent.fromConfig(`swipe:${direction}`, this.target, swipeInfo));
     }
 
     // Mark swipe as completed


Test patch:
diff --git a/src/modules/esl-event-listener/test/targets/resize.test.ts b/src/modules/esl-event-listener/test/targets/resize.test.ts
index b91499b4c..48edb0339 100644
--- a/src/modules/esl-event-listener/test/targets/resize.test.ts
+++ b/src/modules/esl-event-listener/test/targets/resize.test.ts
@@ -1,7 +1,7 @@
 import {ESLResizeObserverTarget} from '../../core';
 import {getLastResizeObserverMock} from '../../../esl-utils/test/resizeObserver.mock';
 
-describe('ESLEventUtils: ResizeObserver EventTarget adapter', () => {
+describe('ESLResizeObserverTarget EventTarget adapter', () => {
   describe('ESLResizeObserverTarget creation relatively to the targets', () => {
     const el1 = document.createElement('div');
     const el2 = document.createElement('div');
diff --git a/src/modules/esl-event-listener/test/targets/swipe.test.ts b/src/modules/esl-event-listener/test/targets/swipe.test.ts
new file mode 100644
index 000000000..30faa17b1
--- /dev/null
+++ b/src/modules/esl-event-listener/test/targets/swipe.test.ts
@@ -0,0 +1,136 @@
+import {ESLSwipeGestureEvent, ESLSwipeGestureTarget} from '../../core/targets/swipe.target';
+import {promisifyTimeout} from '../../../esl-utils/async/promise/timeout';
+
+describe('ESLSwipeGestureTarget EventTarget', () => {
+  describe('ESLSwipeGestureTarget do not throws error on incorrect input (silent processing)', () => {
+    const consoleSpy = jest.spyOn(console, 'warn');
+    beforeEach(() => consoleSpy.mockReset().mockImplementation(() => void 0));
+    afterAll(() => consoleSpy.mockRestore());
+
+    test('ESLResizeObserverTarget.for(undefined) returns null without error', () => {
+      expect(ESLSwipeGestureTarget.for(undefined as any)).toBeNull();
+      expect(consoleSpy).toBeCalled();
+    });
+
+    test('ESLResizeObserverTarget.for(null) returns null without error', () => {
+      expect(ESLSwipeGestureTarget.for(null as any)).toBeNull();
+      expect(consoleSpy).toBeCalled();
+    });
+
+    test('ESLResizeObserverTarget.for(123) returns null without error', () => {
+      expect(ESLSwipeGestureTarget.for(123 as any)).toBeNull();
+      expect(consoleSpy).toBeCalled();
+    });
+
+    test('ESLResizeObserverTarget.for({}) returns null without error', () => {
+      expect(ESLSwipeGestureTarget.for({} as any)).toBeNull();
+      expect(consoleSpy).toBeCalled();
+    });
+  });
+
+  describe('ESLSwipeGestureTarget instance without subscription is light', () => {
+    const $el = document.createElement('div');
+    const addEventListenerSpy = jest.spyOn($el, 'addEventListener');
+    const removeEventListenerSpy = jest.spyOn($el, 'removeEventListener');
+    const target = ESLSwipeGestureTarget.for($el);
+    const listener1 = jest.fn();
+    const listener2 = jest.fn();
+
+    test('ESLSwipeGestureTarget does not produce subscription on creation', () => {
+      expect(addEventListenerSpy).not.toBeCalled();
+      expect(removeEventListenerSpy).not.toBeCalled();
+    });
+
+    test('ESLSwipeGestureTarget does not produce subscription on creation', () => {
+      target.addEventListener('swipe', listener1);
+      expect(addEventListenerSpy).toBeCalled();
+      expect(removeEventListenerSpy).not.toBeCalled();
+      target.addEventListener('swipe', listener2);
+      expect(addEventListenerSpy).toBeCalled();
+      expect(removeEventListenerSpy).not.toBeCalled();
+    });
+
+    test('ESLSwipeGestureTarget does not produce subscription on creation', () => {
+      target.removeEventListener('swipe', listener1);
+      expect(removeEventListenerSpy).not.toBeCalled();
+      target.removeEventListener('swipe', listener2);
+      expect(removeEventListenerSpy).toBeCalled();
+    });
+  });
+
+  describe('ESLSwipeGestureTarget detects swipe gesture', () => {
+    const $el = document.createElement('div');
+    const target = ESLSwipeGestureTarget.for($el, {timeout: 50});
+    const listener = jest.fn();
+
+    beforeAll(() => target.addEventListener('swipe', listener));
+    afterAll(() => target.removeEventListener('swipe', listener));
+    beforeEach(() => listener.mockReset());
+
+    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}));
+      expect(listener).lastCalledWith(expect.any(ESLSwipeGestureEvent));
+      expect(listener).lastCalledWith(expect.objectContaining({direction: 'left'}));
+    });
+
+    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}));
+      expect(listener).lastCalledWith(expect.any(ESLSwipeGestureEvent));
+      expect(listener).lastCalledWith(expect.objectContaining({direction: 'right'}));
+    });
+
+    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}));
+      expect(listener).lastCalledWith(expect.any(ESLSwipeGestureEvent));
+      expect(listener).lastCalledWith(expect.objectContaining({direction: 'up'}));
+    });
+
+    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}));
+      expect(listener).lastCalledWith(expect.any(ESLSwipeGestureEvent));
+      expect(listener).lastCalledWith(expect.objectContaining({direction: 'down'}));
+    });
+
+    test('ESLSwipeGestureTarget ignores short horizontal swipes', async () => {
+      $el.dispatchEvent(Object.assign(new Event('pointerdown'), {clientX: 100, clientY: 100}));
+      $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 110, clientY: 105}));
+      expect(listener).not.toBeCalled();
+    });
+
+    test('ESLSwipeGestureTarget ignores short vertical swipes', async () => {
+      $el.dispatchEvent(Object.assign(new Event('pointerdown'), {clientX: 100, clientY: 100}));
+      $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 105, clientY: 110}));
+      expect(listener).not.toBeCalled();
+    });
+
+    test('ESLSwipeGestureTarget ignores long - lasting swipe', async () => {
+      $el.dispatchEvent(Object.assign(new Event('pointerdown'), {clientX: 100, clientY: 100}));
+      await promisifyTimeout(150);
+      $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 210, clientY: 105}));
+      expect(listener).not.toBeCalled();
+    });
+
+    test('ESLSwipeGestureTarget ignores unlinked events', async () => {
+      $el.dispatchEvent(Object.assign(new Event('pointerup'), {clientX: 210, clientY: 105}));
+      expect(listener).not.toBeCalled();
+    });
+
+    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'}));
+    });
+  });
+});