Repository: exadel-inc/esl (ts)

Issue:
[🚀esl-event-listener]: request for InertWheel event detector
As a consumer, I want to be able to detect "inert" (long wheel) scrolls through the wheel control of the mouse (or alternative, e.g. long swipe on the touchpad).

During the consumer code research, the following snippet was found:
```ts

  @listen('wheel')
  protected _onWheel(event: WheelEvent): void {
    this._onWheelAggregated(event);
  }

  protected _onWheelAggregated = aggregate((events: WheelEvent[]) => {
    const dist = events.reduce((agg, e) => agg + e.deltaY, 0);
    if (dist > WHEEL_TOLERANCE) // Long wheel detected
  }, 100);

```

Proposed to simplify user flow in one of the following ways:

Base (event name, target and options are discussable)
```ts
 @listen({
    event: 'longwheel', 
    target: ($el: Element) => ESLWheelTarget.for($el, {timeout: 100, distance: 400})
  })
  protected _onWheel(event: WheelEvent): void {
     // Long wheel detected
  }
```

Open questions: 
  - Units for distance ... Not sure it is unified in browsers or even in specs.

Pull request:
epic(esl-event-listner): `ESLWheelTarget` proxy target to handle long wheel user actions
Closes #2017 

Patch:
diff --git a/pages/src/esl-events-demo/esl-events-demo.less b/pages/src/esl-events-demo/esl-events-demo.less
new file mode 100644
index 000000000..7fdd94f31
--- /dev/null
+++ b/pages/src/esl-events-demo/esl-events-demo.less
@@ -0,0 +1,4 @@
+@import './esl-swipe-demo/esl-swipe-demo-area.less';
+@import './esl-wheel-demo/esl-wheel-demo-area.less';
+
+@import './log-area/log-area.less';
diff --git a/pages/src/esl-events-demo/esl-events-demo.ts b/pages/src/esl-events-demo/esl-events-demo.ts
new file mode 100644
index 000000000..c4d5e84db
--- /dev/null
+++ b/pages/src/esl-events-demo/esl-events-demo.ts
@@ -0,0 +1,2 @@
+export * from './esl-swipe-demo/esl-swipe-demo-area';
+export * from './esl-wheel-demo/esl-wheel-demo-area';
diff --git a/pages/src/esl-events-demo/esl-swipe-demo/esl-swipe-demo-area.less b/pages/src/esl-events-demo/esl-swipe-demo/esl-swipe-demo-area.less
new file mode 100644
index 000000000..59fa12b49
--- /dev/null
+++ b/pages/src/esl-events-demo/esl-swipe-demo/esl-swipe-demo-area.less
@@ -0,0 +1,3 @@
+esl-d-swipe-area {
+  cursor: grab;
+}
diff --git a/pages/src/esl-events-demo/esl-swipe-demo/esl-swipe-demo-area.ts b/pages/src/esl-events-demo/esl-swipe-demo/esl-swipe-demo-area.ts
new file mode 100644
index 000000000..197ccc35a
--- /dev/null
+++ b/pages/src/esl-events-demo/esl-swipe-demo/esl-swipe-demo-area.ts
@@ -0,0 +1,19 @@
+import {ESLSwipeGestureTarget, ESLSwipeGestureEvent} from '@exadel/esl/modules/esl-event-listener/core';
+import {listen} from '@exadel/esl/modules/esl-utils/decorators';
+
+import {ESLDemoLogArea} from '../log-area/log-area';
+
+/** Area element for logging swipe event data */
+export class ESLDemoSwipeArea extends ESLDemoLogArea {
+  static override is = 'esl-d-swipe-area';
+
+  @listen({
+    event: 'swipe',
+    target: ESLSwipeGestureTarget.for
+  })
+  onSwipe(e: ESLSwipeGestureEvent): void {
+    const {name} = ESLSwipeGestureEvent;
+    const {type, direction, distanceX, distanceY, distance, angle} = e;
+    this.log({name, type, data: {direction, distanceX, distanceY, distance, angle}});
+  }
+}
diff --git a/pages/src/esl-events-demo/esl-wheel-demo/esl-wheel-demo-area.less b/pages/src/esl-events-demo/esl-wheel-demo/esl-wheel-demo-area.less
new file mode 100644
index 000000000..5ad388999
--- /dev/null
+++ b/pages/src/esl-events-demo/esl-wheel-demo/esl-wheel-demo-area.less
@@ -0,0 +1,3 @@
+esl-d-wheel-area {
+  cursor: all-scroll;
+}
diff --git a/pages/src/esl-events-demo/esl-wheel-demo/esl-wheel-demo-area.ts b/pages/src/esl-events-demo/esl-wheel-demo/esl-wheel-demo-area.ts
new file mode 100644
index 000000000..3d0d7c225
--- /dev/null
+++ b/pages/src/esl-events-demo/esl-wheel-demo/esl-wheel-demo-area.ts
@@ -0,0 +1,19 @@
+import {ESLWheelTarget, ESLWheelEvent} from '@exadel/esl/modules/esl-event-listener/core';
+import {listen} from '@exadel/esl/modules/esl-utils/decorators';
+
+import {ESLDemoLogArea} from '../log-area/log-area';
+
+/** Area element for logging long wheel event data */
+export class ESLDemoWheelArea extends ESLDemoLogArea {
+  static override is = 'esl-d-wheel-area';
+
+  @listen({
+    event: 'longwheel',
+    target: ESLWheelTarget.for
+  })
+  onWheel(e: ESLWheelEvent): void {
+    const {name} = ESLWheelEvent;
+    const {type, axis, deltaX, deltaY} = e;
+    this.log({name, type, data: {axis, deltaX, deltaY}});
+  }
+}
diff --git a/pages/src/esl-swipe-demo/esl-swipe-demo-area.less b/pages/src/esl-events-demo/log-area/log-area.less
similarity index 90%
rename from pages/src/esl-swipe-demo/esl-swipe-demo-area.less
rename to pages/src/esl-events-demo/log-area/log-area.less
index 720b5c272..7aab76b38 100644
--- a/pages/src/esl-swipe-demo/esl-swipe-demo-area.less
+++ b/pages/src/esl-events-demo/log-area/log-area.less
@@ -1,6 +1,6 @@
-@import (reference) '../common/variables.less';
+@import (reference) '../../common/variables.less';
 
-esl-d-swipe-area {
+.esl-d-log-area {
   touch-action: none;
   display: flex;
   flex-direction: column;
@@ -13,7 +13,6 @@ esl-d-swipe-area {
 
   padding: 1rem;
 
-  cursor: grab;
   color: @body-fg;
   background-color: @body-bg;
   border: @body-fg 1px solid;
diff --git a/pages/src/esl-events-demo/log-area/log-area.ts b/pages/src/esl-events-demo/log-area/log-area.ts
new file mode 100644
index 000000000..26ea42abc
--- /dev/null
+++ b/pages/src/esl-events-demo/log-area/log-area.ts
@@ -0,0 +1,26 @@
+import {ESLBaseElement} from '@exadel/esl/modules/esl-base-element/core';
+
+export interface LogEventData {
+  /** The name of the event's class */
+  name: string;
+  /** The type of the event */
+  type: string;
+  /** Additional event data */
+  data: Record<string, unknown>;
+}
+
+/** Base element for logging event data */
+export class ESLDemoLogArea extends ESLBaseElement {
+  static override is = 'esl-d-logger';
+
+  log(eventData: LogEventData): void {
+    const {name, type, data} = eventData;
+    const $logItem = document.createElement('div');
+    $logItem.className = 'log-item';
+    $logItem.innerHTML = `<code>${name}</code> ( ${type} ): <i>{${Object.entries(data).map(([key, value]) => `${key}: ${value}`).join(', ')}}</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/esl-swipe-demo/esl-swipe-demo-area.ts b/pages/src/esl-swipe-demo/esl-swipe-demo-area.ts
deleted file mode 100644
index 7705f2fee..000000000
--- a/pages/src/esl-swipe-demo/esl-swipe-demo-area.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import {ESLBaseElement} from '@exadel/esl/modules/esl-base-element/core';
-import {ESLSwipeGestureTarget} from '@exadel/esl/modules/esl-event-listener/core';
-import {listen} from '@exadel/esl/modules/esl-utils/decorators';
-
-import type {ESLSwipeGestureEvent} from '@exadel/esl/modules/esl-event-listener/core';
-
-export class ESLDemoSwipeArea extends ESLBaseElement {
-  static override is = 'esl-d-swipe-area';
-
-  @listen({
-    event: 'swipe',
-    target: ESLSwipeGestureTarget.for
-  })
-  onSwipe(e: ESLSwipeGestureEvent): void {
-    const $logItem = document.createElement('div');
-    $logItem.className = 'log-item';
-    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);
-    setTimeout(() => $logItem.remove(), 3000);
-  }
-}
diff --git a/pages/src/localdev.less b/pages/src/localdev.less
index 48059be58..4f95582ca 100644
--- a/pages/src/localdev.less
+++ b/pages/src/localdev.less
@@ -35,7 +35,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 './esl-events-demo/esl-events-demo.less';
 
 @import './back-link/back-link';
diff --git a/pages/src/localdev.ts b/pages/src/localdev.ts
index 3a421a586..f0bcd796c 100644
--- a/pages/src/localdev.ts
+++ b/pages/src/localdev.ts
@@ -62,7 +62,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';
+import {ESLDemoSwipeArea, ESLDemoWheelArea} from './esl-events-demo/esl-events-demo';
 
 ESLVSizeCSSProxy.observe();
 
@@ -76,6 +76,7 @@ ESLDemoAnchorLink.register();
 ESLDemoBackLink.register();
 ESLDemoBanner.register();
 ESLDemoSwipeArea.register();
+ESLDemoWheelArea.register();
 
 // Test Content
 ESLRandomText.register('lorem-ipsum');
diff --git a/pages/views/draft/swipe-demo.njk b/pages/views/draft/swipe-demo.njk
index 7a7c7ff4c..144c49feb 100644
--- a/pages/views/draft/swipe-demo.njk
+++ b/pages/views/draft/swipe-demo.njk
@@ -24,7 +24,7 @@ tags: [draft]
     {% endcode %}
 
     <div class="d-flex">
-      <esl-d-swipe-area class="m-auto" placeholder="Make swipes in that area">
+      <esl-d-swipe-area class="m-auto esl-d-log-area" placeholder="Make swipes in that area">
         <button class="btn btn-sec-blue" onclick="alert('Clicked')">Click</button>
       </esl-d-swipe-area>
     </div>
diff --git a/pages/views/draft/wheel-demo.njk b/pages/views/draft/wheel-demo.njk
new file mode 100644
index 000000000..b5ed5fb3d
--- /dev/null
+++ b/pages/views/draft/wheel-demo.njk
@@ -0,0 +1,31 @@
+---
+layout: content
+title: Long Wheel Event Target (Regression)
+name: Long Wheel Event Target (Regression)
+tags: [draft]
+---
+
+<section class="row">
+  <div class="col-12">
+    <p>
+      <code>ESLWheelTarget</code> usage example
+    </p>
+    {% code 'ts', 'code-block' %}
+      @listen({
+        event: 'longwheel',
+        target: ESLWheelTarget.for
+      })
+      onWheel(e: ESLWheelEvent): 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-wheel-area class="m-auto esl-d-log-area" placeholder="Make scrolls in the area"></esl-d-wheel-area>
+    </div>
+
+  </div>
+</section>
diff --git a/src/modules/esl-event-listener/README.md b/src/modules/esl-event-listener/README.md
index 50fe37ec1..a105a798c 100644
--- a/src/modules/esl-event-listener/README.md
+++ b/src/modules/esl-event-listener/README.md
@@ -632,6 +632,43 @@ ESLEventUtils.subscribe(host, {
 }, onSwipe);
 ```
 
+<a name="-esleventutilwheel"></a>
+
+### ⚡ `ESLWheelTarget.for` <i class="badge badge-sup badge-success">new</i>
+
+`ESLWheelTarget.for` is a simple way to listen for 'inert' (long wheel) scrolls events on any element.
+This utility detects `wheel` events, and based on the total amount (distance) of `wheel` events and time (`timeout`) between the first and the last events, it triggers `longwheel` event on the target element.
+
+```typescript
+ESLWheelTarget.for(el: Element, settings?: ESLWheelTargetSetting): ESLWheelTarget;
+```
+
+**Parameters**:
+
+- `el` - `Element` to listen for long wheel events
+- `settings` - optional settings (`ESLWheelTargetSetting`)
+
+The `ESLWheelTargetSetting` configuration includes these optional attributes:
+- `distance` - the minimum distance to accept as a long scroll in pixels (400 by default)
+- `timeout` - the maximum duration of the wheel events to consider it inertial in milliseconds (100 by default)
+
+Usage example:
+
+```typescript
+ESLEventUtils.subscribe(host, {
+  event: 'longwheel',
+  target: ESLWheelTarget.for(el)
+}, onWheel);
+// or
+ESLEventUtils.subscribe(host, {
+  event: 'longwheel',
+  target: (host) => ESLWheelTarget.for(host.el, {
+    threshold: 30,
+    timeout: 1000
+  })
+}, onWheel);
+```
+
 <a name="-esleventutilintersection"></a>
 
 ### ⚡ `ESLIntersectionTarget.for` <i class="badge badge-sup badge-success">new</i>
diff --git a/src/modules/esl-event-listener/core.ts b/src/modules/esl-event-listener/core.ts
index ac933f1d8..c4213e5df 100644
--- a/src/modules/esl-event-listener/core.ts
+++ b/src/modules/esl-event-listener/core.ts
@@ -3,4 +3,5 @@ export * from './core/api';
 export * from './core/targets/resize.target';
 export * from './core/targets/decorated.target';
 export * from './core/targets/swipe.target';
+export * from './core/targets/wheel.target';
 export * from './core/targets/intersection.target';
diff --git a/src/modules/esl-event-listener/core/targets/wheel.target.event.ts b/src/modules/esl-event-listener/core/targets/wheel.target.event.ts
new file mode 100644
index 000000000..33e3d9290
--- /dev/null
+++ b/src/modules/esl-event-listener/core/targets/wheel.target.event.ts
@@ -0,0 +1,43 @@
+import {overrideEvent} from '../../../esl-utils/dom/events/misc';
+
+/**
+ * Describes scroll information provided with {@link ESLWheelTarget}
+ */
+export interface ESLWheelEventInfo {
+  /** Axis along which the scroll was performed */
+  axis: 'x' | 'y';
+  /** Distance scrolled along the x-axis */
+  deltaX: number;
+  /** Distance scrolled along the y-axis */
+  deltaY: number;
+  /** List of tracked wheel events */
+  events: WheelEvent[];
+  /** Time between first an last wheel events */
+  duration: number;
+}
+
+/**
+ * Wheel event dispatched by {@link ESLWheelTarget}
+ */
+export class ESLWheelEvent extends UIEvent implements ESLWheelEventInfo {
+  public static readonly type = 'longwheel';
+
+  public override readonly target: Element;
+
+  public readonly axis: 'x' | 'y';
+  public readonly deltaX: number;
+  public readonly deltaY: number;
+  public readonly events: WheelEvent[];
+  public readonly duration: number;
+
+  protected constructor(target: Element, wheelInfo: ESLWheelEventInfo) {
+    super(ESLWheelEvent.type);
+    overrideEvent(this, 'target', target);
+    Object.assign(this, wheelInfo);
+  }
+
+  /** Creates {@link ESLWheelEvent} based on {@link ESLWheelEventInfo} */
+  public static fromConfig(target: Element, wheelInfo: ESLWheelEventInfo): ESLWheelEvent {
+    return new ESLWheelEvent(target, wheelInfo);
+  }
+}
diff --git a/src/modules/esl-event-listener/core/targets/wheel.target.ts b/src/modules/esl-event-listener/core/targets/wheel.target.ts
new file mode 100644
index 000000000..402a77510
--- /dev/null
+++ b/src/modules/esl-event-listener/core/targets/wheel.target.ts
@@ -0,0 +1,142 @@
+import {SyntheticEventTarget} from '../../../esl-utils/dom/events/target';
+import {resolveDomTarget} from '../../../esl-utils/abstract/dom-target';
+import {isElement} from '../../../esl-utils/dom/api';
+import {bind} from '../../../esl-utils/decorators/bind';
+import {aggregate} from '../../../esl-utils/async/aggregate';
+import {ESLEventListener} from '../listener';
+
+import {ESLWheelEvent} from './wheel.target.event';
+
+import type {ESLWheelEventInfo} from './wheel.target.event';
+import type {ESLDomElementTarget} from '../../../esl-utils/abstract/dom-target';
+
+export {ESLWheelEvent};
+
+/**
+ * Describes settings object that could be passed to {@link ESLWheelTarget.for} as optional parameter
+ */
+export interface ESLWheelTargetSetting {
+  /** The minimum distance to accept as a long scroll */
+  distance?: number;
+  /** The maximum duration of the wheel events to consider it inertial */
+  timeout?: number;
+}
+
+/**
+ * Implementation of EventTarget to observe wheel events for inertial scrolling
+ */
+export class ESLWheelTarget extends SyntheticEventTarget {
+  protected static defaultConfig: Required<ESLWheelTargetSetting> = {
+    distance: 400,
+    timeout: 100
+  };
+
+  protected readonly config: Required<ESLWheelTargetSetting>;
+
+  /** Function for aggregating wheel events into array of events */
+  protected aggregateWheel: (event: WheelEvent) => void;
+
+  /**
+   * @param target - The target DOM element to observe
+   * @param settings - Optional settings object
+   * @returns A {@link ESLWheelTarget} instance if the target is a valid element
+   */
+  public static for(target: ESLDomElementTarget, settings?: ESLWheelTargetSetting): ESLWheelTarget;
+  public static for(target: ESLDomElementTarget, settings?: ESLWheelTargetSetting): ESLWheelTarget | null {
+    const $target = resolveDomTarget(target);
+    if (isElement($target)) return new ESLWheelTarget($target, settings);
+
+    console.warn('[ESL]: ESLWheelTarget can`t observe %o', target);
+    return null;
+  }
+
+  protected constructor(
+    protected readonly target: Element,
+    settings?: ESLWheelTargetSetting
+  ) {
+    super();
+    this.config = Object.assign({}, ESLWheelTarget.defaultConfig, settings);
+
+    this.aggregateWheel = aggregate((events: WheelEvent[]) => this.handleAggregatedWheel(events), this.config.timeout);
+  }
+
+  /** Handles wheel events */
+  @bind
+  protected _onWheel(event: WheelEvent): void {
+    this.aggregateWheel(event);
+  }
+
+  /** Handles aggregated wheel events */
+  protected handleAggregatedWheel(events: WheelEvent[]): void {
+    const wheelInfo =  this.resolveEventDetails(events);
+    if (Math.abs(wheelInfo.deltaX) >= this.config.distance) this.dispatchWheelEvent('x', wheelInfo);
+    if (Math.abs(wheelInfo.deltaY) >= this.config.distance) this.dispatchWheelEvent('y', wheelInfo);
+  }
+
+  /**
+   * Dispatches a custom wheel event
+   * @param axis - The axis along which the scroll was performed
+   * @param wheelInfo - The event detail object
+   */
+  protected dispatchWheelEvent(axis: 'x' | 'y', wheelInfo: Omit<ESLWheelEventInfo, 'axis'>): void {
+    this.dispatchEvent(ESLWheelEvent.fromConfig(this.target, Object.assign({}, wheelInfo, {axis})));
+  }
+
+  /**
+   * Resolves long wheel detail object from array of WheelEvent objects
+   * @param events - An array of WheelEvent
+   * @returns An object containing the resolved event details
+   */
+  protected resolveEventDetails(events: WheelEvent[]): Omit<ESLWheelEventInfo, 'axis'> {
+    const delta = events.reduce((agg, e) => ({
+      x: agg.x + this.calculateScrollPixels(e, false),
+      y: agg.y + this.calculateScrollPixels(e, true)
+    }), {x: 0, y: 0});
+
+    const duration = events[events.length - 1].timeStamp - events[0].timeStamp;
+    return {deltaX: delta.x, deltaY: delta.y, events, duration};
+  }
+
+  /**
+   * Calculates the scroll pixels for a given wheel event
+   * @param events - An event to retrieve scroll value from
+   * @param isVertical - A boolean indicating the axis (vertical or horizontal) for scroll calculation
+   * @returns The number of pixels scrolled
+   */
+  private calculateScrollPixels(event: WheelEvent, isVertical: boolean): number {
+    const {DOM_DELTA_LINE, DOM_DELTA_PAGE} = WheelEvent;
+    const deltaValue = (isVertical && event.shiftKey) ? 0 : (isVertical || event.shiftKey) ? event.deltaY : event.deltaX;
+
+    let delta;
+    switch (event.deltaMode) {
+      case DOM_DELTA_LINE:
+        delta = deltaValue * parseInt(window.getComputedStyle(this.target).lineHeight, 10);
+        break;
+      case DOM_DELTA_PAGE:
+        delta = isVertical ? window.innerHeight : window.innerWidth;
+        break;
+      default:
+        delta = deltaValue * window.devicePixelRatio;
+    }
+    return delta;
+  }
+
+  /** Subscribes to wheel event */
+  public override addEventListener(callback: EventListener): void;
+  public override addEventListener(event: string, callback: EventListener): void;
+  public override addEventListener(event: any, callback: EventListener = event): void {
+    super.addEventListener(event, callback);
+
+    if (this.getEventListeners().length > 1) return;
+    ESLEventListener.subscribe(this, this._onWheel, {event: 'wheel', capture: false, target: this.target});
+  }
+
+  /** Unsubscribes from the observed target {@link Element} wheel events */
+  public override removeEventListener(callback: EventListener): void;
+  public override removeEventListener(event: string, callback: EventListener): void;
+  public override removeEventListener(event: any, callback: EventListener = event): void {
+    super.removeEventListener(event, callback);
+
+    if (!this.hasEventListener(event)) ESLEventListener.unsubscribe(this);
+  }
+}


Test patch:
diff --git a/src/modules/esl-event-listener/test/targets/wheel.test.ts b/src/modules/esl-event-listener/test/targets/wheel.test.ts
new file mode 100644
index 000000000..a39fbdb1e
--- /dev/null
+++ b/src/modules/esl-event-listener/test/targets/wheel.test.ts
@@ -0,0 +1,162 @@
+import {ESLWheelEvent, ESLWheelTarget} from '../../core/targets/wheel.target';
+
+describe('ESLWheelTarget', () => {
+  jest.useFakeTimers();
+
+  describe('ESLWheelTarget does not throw error on incorrect input (silent processing)', () => {
+    const consoleSpy = jest.spyOn(console, 'warn');
+    beforeEach(() => consoleSpy.mockReset().mockImplementation(() => void 0));
+    afterAll(() => consoleSpy.mockRestore());
+
+    test('ESLWheelTarget.for(undefined) returns null without error', () => {
+      expect(ESLWheelTarget.for(undefined as any)).toBeNull();
+      expect(consoleSpy).toHaveBeenCalled();
+    });
+
+    test('ESLWheelTarget.for(null) returns null without error', () => {
+      expect(ESLWheelTarget.for(null as any)).toBeNull();
+      expect(consoleSpy).toHaveBeenCalled();
+    });
+
+    test('ESLWheelTarget.for(123) returns null without error', () => {
+      expect(ESLWheelTarget.for(123 as any)).toBeNull();
+      expect(consoleSpy).toHaveBeenCalled();
+    });
+
+    test('ESLWheelTarget.for({}) returns null without error', () => {
+      expect(ESLWheelTarget.for({} as any)).toBeNull();
+      expect(consoleSpy).toHaveBeenCalled();
+    });
+  });
+
+  describe('ESLWheelTarget instance subscription', () => {
+    const $el = document.createElement('div');
+    const addEventListenerSpy = jest.spyOn($el, 'addEventListener');
+    const removeEventListenerSpy = jest.spyOn($el, 'removeEventListener');
+    const target = ESLWheelTarget.for($el);
+    const listener1 = jest.fn();
+    const listener2 = jest.fn();
+
+    test('ESLWheelTarget does not produce subscription on creation', () => {
+      expect(addEventListenerSpy).not.toHaveBeenCalled();
+      expect(removeEventListenerSpy).not.toHaveBeenCalled();
+    });
+
+    test('ESLWheelTarget subscribes once on first subscription', () => {
+      target.addEventListener('longwheel', listener1);
+      expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
+      expect(removeEventListenerSpy).not.toHaveBeenCalled();
+      target.addEventListener('longwheel', listener2);
+      expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
+      expect(removeEventListenerSpy).not.toHaveBeenCalled();
+    });
+
+    test('ESLWheelTarget doesn`t unsubscrie until last subscription is removed from target', () => {
+      target.removeEventListener('longwheel', listener1);
+      expect(removeEventListenerSpy).not.toHaveBeenCalled();
+      target.removeEventListener('longwheel', listener2);
+      expect(removeEventListenerSpy).toHaveBeenCalled();
+    });
+  });
+
+  describe('ESLWheelTarget ignores "short" scroll events', () => {
+    const $el = document.createElement('div');
+    const target = ESLWheelTarget.for($el, {timeout: 50, distance: 101});
+    const listener = jest.fn();
+
+    beforeAll(() => target.addEventListener('longwheel', listener));
+    afterAll(() => target.removeEventListener('longwheel', listener));
+    beforeEach(() => listener.mockReset());
+
+    test('ESLWheelTarget ignores horizontal short swipe', () => {
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: 100}));
+      jest.advanceTimersByTime(50);
+      expect(listener).not.toHaveBeenCalled();
+    });
+
+    test('ESLWheelTarget ignores vertical short swipe',  () => {
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaY: 100}));
+      jest.advanceTimersByTime(50);
+      expect(listener).not.toHaveBeenCalled();
+    });
+
+    test('ESLWheelTarget ignores diagonal short swipe', () => {
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: 100, deltaY: 100}));
+      jest.advanceTimersByTime(50);
+      expect(listener).not.toHaveBeenCalled();
+    });
+
+    test('ESLWheelTarget should handle rapid alternation in scroll direction without false positives', () => {
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: 50}));
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: -50}));
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: 50}));
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: -50}));
+      jest.advanceTimersByTime(50);
+      expect(listener).not.toHaveBeenCalled();
+    });
+
+    test('ESLWheelTarget should ignore long scroll when scrolls are beyond timeout', () => {
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: 100}));
+      jest.advanceTimersByTime(50);
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: 100}));
+      jest.advanceTimersByTime(50);
+      expect(listener).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('ESLWheelTarget detects long scroll events', () => {
+    const $el = document.createElement('div');
+    const target = ESLWheelTarget.for($el, {timeout: 50, distance: 100});
+    const listener = jest.fn();
+
+    beforeAll(() => target.addEventListener('longwheel', listener));
+    afterAll(() => target.removeEventListener('longwheel', listener));
+    beforeEach(() => listener.mockReset());
+
+    test('ESLWheelTarget detects horizontal long scroll', () => {
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: 100}));
+      jest.advanceTimersByTime(50);
+      expect(listener).toHaveBeenLastCalledWith(expect.any(ESLWheelEvent));
+      expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({deltaX: 100, axis: 'x'}));
+    });
+
+    test('ESLWheelTarget detects horizontal long scroll with a shift key pressed', () => {
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaY: 100, shiftKey: true}));
+      jest.advanceTimersByTime(50);
+      expect(listener).toHaveBeenLastCalledWith(expect.any(ESLWheelEvent));
+      expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({deltaX: 100, axis: 'x'}));
+    });
+
+    test('ESLWheelTarget detects vertical long scroll', () => {
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaY: 100}));
+      jest.advanceTimersByTime(50);
+      expect(listener).toHaveBeenLastCalledWith(expect.any(ESLWheelEvent));
+      expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({deltaY: 100, axis: 'y'}));
+    });
+
+    test('ESLWheelTarget detects negative vertical long scroll', () => {
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaY: -100}));
+      jest.advanceTimersByTime(50);
+      expect(listener).toHaveBeenLastCalledWith(expect.any(ESLWheelEvent));
+      expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({deltaY: -100, axis: 'y'}));
+    });
+
+    test('ESLWheelTarget detects both horizontal and vertical long scrolls withing time limit', () => {
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: 100, deltaY: 200}));
+      jest.advanceTimersByTime(50);
+      expect(listener).toHaveBeenCalledTimes(2);
+      expect(listener.mock.calls.slice(-1)[0][0]).toEqual(expect.objectContaining({deltaY: 200, axis: 'y'}));
+      expect(listener.mock.calls.slice(0)[0][0]).toEqual(expect.objectContaining({deltaX: 100, axis: 'x'}));
+    });
+
+    test('ESLWheelTarget detects long scroll when multiple small scrolls exceed distance', async () => {
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: 50}));
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: 50}));
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: 50}));
+      $el.dispatchEvent(Object.assign(new Event('wheel'), {deltaX: 50}));
+      jest.advanceTimersByTime(50);
+      expect(listener).toHaveBeenCalled();
+      expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({deltaX: 200, axis: 'x'}));
+    });
+  });
+});