Repository: exadel-inc/esl
Pull request #1986 (2023-10-16 14:59:49)

feat(esl-event-listener): create ESLIntersectionTarget adapter utility for managing `IntersectionObserver` subscriptions trough event listeners
Closes: #1498


Patch:
diff --git a/src/modules/esl-event-listener/README.md b/src/modules/esl-event-listener/README.md
index 6dfb7f33e..deff43ed7 100644
--- a/src/modules/esl-event-listener/README.md
+++ b/src/modules/esl-event-listener/README.md
@@ -635,6 +635,29 @@ ESLSwipeGestureTarget.subscribe(host, {
 }, onSwipe);
 ```
 
+### ⚡ `ESLIntersectionTarget.for` <i class="badge badge-sup badge-success">new</i>
+
+`ESLIntersectionTarget.for` is a way to listen for intersections using Intersection Observer API but in an EventTarget
+way.
+
+`ESLIntersectionTarget.for` creates a synthetic target that produces `intersection` events. It detects intersections by
+creating `IntersectionObserver` instance, created using passed `settings: IntersectionObserverInit`.
+
+Note: `ESLIntersectionTarget` does not share `IntersectionObserver` instances unlike caching capabilities of adapters 
+mentioned above. 
+
+```typescript
+ESLIntersectionTarget.for(el: Element | Element[], settings?: IntersectionObserverInit): ESLIntersectionTarget;
+```
+
+**Parameters**:
+- `el` - `Element` or `Element[]` to listen for intersection events on;
+- `settings` - optional settings (`ESLIntersectionSetting`)
+
+Event API:
+Throws `ESLIntersectionEvent` that implements `IntersectionObserverEntry` original interface.
+
+
 ---
 
 ## <a name="embedded-behavior-of-eslbaseelement-eslmixinelement">Embedded behavior of `ESLBaseElement` / `ESLMixinElement`</a>
diff --git a/src/modules/esl-event-listener/core.ts b/src/modules/esl-event-listener/core.ts
index b21c892c6..ac933f1d8 100644
--- a/src/modules/esl-event-listener/core.ts
+++ b/src/modules/esl-event-listener/core.ts
@@ -3,3 +3,4 @@ export * from './core/api';
 export * from './core/targets/resize.target';
 export * from './core/targets/decorated.target';
 export * from './core/targets/swipe.target';
+export * from './core/targets/intersection.target';
diff --git a/src/modules/esl-event-listener/core/targets/intersection.event.ts b/src/modules/esl-event-listener/core/targets/intersection.event.ts
new file mode 100644
index 000000000..771880f95
--- /dev/null
+++ b/src/modules/esl-event-listener/core/targets/intersection.event.ts
@@ -0,0 +1,44 @@
+import {overrideEvent} from '../../../esl-utils/dom/events/misc';
+
+/**
+ * An event that is fired when an element intersects with the device viewport by the {@link ESLIntersectionTarget}.
+ * Based on the {@link IntersectionObserverEntry} object.
+ */
+export class ESLIntersectionEvent extends Event implements IntersectionObserverEntry {
+  public static readonly type = 'intersects';
+
+  /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/IntersectionObserverEntry/target) */
+  public override readonly target: Element;
+  /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/IntersectionObserverEntry/boundingClientRect) */
+  public readonly boundingClientRect: DOMRectReadOnly;
+  /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/IntersectionObserverEntry/intersectionRatio) */
+  public readonly intersectionRatio: number;
+  /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/IntersectionObserverEntry/intersectionRect) */
+  public readonly intersectionRect: DOMRectReadOnly;
+  /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/IntersectionObserverEntry/isIntersecting) */
+  public readonly isIntersecting: boolean;
+  /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/IntersectionObserverEntry/rootBounds) */
+  public readonly rootBounds: DOMRectReadOnly | null;
+  /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/IntersectionObserverEntry/time) */
+  public readonly time: DOMHighResTimeStamp;
+
+  protected constructor(target: Element) {
+    super(ESLIntersectionEvent.type, {bubbles: false, cancelable: false});
+    overrideEvent(this, 'target', target);
+  }
+
+  /** Creates {@link ESLIntersectionEvent} from {@link ESLIntersectionEvent} */
+  public static fromEntry(entry: IntersectionObserverEntry): ESLIntersectionEvent {
+    const event = new ESLIntersectionEvent(entry.target);
+    const {
+      boundingClientRect,
+      intersectionRatio,
+      intersectionRect,
+      isIntersecting,
+      rootBounds,
+      time
+    } = entry;
+    Object.assign(event, {boundingClientRect, intersectionRatio, intersectionRect, isIntersecting, rootBounds, time});
+    return event;
+  }
+}
diff --git a/src/modules/esl-event-listener/core/targets/intersection.target.ts b/src/modules/esl-event-listener/core/targets/intersection.target.ts
new file mode 100644
index 000000000..17d1fc147
--- /dev/null
+++ b/src/modules/esl-event-listener/core/targets/intersection.target.ts
@@ -0,0 +1,81 @@
+import {SyntheticEventTarget} from '../../../esl-utils/dom/events/target';
+import {isElement} from '../../../esl-utils/dom/api';
+import {wrap} from '../../../esl-utils/misc/array';
+
+import {ESLIntersectionEvent} from './intersection.event';
+
+export {ESLIntersectionEvent};
+
+/**
+ * {@link EventTarget} adapter class for {@link IntersectionObserver}
+ * Fires {@link ESLIntersectionEvent} on the observed {@link Element} intersection change
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
+ *
+ * Note: does not support sharing, creates new {@link IntersectionObserver} instance for each {@link ESLIntersectionTarget} instance
+ */
+export class ESLIntersectionTarget extends SyntheticEventTarget {
+  /** Default {@link IntersectionObserverInit} options */
+  public static readonly DEFAULTS: IntersectionObserverInit = {threshold: [0.01]};
+
+  /** Creates {@link ESLIntersectionTarget} instance for the {@link Element}(s) */
+  public static for(targets: Element[] | Element, options?: IntersectionObserverInit): ESLIntersectionTarget;
+  public static for(
+    targets: Element[] | Element | null | undefined,
+    options: IntersectionObserverInit = {}
+  ): ESLIntersectionTarget | null {
+    const $targets = wrap(targets).filter(isElement);
+    const initOptions = {...ESLIntersectionTarget.DEFAULTS, ...options};
+    if ($targets.length) return new ESLIntersectionTarget($targets, initOptions);
+    // Error handling
+    console.warn('[ESL]: ESLIntersectionTarget can not observe %o', targets);
+    return null;
+  }
+
+  /** Internal {@link IntersectionObserver} instance */
+  protected readonly observer$$: IntersectionObserver;
+
+  /**
+   * Creates {@link ESLResizeObserverTarget} for the {@link Element}.
+   * Note the {@link ESLResizeObserverTarget} instances are singletons relatively to the {@link Element}
+   */
+  protected constructor(
+    /** Observed {@link Element | Element[]} of the {@link ESLIntersectionTarget} instance */
+    public readonly targets: Element[],
+    public readonly options: IntersectionObserverInit
+  ) {
+    super();
+    this.observer$$ = new IntersectionObserver(this.handleChange.bind(this), options);
+  }
+
+  /** Internal method to handle {@link IntersectionObserver} entry change */
+  protected handleChange(entries: IntersectionObserverEntry[]): void {
+    entries.forEach(
+      (entry) => this.dispatchEvent(ESLIntersectionEvent.fromEntry(entry))
+    );
+  }
+
+  /** Subscribes to the observed target {@link Element} changes */
+  public override addEventListener(callback: EventListener): void;
+  public override addEventListener(event: 'intersect', callback: EventListener): void;
+  public override addEventListener(event: any, callback: EventListener = event): void {
+    if (typeof event !== 'string') event = ESLIntersectionEvent.type;
+    if (event !== ESLIntersectionEvent.type) {
+      console.warn(`[ESL]: ESLIntersectionTarget does not support '${event}' type`);
+      return;
+    }
+    super.addEventListener(ESLIntersectionEvent.type, callback);
+    if (this.getEventListeners(ESLIntersectionEvent.type).length > 1) return;
+    this.targets.forEach((target: Element) => this.observer$$.observe(target));
+  }
+
+  /** Unsubscribes from the observed target {@link Element} changes */
+  public override removeEventListener(callback: EventListener): void;
+  public override removeEventListener(event: 'resize', callback: EventListener): void;
+  public override removeEventListener(event: any, callback: EventListener = event): void {
+    if (typeof event !== 'string') event = ESLIntersectionEvent.type;
+    if (event !== ESLIntersectionEvent.type) return;
+    super.removeEventListener(event, callback);
+    if (this.hasEventListener(ESLIntersectionEvent.type)) return;
+    this.observer$$.disconnect();
+  }
+}


Test patch:
diff --git a/src/modules/esl-event-listener/test/targets/intersection.test.ts b/src/modules/esl-event-listener/test/targets/intersection.test.ts
new file mode 100644
index 000000000..a3467bf9d
--- /dev/null
+++ b/src/modules/esl-event-listener/test/targets/intersection.test.ts
@@ -0,0 +1,121 @@
+import {ESLIntersectionEvent, ESLIntersectionTarget} from '../../core/targets/intersection.target';
+import {IntersectionObserverMock} from '../../../esl-utils/test/intersectionObserver.mock';
+
+describe('ESLIntersectionTarget proxy', () => {
+  beforeAll(() => IntersectionObserverMock.mock());
+
+  describe('ESLIntersectionTarget do not throws error on incorrect input (silent processing)', () => {
+    const consoleSpy = jest.spyOn(console, 'warn');
+    beforeEach(() => consoleSpy.mockReset().mockImplementation(() => void 0));
+    afterAll(() => consoleSpy.mockRestore());
+
+    test('ESLResizeObserverTarget.for(undefined) returns null without error', () => {
+      expect(ESLIntersectionTarget.for(undefined as any)).toBeNull();
+      expect(consoleSpy).toBeCalled();
+    });
+
+    test('ESLResizeObserverTarget.for(null) returns null without error', () => {
+      expect(ESLIntersectionTarget.for(null as any)).toBeNull();
+      expect(consoleSpy).toBeCalled();
+    });
+
+    test('ESLResizeObserverTarget.for(123) returns null without error', () => {
+      expect(ESLIntersectionTarget.for(123 as any)).toBeNull();
+      expect(consoleSpy).toBeCalled();
+    });
+
+    test('ESLResizeObserverTarget.for({}) returns null without error', () => {
+      expect(ESLIntersectionTarget.for({} as any)).toBeNull();
+      expect(consoleSpy).toBeCalled();
+    });
+
+    test('ESLResizeObserverTarget.for({}) returns null without error', () => {
+      expect(ESLIntersectionTarget.for([1, 2, 3] as any)).toBeNull();
+      expect(consoleSpy).toBeCalled();
+    });
+  });
+
+  describe('ESLIntersectionTarget wraps IntersectionObserver', () => {
+    test('ESLIntersectionTarget.for() creates IntersectionObserver instance', () => {
+      const target = ESLIntersectionTarget.for(document.createElement('div'));
+      expect(target).not.toBeNull();
+      expect(IntersectionObserver).toBeCalled();
+    });
+
+    test('ESLIntersectionTarget.for() creates IntersectionObserver instance with default options', () => {
+      const target = ESLIntersectionTarget.for(document.createElement('div'));
+      expect(target).not.toBeNull();
+      expect(IntersectionObserver).toBeCalledWith(expect.any(Function), ESLIntersectionTarget.DEFAULTS);
+    });
+
+    test('ESLIntersectionTarget.for() creates IntersectionObserver instance with custom options', () => {
+      const options = {rootMargin: '10px'};
+      const target = ESLIntersectionTarget.for(document.createElement('div'), options);
+      expect(target).not.toBeNull();
+      expect(IntersectionObserver).toBeCalledWith(expect.any(Function), {
+        ...ESLIntersectionTarget.DEFAULTS,
+        ...options
+      });
+    });
+  });
+
+  describe('ESLIntersectionTarget creates IntersectionObserver subscription', () => {
+    test('ESLIntersectionTarget subscribes to IntersectionObserver on first subscription', () => {
+      const target = ESLIntersectionTarget.for(document.createElement('div'));
+
+      const listener = jest.fn();
+      target.addEventListener(listener);
+      expect(IntersectionObserverMock.lastInstance.observe).toBeCalled();
+    });
+
+    test('ESLIntersectionTarget does not subscribe to IntersectionObserver on second subscription', () => {
+      const target = ESLIntersectionTarget.for(document.createElement('div'));
+
+      const listener = jest.fn();
+      target.addEventListener(listener);
+      const listener2 = jest.fn();
+      target.addEventListener(listener2);
+      expect(IntersectionObserverMock.lastInstance.observe).toBeCalledTimes(1);
+    });
+
+    test('ESLIntersectionTarget disconnect IntersectionObserver on last unsubscription', () => {
+      const target = ESLIntersectionTarget.for(document.createElement('div'));
+
+      const listener = jest.fn();
+      target.addEventListener(listener);
+      const listener2 = jest.fn();
+      target.addEventListener(listener2);
+      target.removeEventListener(listener);
+      expect(IntersectionObserverMock.lastInstance.disconnect).not.toBeCalled();
+      target.removeEventListener(listener2);
+      expect(IntersectionObserverMock.lastInstance.disconnect).toBeCalled();
+    });
+  });
+
+  describe('ESLIntersectionTarget dispatches events when IntersectionObserver fires', () => {
+    test('ESLIntersectionTarget dispatches events when IntersectionObserver fires', () => {
+      const $el = document.createElement('div');
+      const target = ESLIntersectionTarget.for($el);
+
+      const listener = jest.fn();
+      target.addEventListener(listener);
+      IntersectionObserverMock.trigger($el, {isIntersecting: true});
+      expect(listener).toBeCalled();
+    });
+
+    test('ESLIntersectionTarget dispatches ESLIntersectionEvent with IntersectionObserverEntry properties', () => {
+      const $el = document.createElement('div');
+      const target = ESLIntersectionTarget.for($el);
+
+      const listener = jest.fn();
+      target.addEventListener(listener);
+      const entry: IntersectionObserverEntry = IntersectionObserverMock.createEntry($el, {
+        isIntersecting: true,
+        intersectionRatio: 0.5
+      });
+      IntersectionObserverMock.trigger($el, entry);
+      expect(listener).lastCalledWith(expect.objectContaining({type: ESLIntersectionEvent.type}));
+      expect(listener).lastCalledWith(expect.objectContaining(entry));
+    });
+  });
+});
diff --git a/src/modules/esl-utils/test/intersectionObserver.mock.ts b/src/modules/esl-utils/test/intersectionObserver.mock.ts
index 95fab4ec9..680c9feb4 100644
--- a/src/modules/esl-utils/test/intersectionObserver.mock.ts
+++ b/src/modules/esl-utils/test/intersectionObserver.mock.ts
@@ -1,13 +1,31 @@
 import {ESLEventUtils} from '../../esl-event-listener/core/api';
-import {bind} from '../decorators/bind';
+import {wrap} from '../misc/array';
+import {Rect} from '../dom/rect';
 
 let original: typeof IntersectionObserver;
-export class IntersectionObserverMock implements IntersectionObserver {
+let lastMock: IntersectionObserverMock;
 
-  public constructor(public callback: IntersectionObserverCallback) {}
+export class RectMock extends Rect implements DOMRect {
+  public constructor();
+  public constructor(x: number, y: number, width: number, height: number);
+  public constructor(...args: number[]) {
+    if (args.length === 0) super(0, 0, 0, 0);
+    else super(...args);
+  }
+  public toJSON(): DOMRect {
+    return this;
+  }
+}
 
-  public root: Document | Element | null = null;
+export class IntersectionObserverMock implements IntersectionObserver {
+  public constructor(public callback: IntersectionObserverCallback) {
+    this._onObserve = this._onObserve.bind(this);
+    this.observe = jest.fn(this.observe);
+    this.unobserve = jest.fn(this.unobserve);
+    this.disconnect = jest.fn(this.disconnect);
+  }
 
+  public root: Document | Element | null = null;
   public rootMargin: string = '';
 
   public get thresholds(): number[] {
@@ -18,10 +36,11 @@ export class IntersectionObserverMock implements IntersectionObserver {
     return [];
   }
 
-  @bind
   private _onObserve(e: CustomEvent): void {
-    const {intersectionRatio, isIntersecting} = e.detail;
-    this.callback([{intersectionRatio, target: e.target, isIntersecting}] as IntersectionObserverEntry[], this);
+    const entries =  wrap(e.detail).map(
+      (entry) => IntersectionObserverMock.createEntry(e.target as Element, entry)
+    );
+    this.callback(entries, this);
   }
 
   public observe(element: Element): void {
@@ -32,17 +51,44 @@ export class IntersectionObserverMock implements IntersectionObserver {
     element.removeEventListener('intersection', this._onObserve);
   }
 
-  public disconnect = jest.fn();
+  public disconnect(): void {}
+
+  public static createEntry(
+    target: Element,
+    init: Partial<IntersectionObserverEntry>
+  ): IntersectionObserverEntry {
+    return {
+      // Defaults
+      target,
+      intersectionRect: new RectMock(),
+      rootBounds: new RectMock(),
+      boundingClientRect: new RectMock(),
+      isIntersecting: false,
+      intersectionRatio: 0,
+      time: Date.now(),
+      // Custom
+      ...init
+    };
+  }
 
-  public static trigger($el: Element, cfg: Partial<IntersectionObserverEntryInit>): void {
-    ESLEventUtils.dispatch($el, 'intersection', {detail: cfg});
+  public static trigger(
+    $el: Element,
+    detail: Partial<IntersectionObserverEntryInit> | Partial<IntersectionObserverEntryInit>[]
+  ): void {
+    ESLEventUtils.dispatch($el, 'intersection', {detail});
   }
 
   public static mock(): void {
     original = window.IntersectionObserver;
-    window.IntersectionObserver = jest.fn((cb) => new IntersectionObserverMock(cb));
+    window.IntersectionObserver = jest.fn(
+      (cb) => (lastMock = new IntersectionObserverMock(cb))
+    );
   }
   public static unmock(): void {
     window.IntersectionObserver = original;
   }
+
+  public static get lastInstance(): IntersectionObserverMock {
+    return lastMock;
+  }
 }