From 4987652d9d78c245a91e890abc36bacd6d60f8a4 Mon Sep 17 00:00:00 2001 From: Rene Date: Thu, 12 Nov 2020 23:55:06 +0100 Subject: [PATCH] add dom events into support lib --- .../observers/SizeObserver.ts | 71 +++++---- .../src/support/dom/dimensions.ts | 5 +- .../src/support/dom/events.ts | 91 ++++++++++- .../src/support/dom/index.ts | 11 +- .../src/support/dom/offset.ts | 6 +- .../src/support/utils/array.ts | 8 + .../jsdom/support/dom/dimensions.test.ts | 2 +- .../tests/jsdom/support/dom/events.test.ts | 141 ++++++++++++++++++ .../tests/jsdom/support/utils/arrays.test.ts | 12 +- 9 files changed, 299 insertions(+), 48 deletions(-) create mode 100644 packages/overlayscrollbars/tests/jsdom/support/dom/events.test.ts diff --git a/packages/overlayscrollbars/src/overlayscrollbars/observers/SizeObserver.ts b/packages/overlayscrollbars/src/overlayscrollbars/observers/SizeObserver.ts index 888cf17..a93b377 100644 --- a/packages/overlayscrollbars/src/overlayscrollbars/observers/SizeObserver.ts +++ b/packages/overlayscrollbars/src/overlayscrollbars/observers/SizeObserver.ts @@ -1,4 +1,18 @@ -import { createDOM, style, appendChildren, offsetSize, scrollLeft, scrollTop, jsAPI, each, prependChildren, removeElements } from 'support'; +import { + createDOM, + style, + appendChildren, + offsetSize, + scrollLeft, + scrollTop, + jsAPI, + runEach, + prependChildren, + removeElements, + on, + preventDefault, + stopPropagation, +} from 'support'; import { Environment } from 'environment'; const animationStartEventName = 'animationstart'; @@ -14,10 +28,8 @@ const rAF = requestAnimationFrame; const getDirection = (elm: HTMLElement) => style(elm, 'direction'); // TODO: -// 1. handling for event listeners (animationStartEventName.split(' ')) -// 2. return not just element but also destruction function -// 3. shorthand handling for preventDefault & stopPropagation etc. -// 5. MAYBE add comparison function to offsetSize etc. +// 1. MAYBE add comparison function to offsetSize etc. +// 2. remove supportPassiveListeners & resizeobserver from environment export const createSizeObserver = ( target: HTMLElement, @@ -37,6 +49,7 @@ export const createSizeObserver = ( } onSizeChangedCallback(dir === true); }; + const offListeners: (() => void)[] = []; let appearCallback: (...args: any) => any = onSizeChangedCallbackProxy; if (ResizeObserverConstructor) { @@ -81,14 +94,14 @@ export const createSizeObserver = ( reset(); if (scrollEvent) { - scrollEvent.preventDefault(); - scrollEvent.stopPropagation(); + preventDefault(scrollEvent); + stopPropagation(scrollEvent); } return false; }; - expandElement.addEventListener(scrollEventName, onScroll); - shrinkElement.addEventListener(scrollEventName, onScroll); + offListeners.push(on(expandElement, scrollEventName, onScroll)); + offListeners.push(on(shrinkElement, scrollEventName, onScroll)); // lets assume that the divs will never be that large and a constant value is enough style(expandElementChild, { @@ -99,36 +112,34 @@ export const createSizeObserver = ( appearCallback = onScroll; } - each(animationStartEventName.split(' '), (eventName) => { - sizeObserver.addEventListener(eventName, () => { - appearCallback(); - }); - }); - if (direction) { let dirCache: string | undefined; - sizeObserver.addEventListener('scroll', (event: Event) => { - const dir = getDirection(sizeObserver); - const changed = dir !== dirCache; - if (changed) { - if (dir === 'rtl') { - style(listenerElement, { left: 'auto', right: 0 }); - } else { - style(listenerElement, { left: 0, right: 'auto' }); + offListeners.push( + on(sizeObserver, scrollEventName, (event: Event) => { + const dir = getDirection(sizeObserver); + const changed = dir !== dirCache; + if (changed) { + if (dir === 'rtl') { + style(listenerElement, { left: 'auto', right: 0 }); + } else { + style(listenerElement, { left: 0, right: 'auto' }); + } + dirCache = dir; + onSizeChangedCallbackProxy(true); } - dirCache = dir; - onSizeChangedCallbackProxy(true); - } - event.preventDefault(); - event.stopPropagation(); - return false; - }); + preventDefault(event); + stopPropagation(event); + return false; + }) + ); } + offListeners.push(on(sizeObserver, animationStartEventName, appearCallback)); prependChildren(target, sizeObserver); return () => { + runEach(offListeners); removeElements(sizeObserver); }; }; diff --git a/packages/overlayscrollbars/src/support/dom/dimensions.ts b/packages/overlayscrollbars/src/support/dom/dimensions.ts index 531937b..5edb97a 100644 --- a/packages/overlayscrollbars/src/support/dom/dimensions.ts +++ b/packages/overlayscrollbars/src/support/dom/dimensions.ts @@ -1,4 +1,7 @@ -import { WH } from 'support/dom'; +export interface WH { + w: T; + h: T; +} const elementHasDimensions = (elm: HTMLElement): boolean => !!(elm.offsetWidth || elm.offsetHeight || elm.getClientRects().length); const zeroObj: WH = { diff --git a/packages/overlayscrollbars/src/support/dom/events.ts b/packages/overlayscrollbars/src/support/dom/events.ts index 1e5578f..8f0e9ab 100644 --- a/packages/overlayscrollbars/src/support/dom/events.ts +++ b/packages/overlayscrollbars/src/support/dom/events.ts @@ -1,6 +1,89 @@ -export const on = (target: EventTarget, type: string, listener: EventListenerOrEventListenerObject | null, options: AddEventListenerOptions): void => { +import { each, runEach } from 'support/utils/array'; - +let passiveEventsSupport: boolean; +const supportPassiveEvents = (): boolean => { + if (passiveEventsSupport === undefined) { + passiveEventsSupport = false; + try { + /* eslint-disable */ + // @ts-ignore + window.addEventListener( + 'test', + null, + Object.defineProperty({}, 'passive', { + get: function () { + passiveEventsSupport = true; + }, + }) + ); + /* eslint-enable */ + } catch (e) {} + } + return passiveEventsSupport; +}; - target.addEventListener(type, listener, options); -}; \ No newline at end of file +export interface OnOptions { + _capture?: boolean; + _passive?: boolean; + _once?: boolean; +} + +/** + * Removes the passed event listener for the passed events with the passed options. + * @param target The element from which the listener shall be removed. + * @param eventNames The eventsnames for which the listener shall be removed. + * @param listener The listener which shall be removed. + * @param capture The options of the removed listener. + */ +export const off = (target: EventTarget, eventNames: string, listener: EventListener, capture?: boolean): void => { + each(eventNames.split(' '), (eventName) => { + target.removeEventListener(eventName, listener, capture); + }); +}; + +/** + * Adds the passed event listener for the passed eventnames with the passed options. + * @param target The element to which the listener shall be added. + * @param eventNames The eventsnames for which the listener shall be called. + * @param listener The listener which is called on the eventnames. + * @param options The options of the added listener. + */ +export const on = (target: EventTarget, eventNames: string, listener: EventListener, options?: OnOptions): (() => void) => { + const doSupportPassiveEvents = supportPassiveEvents(); + const passive = (doSupportPassiveEvents && options && options._passive) || false; + const capture = (options && options._capture) || false; + const once = (options && options._once) || false; + const offListeners: (() => void)[] = []; + const nativeOptions: AddEventListenerOptions | boolean = doSupportPassiveEvents + ? { + passive, + capture, + } + : capture; + + each(eventNames.split(' '), (eventName) => { + const finalListener = once + ? (evt: Event) => { + target.removeEventListener(eventName, finalListener, capture); + listener && listener(evt); + } + : listener; + + offListeners.push(off.bind(null, target, eventName, finalListener, capture)); + target.addEventListener(eventName, finalListener, nativeOptions); + }); + + return runEach.bind(0, offListeners); +}; + +/** + * Shorthand for the stopPropagation event Method. + * @param evt The event of which the stopPropagation method shall be called. + */ +export const stopPropagation = (evt: Event) => evt.stopPropagation(); + +/** + * Shorthand for the preventDefault event Method. + * @param evt The event of which the preventDefault method shall be called. + */ +export const preventDefault = (evt: Event) => evt.preventDefault(); diff --git a/packages/overlayscrollbars/src/support/dom/index.ts b/packages/overlayscrollbars/src/support/dom/index.ts index 29daf99..1ca5c4b 100644 --- a/packages/overlayscrollbars/src/support/dom/index.ts +++ b/packages/overlayscrollbars/src/support/dom/index.ts @@ -2,17 +2,8 @@ export * from 'support/dom/attribute'; export * from 'support/dom/class'; export * from 'support/dom/create'; export * from 'support/dom/dimensions'; +export * from 'support/dom/events'; export * from 'support/dom/style'; export * from 'support/dom/manipulation'; export * from 'support/dom/offset'; export * from 'support/dom/traversal'; - -export interface XY { - x: T; - y: T; -} - -export interface WH { - w: T; - h: T; -} diff --git a/packages/overlayscrollbars/src/support/dom/offset.ts b/packages/overlayscrollbars/src/support/dom/offset.ts index 854c49d..783cfa6 100644 --- a/packages/overlayscrollbars/src/support/dom/offset.ts +++ b/packages/overlayscrollbars/src/support/dom/offset.ts @@ -1,5 +1,9 @@ import { getBoundingClientRect } from 'support/dom/dimensions'; -import { XY } from 'support/dom'; + +export interface XY { + x: T; + y: T; +} const zeroObj: XY = { x: 0, diff --git a/packages/overlayscrollbars/src/support/utils/array.ts b/packages/overlayscrollbars/src/support/utils/array.ts index eeab397..9775a0e 100644 --- a/packages/overlayscrollbars/src/support/utils/array.ts +++ b/packages/overlayscrollbars/src/support/utils/array.ts @@ -64,3 +64,11 @@ export const from = (arr: ArrayLike) => { }); return result; }; + +/** + * Calls all functions in the passed array of functions. + * @param arr The array filled with function which shall be called. + */ +export const runEach = (arr: Array<((...args: any) => any | any[]) | null | undefined>): void => { + each(arr, (fn) => fn && fn()); +}; diff --git a/packages/overlayscrollbars/tests/jsdom/support/dom/dimensions.test.ts b/packages/overlayscrollbars/tests/jsdom/support/dom/dimensions.test.ts index 7344eec..7871a74 100644 --- a/packages/overlayscrollbars/tests/jsdom/support/dom/dimensions.test.ts +++ b/packages/overlayscrollbars/tests/jsdom/support/dom/dimensions.test.ts @@ -49,7 +49,7 @@ describe('dom dimensions', () => { describe('hasDimensions', () => { test('DOM element', () => { const result = hasDimensions(document.body); - expect(result).toBe(true); + expect(result).toBe(false); }); test('generated element', () => { diff --git a/packages/overlayscrollbars/tests/jsdom/support/dom/events.test.ts b/packages/overlayscrollbars/tests/jsdom/support/dom/events.test.ts new file mode 100644 index 0000000..afa99c7 --- /dev/null +++ b/packages/overlayscrollbars/tests/jsdom/support/dom/events.test.ts @@ -0,0 +1,141 @@ +import { off, preventDefault, stopPropagation, OnOptions } from 'support/dom/events'; + +const testElm = document.body; +const mockEventListener = (passive?: boolean, add?: (...args: any) => any, remove?: (...args: any) => any) => { + const originalAdd = testElm.addEventListener; + const originalRemove = testElm.removeEventListener; + const originalWindow = window.addEventListener; + + if (add) { + testElm.addEventListener = add; + } + if (remove) { + testElm.removeEventListener = remove; + } + if (!passive) { + window.addEventListener = () => {}; + } + return () => { + testElm.addEventListener = originalAdd; + testElm.removeEventListener = originalRemove; + window.addEventListener = originalWindow; + }; +}; + +describe('dom events', () => { + describe('on', () => { + let eventsModule: any; + const onOffTest = (passive: boolean, eventNames: string, options?: OnOptions) => { + const once = options?._once; + const expectObjAdd = passive + ? { + passive: (options && options._passive) || false, + capture: (options && options._capture) || false, + } + : options?._capture || false; + const expectObjRemove = options?._capture || false; + const onceListeners: (() => void)[] = []; + const mockFnRemove = jest.fn(); + const mockFnAdd = jest.fn(); + const eventListener = () => {}; + + const revert = mockEventListener( + passive, + (name: string, listener: any, ...args: any) => { + if (once) { + const onceRemoveMockFn = jest.fn(); + const revertOnceMock = mockEventListener(passive, jest.fn(), onceRemoveMockFn); + + listener(); + expect(onceRemoveMockFn).toHaveBeenCalledWith(name, listener, expectObjRemove); + + revertOnceMock(); + onceListeners.push(listener); + } + mockFnAdd(name, listener, ...args); + }, + mockFnRemove + ); + const removeFn = eventsModule.on(testElm, eventNames, eventListener, options); + + eventNames.split(' ').forEach((eventName, index) => { + expect(mockFnAdd).toHaveBeenCalledWith(eventName, once ? onceListeners[index] : eventListener, expectObjAdd); + }); + + removeFn(); + eventNames.split(' ').forEach((eventName, index) => { + expect(mockFnRemove).toHaveBeenCalledWith(eventName, once ? onceListeners[index] : eventListener, expectObjRemove); + }); + + revert(); + }; + + beforeEach(() => { + return import('support/dom/events').then((module) => { + eventsModule = module; + jest.resetModules(); + }); + }); + + [true, false].forEach((passiveSupport) => { + describe(`passive event listeners support: ${passiveSupport}`, () => { + ['testEventName', 'testEventName testEventName2 testEventName3'].forEach((eventNames) => { + const title = eventNames.split(' ').length === 1 ? 'signle' : 'multiple'; + test(title, () => { + onOffTest(passiveSupport, eventNames); + onOffTest(passiveSupport, eventNames, { _capture: true }); + onOffTest(passiveSupport, eventNames, { _capture: false }); + onOffTest(passiveSupport, eventNames, { _capture: true, _passive: true }); + onOffTest(passiveSupport, eventNames, { _capture: false, _passive: false }); + onOffTest(passiveSupport, eventNames, { _capture: true, _passive: false }); + onOffTest(passiveSupport, eventNames, { _capture: false, _passive: true }); + + onOffTest(passiveSupport, eventNames, { _once: true }); + onOffTest(passiveSupport, eventNames, { _once: false }); + }); + }); + }); + }); + }); + + describe('off', () => { + const offTest = (eventNames: string, options?: boolean) => { + const mockFnRemove = jest.fn(); + const listener = () => {}; + const revert = mockEventListener(false, jest.fn(), mockFnRemove); + + off(testElm, eventNames, listener, options); + eventNames.split(' ').forEach((eventName) => { + expect(mockFnRemove).toHaveBeenCalledWith(eventName, listener, options); + }); + + revert(); + }; + + ['testEventName', 'testEventName testEventName2 testEventName3'].forEach((eventNames) => { + const title = eventNames.split(' ').length === 1 ? 'signle' : 'multiple'; + test(title, () => { + offTest(eventNames, false); + offTest(eventNames, true); + }); + }); + }); + + test('preventDefault', () => { + // @ts-ignore + const fakeEvent: Event = { + preventDefault: jest.fn(), + }; + preventDefault(fakeEvent); + expect(fakeEvent.preventDefault).toHaveBeenCalled(); + }); + + test('stopPropagation', () => { + // @ts-ignore + const fakeEvent: Event = { + stopPropagation: jest.fn(), + }; + stopPropagation(fakeEvent); + expect(fakeEvent.stopPropagation).toHaveBeenCalled(); + }); +}); diff --git a/packages/overlayscrollbars/tests/jsdom/support/utils/arrays.test.ts b/packages/overlayscrollbars/tests/jsdom/support/utils/arrays.test.ts index d3b9bec..00cd8f2 100644 --- a/packages/overlayscrollbars/tests/jsdom/support/utils/arrays.test.ts +++ b/packages/overlayscrollbars/tests/jsdom/support/utils/arrays.test.ts @@ -1,4 +1,4 @@ -import { each, from, indexOf } from 'support/utils/array'; +import { each, from, indexOf, runEach } from 'support/utils/array'; describe('array utilities', () => { describe('each', () => { @@ -196,4 +196,14 @@ describe('array utilities', () => { const idx = indexOf([1, 2, 3], 2); expect(idx).toBe(1); }); + + test('runEach', () => { + const arr = [jest.fn(), null, jest.fn(), undefined, jest.fn()]; + runEach(arr); + arr.forEach((fn) => { + if (fn) { + expect(fn).toHaveBeenCalled(); + } + }); + }); });