Skip to content

Commit

Permalink
Merge pull request #2326 from exadel-inc/tech/port-listener-5.0.0
Browse files Browse the repository at this point in the history
[maintain] Porting `esl-event-listener` and `microtask` utility related changes for `4.16.0`
  • Loading branch information
ala-n authored Apr 10, 2024
2 parents 9db15f7 + 80c5747 commit 19b7c18
Show file tree
Hide file tree
Showing 9 changed files with 142 additions and 20 deletions.
8 changes: 4 additions & 4 deletions src/modules/esl-event-listener/core/listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {wrap} from '../../esl-utils/misc/array';
import {isElement} from '../../esl-utils/dom/api';
import {isPassiveByDefault} from '../../esl-utils/dom/events/misc';
import {resolveProperty} from '../../esl-utils/misc/functions';
import {isObject, isSimilar} from '../../esl-utils/misc/object';
import {isObject, isObjectLike, isSimilar} from '../../esl-utils/misc/object';
import {resolveDomTarget} from '../../esl-utils/abstract/dom-target';
import {memoize} from '../../esl-utils/decorators/memoize';
import {ESLTraversingQuery} from '../../esl-traversing-query/core';
Expand Down Expand Up @@ -149,14 +149,14 @@ export class ESLEventListener implements ESLListenerDefinition, EventListenerObj
* Supports additional filtration criteria
*/
public static get(host?: object, ...criteria: ESLListenerCriteria[]): ESLEventListener[] {
if (!isObject(host)) return [];
if (!isObjectLike(host)) return [];
const listeners = ((host as any)[LISTENERS] || []) as ESLEventListener[];
if (!criteria.length) return listeners;
return listeners.filter((listener) => criteria.every(listener.matches, listener));
}
/** Adds a listener to the listener store of the host object */
protected static add(host: object, instance: ESLEventListener): void {
if (!isObject(host)) return;
if (!isObjectLike(host)) return;
if (!Object.hasOwnProperty.call(host, LISTENERS)) (host as any)[LISTENERS] = [];
(host as any)[LISTENERS].push(instance);
}
Expand Down Expand Up @@ -201,7 +201,7 @@ export class ESLEventListener implements ESLListenerDefinition, EventListenerObj
handler: ESLListenerHandler,
desc: ESLListenerDescriptor
): ESLEventListener[] {
if (!isObject(host)) return [];
if (!isObjectLike(host)) return [];
const eventString = resolveProperty(desc.event, host);
const listeners: ESLEventListener[] = [];
for (const event of splitEvents(eventString)) {
Expand Down
11 changes: 11 additions & 0 deletions src/modules/esl-event-listener/core/targets/swipe.target.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {SyntheticEventTarget} from '../../../esl-utils/dom/events/target';
import {getParentScrollOffsets, isOffsetChanged} from '../../../esl-utils/dom/scroll';
import {getTouchPoint, isMouseEvent, isTouchEvent} from '../../../esl-utils/dom/events/misc';
import {resolveDomTarget} from '../../../esl-utils/abstract/dom-target';
import {isElement} from '../../../esl-utils/dom/api';
Expand All @@ -7,6 +8,7 @@ import {resolveCSSSize} from '../../../esl-utils/dom/units';
import {ESLEventListener} from '../listener';
import {ESLSwipeGestureEvent} from './swipe.target.event';

import type {ElementScrollOffset} from '../../../esl-utils/dom//scroll';
import type {CSSSize} from '../../../esl-utils/dom/units';
import type {SwipeDirection, ESLSwipeGestureEventInfo} from './swipe.target.event';
import type {ESLDomElementTarget} from '../../../esl-utils/abstract/dom-target';
Expand All @@ -17,6 +19,8 @@ export {ESLSwipeGestureEvent};
* Describes settings object that could be passed to {@link ESLSwipeGestureTarget.for} as optional parameter
*/
export interface ESLSwipeGestureSetting {
/** Flag to indicate if the swipe event should not be dispatched if a scroll of content was detected (true by default) */
skipOnScroll?: boolean;
/** The minimum distance to accept swipe (supports `px`, `vw` and `vh` units) */
threshold?: CSSSize;
/** The maximum duration between `ponterdown` and `pointerup` events */
Expand All @@ -28,6 +32,7 @@ export interface ESLSwipeGestureSetting {
*/
export class ESLSwipeGestureTarget extends SyntheticEventTarget {
protected static defaultConfig: Required<ESLSwipeGestureSetting> = {
skipOnScroll: true,
threshold: '20px',
timeout: 500
};
Expand All @@ -49,6 +54,7 @@ export class ESLSwipeGestureTarget extends SyntheticEventTarget {
protected readonly config: Required<ESLSwipeGestureSetting>;

protected startEvent: PointerEvent;
protected startEventOffset: ElementScrollOffset[];

protected constructor(
protected readonly target: Element,
Expand All @@ -70,6 +76,7 @@ export class ESLSwipeGestureTarget extends SyntheticEventTarget {
* @param startEvent - initial pointer event
*/
protected handleStart(startEvent: PointerEvent): void {
this.startEventOffset = this.config.skipOnScroll ? getParentScrollOffsets(startEvent.target as Element, this.target) : [];
this.startEvent = startEvent;
ESLEventListener.subscribe(this, this.handleEnd, {
event: this.endEventName,
Expand Down Expand Up @@ -119,6 +126,10 @@ export class ESLSwipeGestureTarget extends SyntheticEventTarget {

// return if swipe took too long or distance is too short
if (!this.isGestureAcceptable(eventDetails)) return;
if (this.config.skipOnScroll) {
const offsets = getParentScrollOffsets(endEvent.target as Element, this.target);
if (isOffsetChanged(this.startEventOffset.concat(offsets))) return;
}

const event = ESLSwipeGestureEvent.fromConfig(this.target, eventDetails);
// fire `swipe` event on the element that started the swipe
Expand Down
15 changes: 15 additions & 0 deletions src/modules/esl-event-listener/core/targets/wheel.target.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {SyntheticEventTarget} from '../../../esl-utils/dom/events/target';
import {resolveDomTarget} from '../../../esl-utils/abstract/dom-target';
import {getParentScrollOffsets, isOffsetChanged} from '../../../esl-utils/dom/scroll';
import {isElement} from '../../../esl-utils/dom/api';
import {bind} from '../../../esl-utils/decorators/bind';
import {aggregate} from '../../../esl-utils/async/aggregate';
Expand All @@ -9,13 +10,16 @@ import {ESLWheelEvent} from './wheel.target.event';

import type {ESLWheelEventInfo} from './wheel.target.event';
import type {ESLDomElementTarget} from '../../../esl-utils/abstract/dom-target';
import type {ElementScrollOffset} from '../../../esl-utils/dom/scroll';

export {ESLWheelEvent};

/**
* Describes settings object that could be passed to {@link ESLWheelTarget.for} as optional parameter
*/
export interface ESLWheelTargetSetting {
/** Flag to indicate if the `longwheel` event shouldn't be dispatched if scroll of content was detected (true by default) */
skipOnScroll?: boolean;
/** The minimum distance to accept as a long scroll */
distance?: number;
/** The maximum duration of the wheel events to consider it inertial */
Expand All @@ -27,12 +31,15 @@ export interface ESLWheelTargetSetting {
*/
export class ESLWheelTarget extends SyntheticEventTarget {
protected static defaultConfig: Required<ESLWheelTargetSetting> = {
skipOnScroll: true,
distance: 400,
timeout: 100
};

protected readonly config: Required<ESLWheelTargetSetting>;

protected scrollData: ElementScrollOffset[] = [];

/** Function for aggregating wheel events into array of events */
protected aggregateWheel: (event: WheelEvent) => void;

Expand Down Expand Up @@ -63,12 +70,20 @@ export class ESLWheelTarget extends SyntheticEventTarget {
/** Handles wheel events */
@bind
protected _onWheel(event: WheelEvent): void {
if (this.config.skipOnScroll) {
const offsets = getParentScrollOffsets(event.target as Element, this.target);
this.scrollData = this.scrollData.concat(offsets);
}
this.aggregateWheel(event);
}

/** Handles aggregated wheel events */
protected handleAggregatedWheel(events: WheelEvent[]): void {
const wheelInfo = this.resolveEventDetails(events);

const isBlocked = isOffsetChanged(this.scrollData);
this.scrollData = [];
if (isBlocked) return;
if (Math.abs(wheelInfo.deltaX) >= this.config.distance) this.dispatchWheelEvent('x', wheelInfo);
if (Math.abs(wheelInfo.deltaY) >= this.config.distance) this.dispatchWheelEvent('y', wheelInfo);
}
Expand Down
29 changes: 29 additions & 0 deletions src/modules/esl-event-listener/test/listener.subscribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,33 @@ describe('ESLEventUtils:subscribe tests', () => {
ESLEventUtils.subscribe(host, {event: 'click'}, handle);
expect(ESLEventUtils.listeners(host).length).toBe(0);
});

describe('ESLEventListener subscribes correctly for any object-like host', () => {
test.each([
{},
Object.create(null),
function hostFunction() {},
class HostClass {},
new (class HostClassInst {})()
])('ESLEventListener subscribes correctly for %o host', (host) => {
const handle = jest.fn();
ESLEventUtils.subscribe(host, {event: 'click', target: document.body}, handle);
expect(ESLEventUtils.listeners(host).length).toBe(1);
ESLEventUtils.unsubscribe(host);
});

test('ESLEventListener can not subscribe for primitive host', () => {
jest.spyOn(console, 'warn').mockImplementationOnce(() => {});
const handle = jest.fn();
const count = ESLEventUtils.subscribe(1 as any, {event: 'click', target: document.body}, handle).length;
expect(count).toBe(0);
});

test('ESLEventListener can not subscribe for null host', () => {
jest.spyOn(console, 'warn').mockImplementationOnce(() => {});
const handle = jest.fn();
const count = ESLEventUtils.subscribe(null as any, {event: 'click', target: document.body}, handle).length;
expect(count).toBe(0);
});
});
});
3 changes: 2 additions & 1 deletion src/modules/esl-utils/async/microtask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
* (as a microtask produced with Promise)
*/
export function microtask<T>(fn: (...arg: [T?]) => void, thisArg?: object): (arg?: T) => void {
const args: T[] = [];
let args: T[] = [];
return function microtaskFn(arg: T): void {
args.push(arg);
if ((microtaskFn as any).request) return;
(microtaskFn as any).request = Promise.resolve().then(() => {
delete (microtaskFn as any).request;
fn.call(thisArg || this, args);
args = [];
});
};
}
12 changes: 12 additions & 0 deletions src/modules/esl-utils/async/test/microtask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,16 @@ describe('sync/microtask', () => {
await Promise.resolve();
expect(fn).toBeCalledWith(expect.arrayContaining(params));
});
test('Decorated as microtask callback refreshes after decorated method call (leak protected)', async () => {
const fn = jest.fn();
const decorated = microtask(fn);
const params1 = [Symbol('Arg 1'), Symbol('Arg 2')];
for (const param of params1) decorated(param);
await Promise.resolve();

const params2 = [Symbol('Arg 3'), Symbol('Arg 4')];
for (const param of params2) decorated(param);
await Promise.resolve();
expect(fn).lastCalledWith(params2);
});
});
32 changes: 18 additions & 14 deletions src/modules/esl-utils/dom/scroll/parent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,36 @@ import {isElement, getNodeName, getParentNode} from '../api';
/**
* Get the list of all scroll parents, up the list of ancestors until we get to the top window object.
* @param element - element for which you want to get the list of all scroll parents
* @param list - array of elements to concatenate with the list of all scroll parents of element (optional)
* @param root - element which element considered a final scrollable parent target (optional, defaults to element.ownerDocument?.body)
*/
export function getListScrollParents(element: Element, list: Element[] = []): Element[] {
const scrollParent = getScrollParent(element);
const isBody = scrollParent === element.ownerDocument?.body;
const target = isBody
? isScrollable(scrollParent) ? scrollParent : []
: scrollParent;

const updatedList = list.concat(target);
return isBody
? updatedList
: updatedList.concat(getListScrollParents(getParentNode(scrollParent) as Element));
export function getListScrollParents(element: Element, root?: Element): Element[] {
const limitNode = root || element.ownerDocument?.body;
const scrollParent = getScrollParent(element, limitNode);
if (!scrollParent) return [];
const isScrollableTarget = scrollParent === limitNode;
if (isScrollableTarget) return isScrollable(scrollParent) ? [scrollParent] : [];
return [scrollParent].concat(getListScrollParents(getParentNode(scrollParent) as Element, limitNode));
}

/**
* Get the scroll parent of the specified element in the DOM tree.
* @param node - element for which to get the scroll parent
* @param root - element which element considered a final scrollable parent
*/
export function getScrollParent(node: Element, root: Element): Element | undefined;
/**
* Get the scroll parent of the specified element in the DOM tree.
* @param node - element for which to get the scroll parent
*/
export function getScrollParent(node: Element): Element {
export function getScrollParent(node: Element): Element;
export function getScrollParent(node: Element, root?: Element): Element | undefined {
if (['html', 'body', '#document'].indexOf(getNodeName(node)) >= 0) {
return node.ownerDocument?.body as Element;
}

if (isElement(node) && isScrollable(node)) return node;
return getScrollParent(getParentNode(node) as Element);
if (node === root) return;
return getScrollParent(getParentNode(node) as Element, root!);
}

/**
Expand Down
36 changes: 36 additions & 0 deletions src/modules/esl-utils/dom/scroll/test/parent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,27 @@ describe('Function getScrollParent', () => {
expect(getScrollParent(thirdLevelChild)).toEqual(target);
});
});

describe('Limit search to top target element', () => {
const firstLevelChild = document.createElement('div');
target.appendChild(firstLevelChild);

firstLevelChild.style.overflow = 'auto';

beforeAll(() => target.style.overflow = '');

test('should detect first scrollable parent element', () => {
expect(getScrollParent(firstLevelChild, target)).toEqual(firstLevelChild);
});

test('should accept body element as top target element', () => {
expect(getScrollParent(target, $body)).toEqual($body);
});

test('should return undefined if any scrollable parents found', () => {
expect(getScrollParent(target, target)).toEqual(undefined);
});
});
});

describe('Function getListScrollParents', () => {
Expand Down Expand Up @@ -120,4 +141,19 @@ describe('Function getListScrollParents', () => {

expect(getListScrollParents(thirdLevelChild)).toEqual([thirdLevelChild, firstLevelChild, $body]);
});

test('target should only detect scrollable elements up to top target element', () => {
const firstLevelChild = document.createElement('div');
const secondLevelChild = document.createElement('div');
const thirdLevelChild = document.createElement('div');

firstLevelChild.style.overflow = 'auto';
thirdLevelChild.style.overflow = 'auto';

secondLevelChild.appendChild(thirdLevelChild);
target.appendChild(firstLevelChild);
firstLevelChild.appendChild(secondLevelChild);

expect(getListScrollParents(thirdLevelChild, secondLevelChild)).toEqual([thirdLevelChild]);
});
});
16 changes: 15 additions & 1 deletion src/modules/esl-utils/dom/scroll/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {getScrollParent} from './parent';
import {getListScrollParents, getScrollParent} from './parent';

const $html = document.documentElement;

Expand Down Expand Up @@ -82,3 +82,17 @@ export function unlockScroll(target: Element = $html, options: ScrollLockOptions
scrollable.removeAttribute('esl-scroll-lock');
if (options.recursive && scrollable.parentElement) unlockScroll(scrollable.parentElement, options);
}

export interface ElementScrollOffset {
element: Element;
top: number;
left: number;
}

export function isOffsetChanged(offsets: ElementScrollOffset[]): boolean {
return offsets.some((element) => element.element.scrollTop !== element.top || element.element.scrollLeft !== element.left);
}

export function getParentScrollOffsets($el: Element, $topContainer: Element): ElementScrollOffset[] {
return getListScrollParents($el, $topContainer).map((el) => ({element: el, top: el.scrollTop, left: el.scrollLeft}));
}

0 comments on commit 19b7c18

Please sign in to comment.