Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(esl-event-listener): block events if scroll was detected #2098

Merged
merged 6 commits into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
ala-n marked this conversation as resolved.
Show resolved Hide resolved
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
12 changes: 12 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 (false by default) */
ala-n marked this conversation as resolved.
Show resolved Hide resolved
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,17 @@ export class ESLWheelTarget extends SyntheticEventTarget {
/** Handles wheel events */
@bind
protected _onWheel(event: WheelEvent): void {
if (this.config.skipOnScroll) this.scrollData = this.scrollData.concat(getParentScrollOffsets(event.target as Element, this.target));
ala-n marked this conversation as resolved.
Show resolved Hide resolved
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
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}));
}
Loading