From e7e69a257b74f985be0c191b8e722dcb124bc74c Mon Sep 17 00:00:00 2001 From: Ruslan Grechihin Date: Tue, 29 Aug 2023 11:07:36 +0300 Subject: [PATCH] feat(esl-event-listener): add `SwipeEventTarget` to subscribe `swipe` events using `ESLEventListener` Closes: #1809 --- src/modules/esl-event-listener/README.md | 37 ++++ .../core/targets/swipe.event.ts | 47 +++++ .../core/targets/swipe.target.ts | 167 ++++++++++++++++++ src/modules/esl-utils/dom/test/units.test.ts | 37 ++++ src/modules/esl-utils/dom/units.ts | 30 ++++ 5 files changed, 318 insertions(+) create mode 100644 src/modules/esl-event-listener/core/targets/swipe.event.ts create mode 100644 src/modules/esl-event-listener/core/targets/swipe.target.ts create mode 100644 src/modules/esl-utils/dom/test/units.test.ts create mode 100644 src/modules/esl-utils/dom/units.ts 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); ``` + + +### ⚡ `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); +``` + --- ## Embedded behavior of `ESLBaseElement` / `ESLMixinElement` 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/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); + }); +}); 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; +};