Skip to content

Commit

Permalink
feat(esl-event-listener): add SwipeEventTarget to subscribe swipe
Browse files Browse the repository at this point in the history
… events using `ESLEventListener`

Closes: #1809
  • Loading branch information
grechihinrhp authored Aug 29, 2023
1 parent 5993574 commit e7e69a2
Show file tree
Hide file tree
Showing 5 changed files with 318 additions and 0 deletions.
37 changes: 37 additions & 0 deletions src/modules/esl-event-listener/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
47 changes: 47 additions & 0 deletions src/modules/esl-event-listener/core/targets/swipe.event.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
167 changes: 167 additions & 0 deletions src/modules/esl-event-listener/core/targets/swipe.target.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
37 changes: 37 additions & 0 deletions src/modules/esl-utils/dom/test/units.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
30 changes: 30 additions & 0 deletions src/modules/esl-utils/dom/units.ts
Original file line number Diff line number Diff line change
@@ -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;
};

0 comments on commit e7e69a2

Please sign in to comment.