diff --git a/src/modules/esl-event-listener/core/targets/swipe.target.ts b/src/modules/esl-event-listener/core/targets/swipe.target.ts index f8396c3a6..1e2aa493a 100644 --- a/src/modules/esl-event-listener/core/targets/swipe.target.ts +++ b/src/modules/esl-event-listener/core/targets/swipe.target.ts @@ -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'; @@ -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'; @@ -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 */ @@ -28,6 +32,7 @@ export interface ESLSwipeGestureSetting { */ export class ESLSwipeGestureTarget extends SyntheticEventTarget { protected static defaultConfig: Required = { + skipOnScroll: true, threshold: '20px', timeout: 500 }; @@ -49,6 +54,7 @@ export class ESLSwipeGestureTarget extends SyntheticEventTarget { protected readonly config: Required; protected startEvent: PointerEvent; + protected startEventOffset: ElementScrollOffset[]; protected constructor( protected readonly target: Element, @@ -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, @@ -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 diff --git a/src/modules/esl-event-listener/core/targets/wheel.target.ts b/src/modules/esl-event-listener/core/targets/wheel.target.ts index 468555f3f..5820d82f6 100644 --- a/src/modules/esl-event-listener/core/targets/wheel.target.ts +++ b/src/modules/esl-event-listener/core/targets/wheel.target.ts @@ -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'; @@ -9,6 +10,7 @@ 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}; @@ -16,6 +18,8 @@ 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 */ @@ -27,12 +31,15 @@ export interface ESLWheelTargetSetting { */ export class ESLWheelTarget extends SyntheticEventTarget { protected static defaultConfig: Required = { + skipOnScroll: true, distance: 400, timeout: 100 }; protected readonly config: Required; + protected scrollData: ElementScrollOffset[] = []; + /** Function for aggregating wheel events into array of events */ protected aggregateWheel: (event: WheelEvent) => void; @@ -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); } diff --git a/src/modules/esl-utils/dom/scroll/parent.ts b/src/modules/esl-utils/dom/scroll/parent.ts index 7010fcf2f..0d97a9829 100644 --- a/src/modules/esl-utils/dom/scroll/parent.ts +++ b/src/modules/esl-utils/dom/scroll/parent.ts @@ -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!); } /** diff --git a/src/modules/esl-utils/dom/scroll/test/parent.test.ts b/src/modules/esl-utils/dom/scroll/test/parent.test.ts index 78c81edb2..c87ca6bc5 100644 --- a/src/modules/esl-utils/dom/scroll/test/parent.test.ts +++ b/src/modules/esl-utils/dom/scroll/test/parent.test.ts @@ -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', () => { @@ -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]); + }); }); diff --git a/src/modules/esl-utils/dom/scroll/utils.ts b/src/modules/esl-utils/dom/scroll/utils.ts index 35401b5d5..be31ae2c8 100644 --- a/src/modules/esl-utils/dom/scroll/utils.ts +++ b/src/modules/esl-utils/dom/scroll/utils.ts @@ -1,4 +1,4 @@ -import {getScrollParent} from './parent'; +import {getListScrollParents, getScrollParent} from './parent'; const $html = document.documentElement; @@ -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})); +}