Repository: exadel-inc/esl
Pull request #1736 (2023-06-23 01:39:41)

epic: esl-animate documentation, mixin and cleanup (#1281)
Scope of EPIC:

Mixin implementation https://github.com/exadel-inc/esl/pull/1266
Documantation & Demo pages https://github.com/exadel-inc/esl/issues/1279
Tests https://github.com/exadel-inc/esl/issues/1280

Closes: #1281



Patch:
diff --git a/pages/src/localdev.ts b/pages/src/localdev.ts
index 39345220f..587420c01 100644
--- a/pages/src/localdev.ts
+++ b/pages/src/localdev.ts
@@ -32,6 +32,7 @@ import {
   ESLFootnotes,
   ESLTooltip,
   ESLAnimate,
+  ESLAnimateMixin,
   ESLShare,
   ESLRelatedTarget
 } from '../../src/modules/all';
@@ -101,6 +102,7 @@ ESLNote.register();
 ESLTooltip.register();
 
 ESLAnimate.register();
+ESLAnimateMixin.register();
 
 ESLShare.config(() => fetch('/assets/share/config.json').then((response) => response.json()));
 ESLShare.register();
diff --git a/pages/views/examples/animate.njk b/pages/views/examples/animate.njk
index a0a086c22..a67ea3cc7 100644
--- a/pages/views/examples/animate.njk
+++ b/pages/views/examples/animate.njk
@@ -13,20 +13,20 @@ aside:
   <h2>Simple animation</h2>
 
   <div class="simple-grid simple-grid-fw">
-    {% for i in range(0, 4) -%}
-      <esl-animate class="esl-animate-fade simple-grid-cell bg-green round">
+    {% for i in range(0, 2) -%}
+      <esl-animate repeat class="esl-animate-fade simple-grid-cell bg-green round text-white h3 text-center p-4">
         esl-animate-fade
       </esl-animate>
-      <esl-animate class="esl-animate-slide-up simple-grid-cell bg-green round">
+      <esl-animate repeat class="esl-animate-slide-up simple-grid-cell bg-green round text-white h3 text-center p-4">
         esl-animate-slide-up
       </esl-animate>
-      <esl-animate class="esl-animate-slide-left simple-grid-cell bg-green round">
+      <esl-animate repeat class="esl-animate-slide-left simple-grid-cell bg-green round text-white h3 text-center p-4">
         esl-animate-slide-left
       </esl-animate>
-      <esl-animate class="esl-animate-slide-down simple-grid-cell bg-green round">
+      <esl-animate repeat class="esl-animate-slide-down simple-grid-cell bg-green round text-white h3 text-center p-4">
         esl-animate-slide-down
       </esl-animate>
-      <esl-animate class="esl-animate-slide-right simple-grid-cell bg-green round">
+      <esl-animate repeat class="esl-animate-slide-right simple-grid-cell bg-green round text-white h3 text-center p-4">
         esl-animate-slide-right
       </esl-animate>
     {%- endfor %}
@@ -37,9 +37,18 @@ aside:
   <h2>Grouped and repetitive animation</h2>
   <ul class="round simple-grid">
     <esl-animate target="::parent::child(li)" group repeat></esl-animate>
-    {% for i in range(0, 72) -%}
-      <li class="bg-blue round esl-animate-slide-up simple-grid-cell {{ 'hide-xs' if i >= 12 }}"
-          is="my-comp my-com-2"></li>
+    {% for i in range(0, 36) -%}
+      <li class="bg-blue round esl-animate-slide-up simple-grid-cell {{ 'hide-xs' if i >= 12 }}"></li>
+    {%- endfor %}
+  </ul>
+</section>
+
+<section>
+  <h2>Grouped animation with custom delay by mixin</h2>
+  <ul class="round simple-grid">
+    {% for i in range(0, 36) -%}
+      <li class="bg-orange round esl-animate-slide-left simple-grid-cell {{ 'hide-xs' if i >= 12 }}"
+          esl-animate="{group: true, groupDelay: 50}"></li>
     {%- endfor %}
   </ul>
 </section>
diff --git a/src/modules/esl-animate/README.md b/src/modules/esl-animate/README.md
index 1086555f5..1bee02f36 100644
--- a/src/modules/esl-animate/README.md
+++ b/src/modules/esl-animate/README.md
@@ -1,61 +1,80 @@
 # [ESL](https://exadel-inc.github.io/esl/) Animate
 
-Version: *1.0.0*.
+Version: *2.0.0*.
 
-Authors: *Anna-Mariia Petryk*, *Alexey Stsefanovich (ala'n)*, *Julia Murashko*.
-
-**_Important Notice: the component is under beta version, it is tested and ready to use but be aware of its potential critical API changes._**
+Authors: *Anna-Mariia Petryk*, *Feoktyst Shovchko*, *Alexey Stsefanovich (ala'n)*, *Julia Murashko*.
 
 <a name="intro"></a>
 
-**ESLAnimate** is a module to animate items on viewport intersecting.
+`esl-animate` is a module that provides service and its DOM API to animate elements on their intersection with the viewport
+
+The module consists of JS API `ESLAnimateService`, Custom element `ESLAnimate`, and Mixin element `ESLAnimateMixin`.
+ESLAnimateService is a core of the `esl-animate` module. Element needs to be observed by ESLAnimateService in order to be
+animated.
+
+### Module Features:
+- Add class(es) when observed elements enter the viewport area
+- Animate in a group, that allows adding an animation delay for each next item in the intersection queue
+- Automatic re-animation after the item exits the viewport area
+- Pre-defined CSS animations
+  - `esl-animate-fade`
+  - `esl-animate-slide-left`
+  - `esl-animate-slide-right`
+  - `esl-animate-slide-up`
+  - `esl-animate-slide-down`
 
-Features:
-- Add class(es) when observed elements enter viewport area
-- Support group animation that allows item delay its animation on the passed time after previously animated item
-- Support forward and backward animations directions
-- Support automatic re-animate after item exit viewport area
-- JS API (Service) + Custom Element - Plugin for simple initialization
+## `ESLAnimateService`
 
-## ESLAnimateService
-ESLAnimateService is a core of esl-animate module. Element needs to be observed by ESLAnimateService 
-in order to be animated.
+**ESLAnimateService** provides a way to asynchronously add animation on the intersection of a target element with a viewport. It is based on [Intersection Observer Api](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) and serves as a core functionality for `ESLAnimate` and `ESLAnimateMixin` elements.
+
+### Service static and instance API:
 
-### Service API:
 - `ESLAnimateService.observe(els, config)` - method to start element observation
-  - `els` - Element or array of Elements to observe and animate
-  - `config` - an optional ESLAnimateConfig object to describe the behavior of the animation functionality
+  - `els` - element or array of elements to observe and animate
+  - `config` - optional ESLAnimateConfig object to describe the behavior of the animation functionality
 - `ESLAnimateService.unobserve(els)` - method to unsubscribe ESLAnimateService from observing elements
-- `ESLAnimateService.isObserved(el)` - check if element observed by ESLAnimateService
-
-### ESLAnimateConfig (Configuration API)
-- `cls` - CSS class or classes to control animation (`in` by default)
-(supports ESL extended class definition syntax, [CSSClassUtil](../esl-utils/dom/class.ts))
-- `group` (boolean) - enable group animation for items 
-(item will start animation with a delay after previous item animation start)
-- `groupDelay` - number of milliseconds to delay animation in group
+- `ESLAnimateService.isObserved(el)` - check if the element is observed by ESLAnimateService
+
+You can also create a separate (from global) `ESLAnimateService` instance by calling its constructor.
+
+### Configuration API: `ESLAnimateConfig`
+- `cls` (`in` by default) - CSS class(es) to control animation.
+  The control class(es) will be added to the observed element(s) after they had intersected with the viewport area.
+  Service supports ESL extended class definition syntax, [CSSClassUtils](../esl-utils/dom/class.ts)
+- `group` (boolean) - enable group animation for items, hence take `groupDelay` value into account while performing
+  animation (item will start the animation with a delay after the previous item's animation start)
+- `groupDelay` (`100` by default) - number of milliseconds animation delay in group
 - `repeat` (boolean) - refresh (re-animate) items when they became invisible (exit viewport)
-- `force` (boolean) - if true then allows to re-animate items when ESLAnimateService subscribed 
-on already animated item
-- `ratio` (0.2|0.4|0.6|0.8) - intersection ratio to consider element as visible.
-Only 0.2 (20%), 0.4 (40%), 0.6 (60%), 0.8 (80%) values are allowed due to share of IntersectionObserver instance
-with a fixed set of thresholds defined.
-
-## ESLAnimate
-ESLAnimate (`<esl-animate>`) - custom element to automatically initialize ESLAnimateService from html
-
-### Element API
-- `target` - target element or elements to animate, defined by [ESLTraversingQuery](../esl-traversing-query/README.md)  
-Default: empty (animates itself)
-- `cls` - CSS class or classes to control animation (`in` by default)
-(supports ESL extended class definition syntax, [CSSClassUtil](../esl-utils/dom/class.ts))
-- `group` (boolean) - enable group animation for items
-(item will start animation with a delay after previous item animation start)
-- `group-delay` - number of milliseconds to delay animation in group
+- `force` (boolean) - allows to re-animate items when ESLAnimateService subscribed 
+on already animated item if set to true
+- `ratio` (0.2|0.4|0.6|0.8) - intersection ratio to consider an element as visible
+  Only 0.2 (20%), 0.4 (40%), 0.6 (60%), 0.8 (80%) values are allowed due to sharing of IntersectionObserver instance
+  with a fixed set of thresholds defined
+
+## `ESLAnimate` custom element
+
+**ESLAnimate** is a custom element that subscribes `ESLAnimateService` to elements from html.
+
+To use ESLAnimate you need to include the following code:
+```js
+  ESLAnimate.register();
+```
+
+### `ESLAnimate` Attributes | Properties:
+- `target` - target element(s) to animate, defined by [ESLTraversingQuery](../esl-traversing-query/README.md). By default target value is empty, meaning component will animate itself
+- `cls` (`in` by default) - CSS class(es) to control animation. The control class(es) will be added to observed element(s), after they had intersected with vieport area. Service supports ESL extended class definition syntax, [CSSClassUtils](../esl-utils/dom/class.ts)
+- `group` (boolean) - enable group animation for items, hence take `groupDelay` value into account while performing
+  animation (item will start the animation with a delay after the previous item's animation start)
+- `groupDelay` (`100` by default) - number of milliseconds animation delay in group
 - `repeat` (boolean) - refresh (re-animate) items when they became invisible (exit viewport)
-- `ratio` - number of intersection ratio to consider element as visible
-Only 0.2 (20%), 0.4 (40%), 0.6 (60%), 0.8 (80%) values are allowed due to share of IntersectionObserver instance
-with a fixed set of thresholds defined.
+- `force` (boolean) - allows to re-animate items when ESLAnimateService subscribed 
+  on already animated item if set to true
+- `ratio` (0.2|0.4|0.6|0.8) - intersection ratio to consider an element as visible
+  Only 0.2 (20%), 0.4 (40%), 0.6 (60%), 0.8 (80%) values are allowed due to the share of the IntersectionObserver instance
+  with a fixed set of thresholds defined
+
+By default, attributes `group`, `repeat`, and `target` are observed, meaning the animation sequence will restart once
+theese attributes are changed. Additionally, you can do the re-animation manually by calling the instance method `reanimate()`.
 
 ### Use cases
 - plugin (target attribute defined)
@@ -67,7 +86,7 @@ with a fixed set of thresholds defined.
     ...
 </ul>
 ```
-Note: `<esl-animate>` hidden (`display: none`) by default when there is no content inside
+**Note: `<esl-animate>` hidden (`display: none`) by default when there is no content inside** 
 
 - wrapper (no target attribute defined)
 ```html
@@ -75,3 +94,32 @@ Note: `<esl-animate>` hidden (`display: none`) by default when there is no conte
     ...HTML Content...
 </esl-animate>
 ```
+
+## `ESLAnimateMixin`
+
+**ESLAnimateMixin** is an ESL mixin attribute that automatically subscribes `ESLAnimateService` to the element from html.
+
+To use ESLAnimateMixin you need to include the following code:
+```js
+  ESLAnimateMixin.register();
+```
+
+### `ESLAnimateMixin` Attributes | Properties:
+- `esl-animate-mixin` - json attribute containing following properties:
+  - `cls` (`in` by default) - CSS class(es) to control animation. The control class(es) will be added to observed element(s), after they had intersected with vieport area. Service supports ESL extended class definition syntax, [CSSClassUtils](../esl-utils/dom/class.ts)
+  - `repeat` (boolean) - refresh (re-animate) items when they became invisible (exit viewport)
+  - `ratio` (0.2|0.4|0.6|0.8) - intersection ratio to consider an element as visible
+    Only 0.2 (20%), 0.4 (40%), 0.6 (60%), 0.8 (80%) values are allowed due to sharing of the IntersectionObserver instance
+    with a fixed set of thresholds defined
+
+Apart from `ESLAnimate` module mixin doesn't observe any of the element attributes. But you can do the re-animation manually by calling instance method `reanimate()`.
+
+### Use cases
+- default declaration
+```html
+<div esl-animate>...HTML Content...</div>
+```
+- custom config
+```html
+<div esl-animate="{repeat: true, ratio: 0.2, cls: 'in'}">...HTML Content...</div>
+```
diff --git a/src/modules/esl-animate/core.ts b/src/modules/esl-animate/core.ts
index 450eb7de0..1e3293b82 100644
--- a/src/modules/esl-animate/core.ts
+++ b/src/modules/esl-animate/core.ts
@@ -1,4 +1,5 @@
 export type {ESLAnimateShape} from './core/esl-animate.shape';
 
 export * from './core/esl-animate';
+export * from './core/esl-animate-mixin';
 export * from './core/esl-animate-service';
diff --git a/src/modules/esl-animate/core/esl-animate-mixin.ts b/src/modules/esl-animate/core/esl-animate-mixin.ts
new file mode 100644
index 000000000..c370de07c
--- /dev/null
+++ b/src/modules/esl-animate/core/esl-animate-mixin.ts
@@ -0,0 +1,50 @@
+import {ESLMixinElement} from '../../esl-mixin-element/core';
+import {ready, jsonAttr} from '../../esl-utils/decorators';
+import {ExportNs} from '../../esl-utils/environment/export-ns';
+
+import {ESLAnimateService} from './esl-animate-service';
+import type {ESLAnimateConfig} from './esl-animate-service';
+
+/**
+ * ESLAnimateMixin - custom mixin element for quick {@link ESLAnimateService} attaching
+ *
+ * Use example:
+ * `<div esl-animate>Content</div>`
+ *
+ * Supports additional parameters:
+ * `<div esl-animate={repeat: true, ratio: 0.8, cls: 'in'}>Content</div>`
+ */
+@ExportNs('AnimateMixin')
+export class ESLAnimateMixin extends ESLMixinElement {
+  public static override is = 'esl-animate';
+
+  public static defaultConfig: ESLAnimateConfig = {
+    force: true
+  };
+
+  @jsonAttr({name: ESLAnimateMixin.is})
+  public options?: ESLAnimateConfig;
+
+  protected mergeDefaultParams(): ESLAnimateConfig {
+    const type = this.constructor as typeof ESLAnimateMixin;
+    return Object.assign({}, type.defaultConfig, this.options);
+  }
+
+  @ready
+  public override connectedCallback(): void {
+    super.connectedCallback();
+    this.reanimate();
+  }
+
+  @ready
+  public override disconnectedCallback(): void {
+    super.disconnectedCallback();
+    ESLAnimateService.unobserve(this.$host);
+  }
+
+  /** Reinitialize {@link ESLAnimateService} for target */
+  public reanimate(): void {
+    ESLAnimateService.unobserve(this.$host);
+    ESLAnimateService.observe(this.$host, this.mergeDefaultParams());
+  }
+}
diff --git a/src/modules/esl-animate/core/esl-animate-service.ts b/src/modules/esl-animate/core/esl-animate-service.ts
index cc12efd4b..5f05b8076 100644
--- a/src/modules/esl-animate/core/esl-animate-service.ts
+++ b/src/modules/esl-animate/core/esl-animate-service.ts
@@ -31,7 +31,12 @@ export interface ESLAnimateConfig {
 }
 
 /** ESLAnimateService animation inner options. Contains system animation properties */
-interface ESLAnimateConfigInner extends Required<ESLAnimateConfig> {
+interface ESLAnimateConfigInner extends ESLAnimateConfig {
+  // Required parts
+  cls: string;
+  ratio: number;
+  groupDelay: number;
+
   /** (private) animation requested */
   _timeout?: number;
   /** (private) marker to unobserve */
@@ -43,7 +48,7 @@ interface ESLAnimateConfigInner extends Required<ESLAnimateConfig> {
 export class ESLAnimateService {
 
   /** ESLAnimateService default animation configuration */
-  protected static DEFAULT_CONFIG: ESLAnimateConfig = {cls: 'in', groupDelay: 100, ratio: 0.4};
+  protected static DEFAULT_CONFIG: ESLAnimateConfigInner = {cls: 'in', groupDelay: 100, ratio: 0.4};
   /** ESLAnimationService IntersectionObserver properties */
   protected static OPTIONS_OBSERVER: IntersectionObserverInit = {threshold: [0.001, 0.2, 0.4, 0.6, 0.8]};
 
@@ -63,7 +68,7 @@ export class ESLAnimateService {
 
   /** @returns if service observing target */
   public static isObserved(target: Element): boolean {
-    return !!this.instance.getConfigFor(target);
+    return this.instance.isObserved(target);
   }
 
   @memoize()
@@ -83,7 +88,8 @@ export class ESLAnimateService {
    * @param config - optional animation configuration
    */
   public observe(el: Element, config: ESLAnimateConfig = {}): void {
-    const cfg = this.setConfigFor(el, config);
+    const cfg = Object.assign({}, ESLAnimateService.DEFAULT_CONFIG, config);
+    this._configMap.set(el, cfg);
     cfg.force && CSSClassUtils.remove(el, cfg.cls);
     this._io.observe(el);
   }
@@ -94,11 +100,16 @@ export class ESLAnimateService {
     this._configMap.delete(el);
   }
 
+  /** @returns if service observing target */
+  public isObserved(target: Element): boolean {
+    return !!this._configMap.get(target);
+  }
+
   /** Intersection observable callback */
   @bind
   protected onIntersect(entries: IntersectionObserverEntry[]): void {
     entries.forEach(({target, intersectionRatio, isIntersecting}: IntersectionObserverEntry) => {
-      const config = this.getConfigFor(target);
+      const config = this._configMap.get(target);
       if (!config) return;
 
       // Item will be marked as visible in case it intersecting to the viewport with a ratio grater then passed visibleRatio
@@ -123,7 +134,7 @@ export class ESLAnimateService {
   protected onAnimate(): void {
     let time = -1;
     this._entries.forEach((target) => {
-      const config = this.getConfigFor(target);
+      const config = this._configMap.get(target);
       if (!config) return;
 
       if (config._timeout) window.clearTimeout(config._timeout);
@@ -139,26 +150,12 @@ export class ESLAnimateService {
 
   /** Animates passed item */
   protected onAnimateItem(item: Element): void {
-    const config = this.getConfigFor(item);
+    const config = this._configMap.get(item);
     if (!config) return;
 
     CSSClassUtils.add(item, config.cls);
     this._entries.delete(item);
 
-    if (config._unsubscribe) {
-      this._io.unobserve(item);
-      this._configMap.delete(item);
-    }
-  }
-
-  /** Returns config */
-  protected getConfigFor(el: Element): ESLAnimateConfigInner | undefined {
-    return this._configMap.get(el);
-  }
-  /** Returns config */
-  protected setConfigFor(el: Element, config: ESLAnimateConfig): ESLAnimateConfigInner {
-    const cfg = Object.assign({}, ESLAnimateService.DEFAULT_CONFIG, config) as ESLAnimateConfigInner;
-    this._configMap.set(el, cfg);
-    return cfg;
+    if (config._unsubscribe) this.unobserve(item);
   }
 }


Test patch:
diff --git a/src/modules/esl-animate/test/animate.element.test.ts b/src/modules/esl-animate/test/animate.element.test.ts
new file mode 100644
index 000000000..8b4a426fa
--- /dev/null
+++ b/src/modules/esl-animate/test/animate.element.test.ts
@@ -0,0 +1,49 @@
+import {ESLAnimate, ESLAnimateService} from '../core';
+
+jest.mock('../core/esl-animate-service', () => ({
+  ESLAnimateService: {
+    observe: jest.fn(),
+    unobserve: jest.fn()
+  }
+}));
+
+describe('ESLAnimate element', () => {
+  const $el = ESLAnimate.create();
+  document.body.appendChild($el);
+
+  beforeAll(() => {
+    jest.useFakeTimers();
+    ESLAnimate.register();
+  });
+
+  afterAll(() => jest.useRealTimers());
+
+  afterEach(() => jest.resetAllMocks());
+
+  test('ESLAnimate instance', () => expect($el).toBeInstanceOf(ESLAnimate));
+
+  test('connected callback', () => {
+    expect($el.connected).toBe(false);
+    $el.repeat = true;
+    expect(ESLAnimateService.observe).not.toBeCalled();
+    jest.advanceTimersByTime(1);
+    expect(ESLAnimateService.observe).toBeCalledWith([$el], {cls: 'in', force: true, group: false, groupDelay: 100, ratio: 0.4, repeat: true});
+  });
+
+  test('attribute change callback', () => {
+    $el.repeat = true;
+    expect(ESLAnimateService.unobserve).toBeCalled();
+    expect(ESLAnimateService.observe).toBeCalled();
+  });
+
+  test('reanimate call', () => {
+    $el.reanimate();
+    expect(ESLAnimateService.observe).toBeCalled();
+  });
+
+  test('disconnected callback', () => {
+    $el.remove();
+    jest.advanceTimersByTime(1);
+    expect(ESLAnimateService.unobserve).toBeCalled();
+  });
+});
diff --git a/src/modules/esl-animate/test/animate.mixin.test.ts b/src/modules/esl-animate/test/animate.mixin.test.ts
new file mode 100644
index 000000000..686cb05f6
--- /dev/null
+++ b/src/modules/esl-animate/test/animate.mixin.test.ts
@@ -0,0 +1,40 @@
+import {ESLAnimateMixin, ESLAnimateService} from '../core';
+
+jest.mock('../core/esl-animate-service', () => ({
+  ESLAnimateService: {
+    observe: jest.fn(),
+    unobserve: jest.fn()
+  }
+}));
+
+jest.mock('../../esl-utils/dom/ready', () => ({
+  onDocumentReady: (cb: any) => cb()
+}));
+
+describe('ESLAnimateMixin mixin', () => {
+  const $el = document.createElement('div');
+  $el.toggleAttribute(ESLAnimateMixin.is, true);
+  document.body.appendChild($el);
+
+  ESLAnimateMixin.register();
+  const mixin: ESLAnimateMixin = ESLAnimateMixin.get($el)!;
+
+  afterEach(() => jest.resetAllMocks());
+
+  test('ESLAnimateMixin instance', () => {
+    expect(mixin).toBeInstanceOf(ESLAnimateMixin);
+    expect(ESLAnimateService.observe).toBeCalledWith($el, {force: true});
+  });
+
+  test('manual reanimate call', () => {
+    mixin.options = {repeat: true};
+    mixin.reanimate();
+    expect(ESLAnimateService.observe).toBeCalledWith($el, {force: true, repeat: true});
+  });
+
+  test('disconnected callback', async () => {
+    $el.toggleAttribute(ESLAnimateMixin.is);
+    await Promise.resolve();
+    expect(ESLAnimateService.unobserve).toBeCalled();
+  });
+});
diff --git a/src/modules/esl-animate/test/animate.service.test.ts b/src/modules/esl-animate/test/animate.service.test.ts
new file mode 100644
index 000000000..4f21ff005
--- /dev/null
+++ b/src/modules/esl-animate/test/animate.service.test.ts
@@ -0,0 +1,279 @@
+import {ESLAnimateService} from '../core';
+import {CSSClassUtils} from '../../esl-utils/dom/class';
+import {IntersectionObserverMock} from '../../esl-utils/test/intersectionObserver.mock';
+
+describe('ESLAnimateService', () => {
+  beforeAll(() => {
+    IntersectionObserverMock.mock();
+    jest.useFakeTimers();
+  });
+
+  afterAll(() => {
+    IntersectionObserverMock.unmock();
+    jest.clearAllMocks();
+    jest.useRealTimers();
+  });
+
+  describe('Default ESLAnimateService config', () => {
+    describe('Static class methods', () => {
+      test('intersection is detected', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('intersection is detected, but with ration less than 1%', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(false);
+      });
+
+      test('intersection is triggered only once', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el, {repeat: false});
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('intersection of multiple elements is detected', () => {
+        const el = document.createElement('div');
+        const el2 = document.createElement('div');
+        ESLAnimateService.observe([el, el2]);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        IntersectionObserverMock.trigger(el2, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+        expect(CSSClassUtils.has(el2, 'in')).toBe(true);
+      });
+
+      test('element is observed for intersections', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el);
+        expect(ESLAnimateService.isObserved(el)).toBe(true);
+      });
+
+      test('element gets unobserved and intersection isn`t triggered', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        ESLAnimateService.unobserve(el);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('debounced animation process isn`t triggered if it gets unobserved in process', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el);
+        ESLAnimateService.unobserve(el);
+        expect(ESLAnimateService.isObserved(el)).toBe(false);
+      });
+    });
+
+    describe('Instance methods', () => {
+      test('intersection is detected', () => {
+        const el = document.createElement('div');
+        const service = new ESLAnimateService();
+        service.observe(el);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('intersection is detected, but with ration less than 1%', () => {
+        const el = document.createElement('div');
+        const service = new ESLAnimateService();
+        service.observe(el);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(false);
+      });
+
+      test('intersection is triggered only once', () => {
+        const el = document.createElement('div');
+        const service = new ESLAnimateService();
+        service.observe(el, {repeat: false});
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('automatic unobservation after on second intersection', () => {
+        const el = document.createElement('div');
+        const service = new ESLAnimateService();
+        service.observe(el);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('element gets unobserved and intersection isn`t triggered', () => {
+        const el = document.createElement('div');
+        const service = new ESLAnimateService();
+        service.observe(el);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        service.unobserve(el);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 0, isIntersecting: false});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('debounced animation process isn`t triggered if it gets unobserved in process', () => {
+        const el = document.createElement('div');
+        const service = new ESLAnimateService();
+        service.observe(el);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        service.unobserve(el);
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(false);
+      });
+    });
+  });
+
+  describe('Custom ESLAnimateService config', () => {
+    test('intersection is triggered multiple times', () => {
+      const el = document.createElement('div');
+      ESLAnimateService.observe(el, {repeat: true});
+
+      IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+      jest.advanceTimersByTime(100);
+
+      IntersectionObserverMock.trigger(el, {intersectionRatio: 0, isIntersecting: false});
+      jest.advanceTimersByTime(100);
+      expect(CSSClassUtils.has(el, 'in')).toBe(false);
+
+      IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+      jest.advanceTimersByTime(100);
+      expect(CSSClassUtils.has(el, 'in')).toBe(true);
+    });
+
+    test('custom class is set', () => {
+      const el = document.createElement('div');
+      ESLAnimateService.observe(el, {cls: 'customclass'});
+
+      IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+      jest.advanceTimersByTime(100);
+      expect(CSSClassUtils.has(el, 'customclass')).toBe(true);
+    });
+
+    test('observe even if class is present', () => {
+      const el = document.createElement('div');
+      el.classList.add('in');
+      ESLAnimateService.observe(el, {force: true});
+      expect(CSSClassUtils.has(el, 'in')).toBe(false);
+
+      IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+      jest.advanceTimersByTime(100);
+      expect(CSSClassUtils.has(el, 'in')).toBe(true);
+    });
+
+    describe('Intersection ratio', () => {
+      test('intersection isn`t triggered until required visibility ratio', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el, {ratio: 0.2});
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 0.1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(false);
+      });
+
+      test('intersection is triggered on required visibility ratio', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el, {ratio: 0.2});
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 0.2, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+
+      test('intersection is triggered after required visibility ratio', () => {
+        const el = document.createElement('div');
+        ESLAnimateService.observe(el, {ratio: 0.2});
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 0.4, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+      });
+    });
+
+    describe('Group delay', () => {
+      test('additional delay added to each next element', () => {
+        const el = document.createElement('div');
+        const el2 = document.createElement('div');
+        ESLAnimateService.observe([el, el2], {group: true, groupDelay: 100});
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        IntersectionObserverMock.trigger(el2, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(101);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+        expect(CSSClassUtils.has(el2, 'in')).toBe(false);
+
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el2, 'in')).toBe(true);
+      });
+
+      test('groupDelay is cancelled if intersected again in the groupDelay timespan', () => {
+        const el = document.createElement('div');
+        const el2 = document.createElement('div');
+        ESLAnimateService.observe([el, el2], {group: true, groupDelay: 101});
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        IntersectionObserverMock.trigger(el2, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        IntersectionObserverMock.trigger(el2, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+        expect(CSSClassUtils.has(el, 'in')).toBe(true);
+        expect(CSSClassUtils.has(el2, 'in')).toBe(false);
+      });
+
+      test('groupDelay is cancelled if element is not intersected anymore in delay span', () => {
+        const el = document.createElement('div');
+        const el2 = document.createElement('div');
+        ESLAnimateService.observe([el, el2], {group: true, repeat: true, groupDelay: 101});
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 1, isIntersecting: true});
+        IntersectionObserverMock.trigger(el2, {intersectionRatio: 1, isIntersecting: true});
+        jest.advanceTimersByTime(100);
+
+        IntersectionObserverMock.trigger(el, {intersectionRatio: 0, isIntersecting: false});
+        IntersectionObserverMock.trigger(el2, {intersectionRatio: 0, isIntersecting: false});
+        expect(CSSClassUtils.has(el, 'in')).toBe(false);
+        expect(CSSClassUtils.has(el2, 'in')).toBe(false);
+      });
+    });
+  });
+});
diff --git a/src/modules/esl-utils/test/intersectionObserver.mock.ts b/src/modules/esl-utils/test/intersectionObserver.mock.ts
new file mode 100644
index 000000000..95fab4ec9
--- /dev/null
+++ b/src/modules/esl-utils/test/intersectionObserver.mock.ts
@@ -0,0 +1,48 @@
+import {ESLEventUtils} from '../../esl-event-listener/core/api';
+import {bind} from '../decorators/bind';
+
+let original: typeof IntersectionObserver;
+export class IntersectionObserverMock implements IntersectionObserver {
+
+  public constructor(public callback: IntersectionObserverCallback) {}
+
+  public root: Document | Element | null = null;
+
+  public rootMargin: string = '';
+
+  public get thresholds(): number[] {
+    return [];
+  }
+
+  public takeRecords(): IntersectionObserverEntry[] {
+    return [];
+  }
+
+  @bind
+  private _onObserve(e: CustomEvent): void {
+    const {intersectionRatio, isIntersecting} = e.detail;
+    this.callback([{intersectionRatio, target: e.target, isIntersecting}] as IntersectionObserverEntry[], this);
+  }
+
+  public observe(element: Element): void {
+    element.addEventListener('intersection', this._onObserve);
+  }
+
+  public unobserve(element: Element): void {
+    element.removeEventListener('intersection', this._onObserve);
+  }
+
+  public disconnect = jest.fn();
+
+  public static trigger($el: Element, cfg: Partial<IntersectionObserverEntryInit>): void {
+    ESLEventUtils.dispatch($el, 'intersection', {detail: cfg});
+  }
+
+  public static mock(): void {
+    original = window.IntersectionObserver;
+    window.IntersectionObserver = jest.fn((cb) => new IntersectionObserverMock(cb));
+  }
+  public static unmock(): void {
+    window.IntersectionObserver = original;
+  }
+}