Repository: exadel-inc/esl
Pull request #1212 (2022-09-12 03:03:15)

fix(esl-event-listener): change resolving strategy and subscription process
**NOTE:**
Tests and docs under `src/modules/esl-event-listener` are not ready yet (just a part of the tests is effective, docs should be extended with a detailed description).
The implementation is ready to be reviewed.


Patch:
diff --git a/pages/src/anchor/anchor-link.ts b/pages/src/anchor/anchor-link.ts
index f8a15c3e6..1d0bcf43c 100644
--- a/pages/src/anchor/anchor-link.ts
+++ b/pages/src/anchor/anchor-link.ts
@@ -1,7 +1,7 @@
 import {ESLMixinElement} from '../../../src/modules/esl-mixin-element/ui/esl-mixin-element';
 import {listen, memoize} from '../../../src/modules/esl-utils/decorators';
 import {afterNextRender} from '../../../src/modules/esl-utils/async/raf';
-import {EventUtils} from '../../../src/modules/esl-utils/dom/events/utils';
+import {EventUtils} from '../../../src/modules/esl-utils/dom/events';
 
 /**
  * Demo sample of anchor mixin
diff --git a/pages/views/core/esl-event-listener.njk b/pages/views/core/esl-event-listener.njk
new file mode 100644
index 000000000..2ac6f9e8b
--- /dev/null
+++ b/pages/views/core/esl-event-listener.njk
@@ -0,0 +1,10 @@
+---
+layout: content
+title: ESL Event Listeners
+seoTitle: Built-in ESL EventListeners
+name: ESL Event Listeners
+tags: [core, new]
+order: 2
+---
+
+{% mdRender 'src/modules/esl-event-listener/README.md', 'intro' %}
diff --git a/pages/views/core/esl-utils.njk b/pages/views/core/esl-utils.njk
index 1772b9483..b36d57a80 100644
--- a/pages/views/core/esl-utils.njk
+++ b/pages/views/core/esl-utils.njk
@@ -4,7 +4,7 @@ title: ESL Utils
 seoTitle: Universal JS, TS and Web API utilities set from Exadel Smart Library
 name: ESL Utils
 tags: core
-
+order: 3
 ---
 
 {% mdRender 'src/modules/esl-utils/README.md', 'intro' %}
diff --git a/src/modules/esl-event-listener/README.md b/src/modules/esl-event-listener/README.md
new file mode 100644
index 000000000..33f2940a2
--- /dev/null
+++ b/src/modules/esl-event-listener/README.md
@@ -0,0 +1,178 @@
+[//]: # (TODO)
+
+# ESL Event Listener module
+
+<a name="intro"></a>
+
+ESL has a built-in mechanism to work with DOM events. 
+ESLEventListeners has more control and more advanced features than native DOM API.
+In addition, ESLMixinElement and ESlBaseElement have even more syntax sugar 
+to make consumer's code super small and clean.
+
+## EventUtils class
+
+`EventUtils` is a root class for the event listener module, it contains ESL event listeners module API.
+One of the main advantages of `EventUtils` over the native addEventListener is the extended control of subscriptions.
+All ESL listeners are saved and associated with the host element, after that, 
+the listener can be unhooked at any time in a variety of ways. 
+And most importantly, you do not need the original callback handler for this.
+
+For example 
+- `EventUtils.unsubscribe($host);` will unsubscribe everything bound to $host
+- `EventUtils.unsubscribe($host, handlerFn);` will unsubscribe everything that is bound to $host and is handled by handlerFn
+- `EventUtils.unsubscribe($host, 'click');` will unsubscribe everything bound to $host and process 'click'
+- `EventUtils.unsubscribe($host, 'click', handlerFn);` will unsubscribe everything that is bound to $host, processes 'click' event and handles handlerFn
+- There can be any number of criteria.
+
+
+Here the `EventUtils` API:
+
+### ⚡ `EventUtils.subscribe`
+The main method to create ESLEventListener and subscribe to DOM events.
+
+```typescript
+subscribe(host: HTMLElement, handler: ESLListenerHandler): ESLEventListener[];
+subscribe<EType extends keyof ESLListenerEventMap>(
+  host: HTMLElement,
+  descriptor: EType | ESLListenerDescriptor<EType>,
+  handler: ESLListenerHandler<ESLListenerEventMap[EType]>
+): ESLEventListener[];
+subscribe<EType extends keyof ESLListenerEventMap>(
+  host: HTMLElement,
+  descriptor: Partial<ESLListenerDescriptor>,
+  handler: ESLListenerDescriptorFn<EType>
+): ESLEventListener[];
+```
+
+**Parameters**:
+- `host` - host element to store subscription (event target by default);
+- `eventDesc` - Event type or object of event description data;
+- `handler` - Callback handler. See [ESLListenerHandler](#listenerHandler).
+
+Examples:
+- `EventUtils.subscribe($host, handlerFn);` - 
+subscribes decorated `handlerFn` method to the `target`;
+- `EventUtils.subscribe($host, 'click', handlerFn);` - 
+subscribes `handlerFn` function with the passed event type;
+- `EventUtils.subscribe($host, {event: 'scroll', target: window}, handlerFn);` - 
+subscribes `handlerFn` function with the passed additional descriptor data.
+
+
+### ⚡ `EventUtils.unsubscribe`
+Method of the `EventUtils` interface that allows subscribing elements to DOM events.
+
+```typescript
+unsubscribe(
+  host: HTMLElement,
+  ...criteria: ESLListenerCriteria[]
+): ESLEventListener[];
+```
+
+**Parameters**:
+- `host` - An element to unsubsribe;
+- `criteria` - An optional set of criteria to filter listeners to remove.
+
+
+### ⚡ `EventUtils.descriptors`
+Method of the `EventUtils` interface that gathers descriptors from the passed object. See [ESLListenerDescriptorFn](#listenerDescFn).
+
+```typescript
+EventUtils.descriptors(
+  target?: any, 
+  auto: boolean = true
+): ESLListenerDescriptorFn[]
+```
+
+**Parameters**:
+- `target` - An object to get descriptors from;
+- `auto` - A boolean value indicating that the listener should be automatically subscribed within connected callback.
+
+
+### ⚡ `EventUtils.listeners`
+Method of the `EventUtils` interface that gathers listeners currently subscribed to the target.
+
+```typescript
+EventUtils.listeners(
+  target: HTMLElement,
+  ...criteria: ESLListenerCriteria[]
+): ESLEventListener[];
+```
+
+**Parameters**:
+- `host` - An element which listeners to get;
+- `criteria` - An optional set of criteria to filter listeners list.
+
+### ⚡ `EventUtils.dispatch`
+
+Method of the `EventUtils` interface that is used to dispatch custom events.
+The event that is being dispatched is bubbling and cancelable by default.
+
+```typescript
+EventUtils.dispatch(
+  el: EventTarget,
+  eventName: string,
+  eventInit?: CustomEventInit
+): boolean;
+```
+
+**Parameters**:
+- `el` - EventTarget to dispatch event;
+- `eventName` - name of the event to dispatch;
+- `eventInit` - object that specifies characteristics of the event.
+  This parameter can be used to overwrite the default behavior of bubbling and being cancelable.
+
+
+## Listeners methods on ESLBaseElement / ESLMixinElement
+All the inheritors of `ESLBaseElement` and `ESLMixinElement` now have `$$on` and `$$off` methods,
+which still have the same capabilities but only set the current element as the host.
+
+```typescript
+this.$$on('click', this.onClick);
+this.$$off('click'); // или this.$$off(this.onClick)
+```
+
+### Auto-unbind
+All the inheritors of `ESLBaseElement` and `ESLMixinElement` unsubscribe from events attached via ESL automatically 
+on `disconnectedCallback`.
+
+### Decorator `@listen`
+The `@listen` decorator is now available in ESL, which makes it even easier to work with events
+
+The following short snippet of code describes a listener that will automatically subscribe and unsubscribe 
+on connected/disconnected callback inside `ESLBaseElement` or `ESLMixinElement`:
+ ```typescript
+class MyEl extends ESLBaseElement {
+  @listen('click')
+  onClick(e) { 
+    //... 
+  }
+}
+ ```
+
+Full descriptor options are available with decorator as well
+ ```typescript
+class MyEl extends ESLBaseElement {
+  @listen({event: 'click', target: 'body', capture: true})
+  onBodyClick(e) {
+    // ...
+  }
+}
+ ```
+
+You can manage the subscription manually and link the whole meta information or part of it with the handler itself
+ ```typescript
+class MyEl extends ESLBaseElement {
+  @listen({event: 'click', auto: false}) // Won`t be subscribed automatically
+  onClick(e) { 
+    // ...
+  }
+
+  myMethod() {
+    this.$$on(this.onClick); // Manual subscription
+    this.$$on({target: 'body'}, this.onClick); // Manual subscription with parameters (will be merged)
+    this.$$off(this.onClick); // The same behavior as in the examples above
+
+    EventUtils.subscribe($host, this.onClick); // Low-level API support
+  }
+}
+ ```
diff --git a/src/modules/esl-event-listener/core.ts b/src/modules/esl-event-listener/core.ts
new file mode 100644
index 000000000..2b7e315a1
--- /dev/null
+++ b/src/modules/esl-event-listener/core.ts
@@ -0,0 +1,12 @@
+export * from './core/api';
+export type {
+  ESLListenerHandler,
+  ESLListenerCriteria,
+  ESLListenerEventMap,
+  ESLListenerDescriptor,
+  ESLListenerTarget,
+  ESLListenerDefinition
+} from './core/descriptor';
+export type {
+  ESLEventListener,
+} from './core/listener';
diff --git a/src/modules/esl-utils/dom/events/utils.ts b/src/modules/esl-event-listener/core/api.ts
similarity index 65%
rename from src/modules/esl-utils/dom/events/utils.ts
rename to src/modules/esl-event-listener/core/api.ts
index 93ac25321..b4cdd6ec0 100644
--- a/src/modules/esl-utils/dom/events/utils.ts
+++ b/src/modules/esl-event-listener/core/api.ts
@@ -1,16 +1,14 @@
-import {ExportNs} from '../../environment/export-ns';
+import {ExportNs} from '../../esl-utils/environment/export-ns';
+
 import {ESLEventListener} from './listener';
 
 import type {
   ESLListenerHandler,
   ESLListenerCriteria,
+  ESLListenerDescriptor,
   ESLListenerEventMap,
-  ESLListenerDescriptor
-} from './listener';
-
-/** Function decorated as {@link ESLListenerDescriptor} */
-export type ESLListenerDescriptorFn<EType extends keyof ESLListenerEventMap = string> =
-  ESLListenerHandler<ESLListenerEventMap[EType]> & ESLListenerDescriptor<EType>;
+  ESLListenerDescriptorFn
+} from './descriptor';
 
 /** Type guard to check if the passed function is typeof {@link ESLListenerDescriptorFn} */
 export const isDescriptorFn = (obj: any): obj is ESLListenerDescriptorFn =>
@@ -21,9 +19,9 @@ export class EventUtils {
   /**
    * Dispatches custom event.
    * Event bubbles and is cancelable by default, use `eventInit` to override that.
-   * @param el - element target
-   * @param eventName - event name
-   * @param eventInit - custom event init. See {@link CustomEventInit}
+   * @param el - EventTarget to dispatch event
+   * @param eventName - name of the event to dispatch
+   * @param eventInit - object that specifies characteristics of the event. See {@link CustomEventInit}
    */
   public static dispatch(el: EventTarget, eventName: string, eventInit?: CustomEventInit): boolean {
     const init = Object.assign({
@@ -45,48 +43,50 @@ export class EventUtils {
   }
 
   /**
-   * Gets currently subscribed listeners of the target
-   * @param target - host element (listeners context)
+   * Gets currently subscribed listeners of the host
+   * @param host - host element (listeners context)
    * @param criteria - optional set of criteria {@link ESLListenerCriteria} to filter listeners list
    */
-  public static listeners(target: HTMLElement, ...criteria: ESLListenerCriteria[]): ESLEventListener[] {
-    return ESLEventListener.get(target).filter((listener) => !criteria.length || criteria.every(listener.matches, listener));
+  public static listeners(host: HTMLElement, ...criteria: ESLListenerCriteria[]): ESLEventListener[] {
+    return ESLEventListener.get(host, ...criteria);
   }
 
-  /** Subscribes decorated `handler` method of the `target` */
-  public static subscribe(target: HTMLElement, handler: ESLListenerHandler): ESLEventListener[];
+  /** Subscribes decorated `handler` method of the `host` */
+  public static subscribe(host: HTMLElement, handler: ESLListenerHandler): ESLEventListener[];
   /** Subscribes `handler` function with the passed event type */
   public static subscribe<EType extends keyof ESLListenerEventMap>(
-    target: HTMLElement,
+    host: HTMLElement,
     descriptor: EType | ESLListenerDescriptor<EType>,
     handler: ESLListenerHandler<ESLListenerEventMap[EType]>
   ): ESLEventListener[];
   /** Subscribes `handler` descriptor function with the passed additional descriptor data */
   public static subscribe<EType extends keyof ESLListenerEventMap>(
-    target: HTMLElement,
+    host: HTMLElement,
     descriptor: Partial<ESLListenerDescriptor>,
     handler: ESLListenerDescriptorFn<EType>
   ): ESLEventListener[];
   public static subscribe(
-    target: HTMLElement,
+    host: HTMLElement,
     eventDesc: string | Partial<ESLListenerDescriptor> | ESLListenerHandler,
     handler: ESLListenerHandler = eventDesc as ESLListenerDescriptorFn
   ): ESLEventListener[] {
     if (typeof handler !== 'function') return [];
     if (typeof eventDesc === 'string') eventDesc = {event: eventDesc};
     if (isDescriptorFn(handler) && eventDesc !== handler) eventDesc = Object.assign({}, handler, eventDesc);
-    const listeners = ESLEventListener.create(target, handler, eventDesc as ESLListenerDescriptor);
-    listeners.forEach((listener) => listener.subscribe());
-    return listeners;
+
+    const listeners = ESLEventListener.createOrResolve(host, handler, eventDesc as ESLListenerDescriptor);
+    const subscribed = listeners.filter((listener) => listener.subscribe());
+    if (!subscribed.length) console.warn('[ESL]: Empty subscription %o for %o', eventDesc, handler);
+    return subscribed;
   }
 
   /**
    * Unsubscribes {@link ESLEventListener}(s) from the object
-   * @param target - host element (listeners context)
+   * @param host - host element that stores subscriptions (listeners context)
    * @param criteria - optional set of criteria {@link ESLListenerCriteria} to filter listeners to remove
    */
-  public static unsubscribe(target: HTMLElement, ...criteria: ESLListenerCriteria[]): ESLEventListener[] {
-    const listeners = EventUtils.listeners(target, ...criteria);
+  public static unsubscribe(host: HTMLElement, ...criteria: ESLListenerCriteria[]): ESLEventListener[] {
+    const listeners = ESLEventListener.get(host, ...criteria);
     listeners.forEach((listener) => listener.unsubscribe());
     return listeners;
   }
diff --git a/src/modules/esl-event-listener/core/descriptor.ts b/src/modules/esl-event-listener/core/descriptor.ts
new file mode 100644
index 000000000..d7e514f73
--- /dev/null
+++ b/src/modules/esl-event-listener/core/descriptor.ts
@@ -0,0 +1,63 @@
+import type {ESLEventListener} from './listener';
+import type {PropertyProvider} from '../../esl-utils/misc/functions';
+
+/** String CSS selector to find the target or {@link EventTarget} object or array of {@link EventTarget}s */
+export type ESLListenerTarget = EventTarget | EventTarget[] | string | null;
+
+/** Extended event map with the custom event definition */
+export interface ESLListenerEventMap extends HTMLElementEventMap {
+  /** User custom event or group of events */
+  [e: string]: Event;
+}
+
+/** Descriptor to create {@link ESLEventListener} */
+export type ESLListenerDescriptor<EType extends keyof ESLListenerEventMap = string> = {
+  /** A case-sensitive string (or provider function) representing the event type to listen for */
+  event: EType | PropertyProvider<EType>;
+  /**
+   * A boolean value indicating that events for this listener will be dispatched on the capture phase.
+   * @see AddEventListenerOptions.capture
+   */
+  capture?: boolean;
+  /**
+   * A boolean value that indicates that the function specified by listener will never call preventDefault()
+   * @see AddEventListenerOptions.passive
+   */
+  passive?: boolean;
+
+  /** A string (or provider function) representing CSS selector to check delegated event target (undefined (disabled) by default) */
+  selector?: string | PropertyProvider<string>;
+  /**
+   * An ESLEventTarget (or provider function) to subscribe the event listener to
+   * **Note**: string values are processed by the {@link TraversingQuery} syntax
+   * (e.g. `button` selects all buttons globally, while `::find(button)` selects only buttons inside current element)
+   */
+  target?: ESLListenerTarget | PropertyProvider<ESLListenerTarget>;
+
+  /**
+   * A reference to the component (mixin) that holds the event listener descriptor
+   * Used as a call context for the event listener handler if defined
+   */
+  context?: unknown;
+
+  /** A boolean value indicating that the listener should be automatically subscribed within connected callback */
+  auto?: boolean;
+  /** A boolean value indicating that the listener should be invoked at most once after being added */
+  once?: boolean;
+};
+
+/** Resolved descriptor (definition) to create {@link ESLEventListener} */
+export interface ESLListenerDefinition<EType extends keyof ESLListenerEventMap = string> extends ESLListenerDescriptor<EType> {
+  /** A case-sensitive string (or provider function) representing the event type to listen for */
+  event: EType;
+}
+
+/** Describes callback handler */
+export type ESLListenerHandler<EType extends Event = Event> = (event: EType, listener: ESLEventListener) => void;
+
+/** Condition (criteria) to find {@link ESLListenerDescriptor} */
+export type ESLListenerCriteria = undefined | keyof ESLListenerEventMap | ESLListenerHandler | Partial<ESLListenerDefinition>;
+
+/** Function decorated as {@link ESLListenerDescriptor} */
+export type ESLListenerDescriptorFn<EType extends keyof ESLListenerEventMap = string> =
+  ESLListenerHandler<ESLListenerEventMap[EType]> & ESLListenerDescriptor<EType>;
diff --git a/src/modules/esl-event-listener/core/listener.ts b/src/modules/esl-event-listener/core/listener.ts
new file mode 100644
index 000000000..e4facf4aa
--- /dev/null
+++ b/src/modules/esl-event-listener/core/listener.ts
@@ -0,0 +1,156 @@
+import {wrap} from '../../esl-utils/misc/array';
+import {resolveProperty} from '../../esl-utils/misc/functions';
+import {memoize} from '../../esl-utils/decorators/memoize';
+import {isSimilar} from '../../esl-utils/misc/object/compare';
+import {TraversingQuery} from '../../esl-traversing-query/core';
+import {isPassiveByDefault, splitEvents} from '../../esl-utils/dom/events/misc';
+
+import type {PropertyProvider} from '../../esl-utils/misc/functions';
+import type {
+  ESLListenerTarget,
+  ESLListenerDefinition,
+  ESLListenerDescriptor,
+  ESLListenerHandler,
+  ESLListenerCriteria
+} from './descriptor';
+
+/** Key to store listeners on the host */
+const STORE = '__listeners';
+
+/**
+ * `EventListener` instance, used as an 'inner' record to process subscriptions made by `EventUtils`
+ * Uses `EventListenerObject` interface to subscribe on event.
+ *
+ * Use Chrome console `getEventListeners` method to check subscribers details when debugging ESLEventListener subscriptions.
+ * */
+export class ESLEventListener implements ESLListenerDefinition, EventListenerObject {
+  public readonly target?: ESLListenerTarget | PropertyProvider<ESLListenerTarget>;
+  public readonly selector?: string;
+  public readonly capture?: boolean;
+
+  public readonly once?: boolean;
+  public readonly auto?: boolean;
+  public readonly passive?: boolean;
+  public readonly context?: unknown;
+
+  protected constructor(
+    public readonly $host: HTMLElement,
+    public readonly event: string,
+    public readonly handler: ESLListenerHandler,
+    desc: ESLListenerDescriptor
+  ) {
+    const defaults = {
+      capture: false,
+      passive: isPassiveByDefault(event)
+    };
+    Object.assign(this, defaults, desc, {event});
+  }
+
+  /** @returns target element to listen */
+  @memoize()
+  public get $targets(): EventTarget[] {
+    const target = resolveProperty(this.target, this.context || this.$host);
+    if (typeof target === 'undefined') return [this.$host];
+    if (typeof target === 'string') return TraversingQuery.all(target, this.$host);
+    if (typeof target === 'object' && target) return wrap(target);
+    return [];
+  }
+
+  /** @returns resolved selector to check event target */
+  public get delegate(): string | undefined {
+    return resolveProperty(this.selector, this.context || this.$host);
+  }
+
+  /**
+   * Checks if the passed criteria matches current event listener
+   * - `string` type criteria checked by the `event` name
+   * - `function` type criteria checked by the `handler` reference
+   * - `object` type criteria checked as descriptor using `isSimilar` comparer
+   * Note: `function` (handler) marcher has a priority over descriptor check, so handler function own properties will be ignored
+   */
+  public matches(desc?: ESLListenerCriteria): boolean {
+    if (typeof desc === 'string') return this.event === desc;
+    if (typeof desc === 'function') return this.handler === desc;
+    if (typeof desc === 'object') return isSimilar(this, desc, false);
+    return false;
+  }
+
+  /** Handles caught event (used as callback for low-level subscriptions) */
+  public handleEvent(e: Event): void {
+    if (!this.isDelegatedTarget(e)) return;
+    this.handler.call(this.context ?? this.$host, e, this);
+    if (this.once) this.unsubscribe();
+  }
+
+  /** Checks if the passed event can be handled by the current event listener */
+  protected isDelegatedTarget(e: Event): boolean {
+    const target = e.target;
+    const current = e.currentTarget;
+    const delegate = this.delegate;
+    if (typeof delegate !== 'string') return true;
+    if (!(target instanceof HTMLElement) || !(current instanceof HTMLElement)) return false;
+    return current.contains(target.closest(delegate));
+  }
+
+  /**
+   * (Re-)Subscribes event listener instance
+   * @returns if subscription was successful
+   */
+  public subscribe(): boolean {
+    const {passive, capture} = this;
+    this.unsubscribe();
+    memoize.clear(this, '$targets');
+    if (!this.$targets.length) return false;
+    this.$targets.forEach((el: EventTarget) => el.addEventListener(this.event, this, {passive, capture}));
+    ESLEventListener.add(this.$host, this);
+    return true;
+  }
+
+  /** Unsubscribes event listener instance */
+  public unsubscribe(): void {
+    const {capture} = this;
+    if (!memoize.has(this, '$targets')) return;
+    this.$targets.forEach((el: EventTarget) => el.removeEventListener(this.event, this, {capture}));
+    ESLEventListener.remove(this.$host, this);
+  }
+
+  /**
+   * Gets stored listeners array the passed `host` object
+   * Supports additional filtration criteria
+   */
+  public static get(host?: any, ...criteria: ESLListenerCriteria[]): ESLEventListener[] {
+    if (!host) return [];
+    const listeners = (host[STORE] || []) as ESLEventListener[];
+    if (!criteria.length) return listeners;
+    return listeners.filter((listener) => criteria.every(listener.matches, listener));
+  }
+  /** Adds listener to the listener store of the host object */
+  protected static add(host: any, instance: ESLEventListener): void {
+    if (!host) return;
+    if (!Object.hasOwnProperty.call(host, STORE)) host[STORE] = [];
+    host[STORE].push(instance);
+  }
+  /** Removes listener from the listener store of the host object */
+  protected static remove(host: any, instance: ESLEventListener): void {
+    const listeners = ESLEventListener.get(host);
+    const value = listeners.filter((listener) => listener !== instance);
+    Object.defineProperty(host, STORE, {value, configurable: true});
+  }
+
+  /** Creates or resolve existing event listeners by handler and descriptors */
+  public static createOrResolve(host: HTMLElement, handler: ESLListenerHandler, desc: ESLListenerDescriptor): ESLEventListener[] {
+    const eventString = resolveProperty(desc.event, desc.context || host);
+
+    const listeners: ESLEventListener[] = [];
+    for (const event of splitEvents(eventString)) {
+      const subscribed = ESLEventListener.get(host, event, handler, {
+        target: desc.target,
+        selector: desc.selector,
+        capture: !!desc.capture
+      });
+      if (subscribed.length) listeners.push(...subscribed);
+      else listeners.push(new ESLEventListener(host, event, handler, desc));
+    }
+    return listeners;
+  }
+}
diff --git a/src/modules/esl-utils/dom/events.ts b/src/modules/esl-utils/dom/events.ts
index 928d88997..9775676aa 100644
--- a/src/modules/esl-utils/dom/events.ts
+++ b/src/modules/esl-utils/dom/events.ts
@@ -1,4 +1,4 @@
 export * from './events/target';
 export * from './events/misc';
-export * from './events/listener';
-export * from './events/utils';
+
+export * from '../../esl-event-listener/core';
diff --git a/src/modules/esl-utils/dom/events/listener.ts b/src/modules/esl-utils/dom/events/listener.ts
deleted file mode 100644
index 236ed56d7..000000000
--- a/src/modules/esl-utils/dom/events/listener.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import {wrap} from '../../misc/array';
-import {sequentialUID} from '../../misc/uid';
-import {resolveProperty} from '../../misc/functions';
-import {memoize} from '../../decorators/memoize';
-import {isSimilar} from '../../misc/object/compare';
-import {TraversingQuery} from '../../../esl-traversing-query/core';
-import {isPassiveByDefault} from './misc';
-import type {PropertyProvider} from '../../misc/functions';
-
-/** Describes callback handler */
-export type ESLListenerHandler<EType extends Event = Event> = (event: EType, listener?: ESLEventListener) => void;
-
-/** Extended event map with the custom event definition */
-export interface ESLListenerEventMap extends HTMLElementEventMap {
-  /** User custom event or group of events */
-  [e: string]: Event;
-}
-
-/** Descriptor to create {@link ESLEventListener} */
-export type ESLListenerDescriptor<EType extends keyof ESLListenerEventMap = string> = {
-  /** A case-sensitive string (or provider function) representing the event type to listen for */
-  event: EType | PropertyProvider<EType>;
-  /**
-   * A boolean value indicating that events for this listener will be dispatched on the capture phase.
-   * @see AddEventListenerOptions.capture
-   */
-  capture?: boolean;
-  /**
-   * A boolean value that indicates that the function specified by listener will never call preventDefault()
-   * @see AddEventListenerOptions.passive
-   */
-  passive?: boolean;
-
-  /** A string (or provider function) representing CSS selector to check delegated event target (undefined (disabled) by default) */
-  selector?: string | PropertyProvider<string>;
-  /**
-   * A string (or provider function) selector to find the target or {@link EventTarget} object to subscribe the event listener to
-   * **Note**: string values are processed by the {@link TraversingQuery} syntax
-   * (e.g. `button` selects all buttons globally, while `::find(button)` selects only buttons inside current element)
-   */
-  target?: EventTarget | EventTarget[] | string | null | PropertyProvider<EventTarget | EventTarget[] | string | null>;
-
-  /** Identifier of the event listener. Can be used to group and unsubscribe listeners */
-  id?: string;
-  /**
-   * A reference to the component (mixin) that holds the event listener descriptor
-   * Used as a call context for the event listener handler if defined
-   */
-  context?: unknown;
-
-  /** A boolean value indicating that the listener should be automatically subscribed within connected callback */
-  auto?: boolean;
-  /** A boolean value indicating that the listener should be invoked at most once after being added */
-  once?: boolean;
-};
-
-/** Condition (criteria) to find {@link ESLListenerDescriptor} */
-export type ESLListenerCriteria = undefined | keyof ESLListenerEventMap | ESLListenerHandler | Partial<ESLEventListener>;
-
-const STORE = '__listeners';
-
-/**
- * `EventListener` instance, used as an 'inner' record to process subscriptions made by `EventUtils`
- * Uses `EventListenerObject` interface to subscribe on event.
- *
- * Use Chrome console `getEventListeners` method to check subscribers details when debugging ESLEventListener subscriptions.
- * */
-export class ESLEventListener implements ESLListenerDescriptor, EventListenerObject {
-  public readonly id: string;
-  public readonly event: string;
-  public readonly once?: boolean;
-  public readonly auto?: boolean;
-  public readonly target?: string | EventTarget | EventTarget[];
-  public readonly capture?: boolean;
-  public readonly passive?: boolean;
-  public readonly selector?: string;
-  public readonly context?: unknown;
-
-  constructor(
-    public readonly $host: HTMLElement,
-    public readonly handler: ESLListenerHandler,
-    desc: ESLListenerDescriptor
-  ) {
-    desc = Object.assign({}, desc);
-    desc.id = desc.id || sequentialUID('esl.event');
-    desc.target = resolveProperty(desc.target, desc.context || $host);
-    desc.selector = resolveProperty(desc.selector, desc.context || $host);
-    Object.assign(this, {
-      capture: false,
-      passive: isPassiveByDefault(this.event)
-    }, desc);
-  }
-
-  /** @returns target element to listen */
-  @memoize()
-  public get $targets(): EventTarget[] {
-    if (typeof this.target === 'string') return TraversingQuery.all(this.target, this.$host);
-    if (typeof this.target === 'undefined') return [this.$host];
-    if (typeof this.target === 'object' && this.target) return wrap(this.target);
-    return [];
-  }
-
-  /**
-   * Checks if the passed criteria matches current event listener
-   * - `string` type criteria checked by the `event` name
-   * - `function` type criteria checked by the `handler` reference
-   * - `object` type criteria checked as descriptor using `isSimilar` comparer
-   * Note: `function` (handler) marcher has a priority over descriptor check, so handler function own properties will be ignored
-   */
-  public matches(desc?: ESLListenerCriteria): boolean {
-    if (typeof desc === 'string') return this.event === desc;
-    if (typeof desc === 'function') return this.handler === desc;
-    if (typeof desc === 'object') return isSimilar(this, desc, false);
-    return false;
-  }
-
-  /** Handles caught event (used as callback for low-level subscriptions) */
-  public handleEvent(e: Event): void {
-    if (!this.isDelegatedTarget(e)) return;
-    this.handler.call(this.context ?? this.$host, e, this);
-    if (this.once) this.unsubscribe();
-  }
-
-  /** Checks if the passed event can be handled by the curren event listener */
-  protected isDelegatedTarget(e: Event): boolean {
-    if (typeof this.selector !== 'string') return true;
-    const target = e.target;
-    const current = e.currentTarget;
-    if (!(target instanceof HTMLElement) || !(current instanceof HTMLElement)) return false;
-    return current.contains(target.closest(this.selector));
-  }
-
-  /**
-   * (Re-)Subscribes event listener instance
-   * @returns if subscription was successful
-   */
-  public subscribe(): boolean {
-    const {passive, capture} = this;
-    this.unsubscribe();
-    memoize.clear(this, '$targets');
-    if (!this.$targets.length) return false;
-    this.$targets.forEach((el: EventTarget) => el.addEventListener(this.event, this, {passive, capture}));
-    ESLEventListener.get(this.$host).push(this);
-    return true;
-  }
-
-  /** Unsubscribes event listener instance */
-  public unsubscribe(): void {
-    const {capture} = this;
-    this.$targets.forEach((el: EventTarget) => el.removeEventListener(this.event, this, {capture}));
-    const listeners = ESLEventListener.get(this.$host);
-    const value = listeners.filter((listener) => listener !== this);
-    Object.defineProperty(this.$host, STORE, {value, configurable: true});
-  }
-
-  /** Gets stored listeners array the passed `host` object */
-  public static get(host?: any): ESLEventListener[] {
-    if (!host) return [];
-    if (!Object.hasOwnProperty.call(host, STORE)) host[STORE] = [];
-    return host[STORE];
-  }
-
-  /** Creates event listeners by handler and descriptors */
-  public static create(target: HTMLElement, handler: ESLListenerHandler, desc: ESLListenerDescriptor): ESLEventListener[] {
-    const events = resolveProperty(desc.event, desc.context || target);
-    if (!events.length) {
-      console.warn('[ESL]: Can\'t create ESLEventListener with empty event type: %o %o', handler, desc);
-    }
-    return ESLEventListener.splitEventQ(events).map((event) => {
-      const spec: ESLListenerDescriptor = Object.assign({}, desc, {event});
-      return new ESLEventListener(target, handler, spec);
-    });
-  }
-
-  /** Splits and deduplicate event query */
-  protected static splitEventQ(query: string): string[] {
-    const terms = (query || '').split(' ').map((term) => term.trim());
-    const deduplicate = new Set<string>();
-    return terms.filter((term) => {
-      if (!term || deduplicate.has(term)) return false;
-      deduplicate.add(term);
-      return true;
-    });
-  }
-}
diff --git a/src/modules/esl-utils/dom/events/misc.ts b/src/modules/esl-utils/dom/events/misc.ts
index 218d397c9..755ea6f82 100644
--- a/src/modules/esl-utils/dom/events/misc.ts
+++ b/src/modules/esl-utils/dom/events/misc.ts
@@ -42,3 +42,17 @@ export const getOffsetPoint = (el: Element): Point => {
   const x = props.left + window.scrollX;
   return {x, y};
 };
+
+/**
+ * Splits and deduplicates event string
+ * @returns array of unique events presented in events string
+ */
+export const splitEvents = (events: string): string[] => {
+  const terms = (events || '').split(' ').map((term) => term.trim());
+  const deduplicate = new Set<string>();
+  return terms.filter((term) => {
+    if (!term || deduplicate.has(term)) return false;
+    deduplicate.add(term);
+    return true;
+  });
+};


Test patch:
diff --git a/src/modules/esl-base-element/test/element.listener.test.ts b/src/modules/esl-base-element/test/element.listener.test.ts
index b3c3b0069..041aae914 100644
--- a/src/modules/esl-base-element/test/element.listener.test.ts
+++ b/src/modules/esl-base-element/test/element.listener.test.ts
@@ -2,23 +2,43 @@ import '../../../polyfills/es5-target-shim';
 
 import {ESLBaseElement, listen} from '../core';
 import {randUID} from '../../esl-utils/misc/uid';
-import {EventUtils} from '../../esl-utils/dom/events/utils';
+import {EventUtils} from '../../esl-utils/dom/events';
 
 describe('ESLBaseElement: listeners', () => {
-  test('auto-subscribe', () => {
+  describe('ESLBaseElement auto subscribes to listener declarations', () => {
+    const mockHandler = jest.fn();
     class TestElement extends ESLBaseElement {
+      static is = 'test-listen-element';
+
       @listen('click')
-      public onClick() {}
+      public onClick(...args: any[]) { mockHandler(this, ...args); }
     }
-    TestElement.register('test-' + randUID());
-    const el = new TestElement();
-
-    expect(EventUtils.listeners(el).length).toBe(0);
-    document.body.appendChild(el);
-    expect(EventUtils.listeners(el).length).toBe(1);
-    expect(EventUtils.listeners(el)[0].event).toBe('click');
-    document.body.removeChild(el);
-    expect(EventUtils.listeners(el).length).toBe(0);
+    TestElement.register();
+
+    const el = document.createElement(TestElement.is);
+
+    beforeAll(() => document.body.appendChild(el));
+
+    test('ESLBaseElement successfully auto subscribed', () => {
+      expect(EventUtils.listeners(el).length).toBe(1);
+      expect(EventUtils.listeners(el)[0].event).toBe('click');
+    });
+
+    test('ESLBaseElement subscription works correctly', () => {
+      mockHandler.mockReset();
+      el.click();
+      expect(mockHandler).toBeCalled();
+      expect(mockHandler).lastCalledWith(el, expect.any(Event), expect.any(Object));
+    });
+
+    test('ESLBaseElement successfully auto unsubscribed', async () => {
+      document.body.removeChild(el);
+      await Promise.resolve(); // Wait for microtasks completed
+      expect(EventUtils.listeners(el).length).toBe(0);
+      return Promise.resolve();
+    });
+
+    afterAll(async () => el.parentElement && el.remove());
   });
 
   describe('event accessors', () => {
diff --git a/src/modules/esl-event-listener/test/subscribe.test.ts b/src/modules/esl-event-listener/test/subscribe.test.ts
new file mode 100644
index 000000000..d9ddd9759
--- /dev/null
+++ b/src/modules/esl-event-listener/test/subscribe.test.ts
@@ -0,0 +1,309 @@
+import {EventUtils} from '../core';
+
+describe('EventUtils:subscribe tests', () => {
+  test('EventUtils:subscribe successfully subscribe listener by descriptor', () => {
+    const $host = document.createElement('div');
+    const handle = jest.fn();
+    EventUtils.subscribe($host, {event: 'click'}, handle);
+    expect(EventUtils.listeners($host).length).toBe(1);
+  });
+  test('EventUtils:subscribe successfully subscribe listener by event name', () => {
+    const $host = document.createElement('div');
+    const handle = jest.fn();
+    EventUtils.subscribe($host, 'click', handle);
+    expect(EventUtils.listeners($host).length).toBe(1);
+  });
+  test('EventUtils:subscribe successfully subscribe listener by event provider', () => {
+    const $host = document.createElement('div');
+    const provider = jest.fn(function () {
+      expect(this).toBe($host);
+      return 'event';
+    });
+    const handle = jest.fn();
+    EventUtils.subscribe($host, {event: provider}, handle);
+    expect(EventUtils.listeners($host).length).toBe(1);
+    expect(provider).toBeCalledWith($host);
+  });
+
+  test('EventUtils:subscribe successfully subscribe listeners by string with multiple events', () => {
+    const $host = document.createElement('div');
+    const handle = jest.fn();
+    EventUtils.subscribe($host, 'click keydown', handle);
+    expect(EventUtils.listeners($host).length).toBe(2);
+    expect(EventUtils.listeners($host, 'keydown').length).toBe(1);
+    expect(EventUtils.listeners($host, 'click').length).toBe(1);
+  });
+
+  test('ESLEventListener observes target events', () => {
+    const $host = document.createElement('div');
+    const handle = jest.fn();
+    EventUtils.subscribe($host, {event: 'click'}, handle);
+
+    $host.click();
+    expect(handle).toBeCalledTimes(1);
+  });
+  test('ESLEventListener correctly observes target with selector (event delegation)', ()  => {
+    const div = document.createElement('div');
+    const btn = document.createElement('button');
+    div.appendChild(btn);
+    const span = document.createElement('span');
+    div.appendChild(span);
+    const handle = jest.fn();
+    EventUtils.subscribe(div, {event: 'click', selector: 'button'}, handle);
+
+    btn.click();
+    expect(handle).toBeCalledTimes(1);
+    span.click();
+    expect(handle).toBeCalledTimes(1);
+  });
+
+  describe('ESlEventListener subscription and delegation', () => {
+    const host = document.createElement('section');
+    host.innerHTML = `
+      <div class="btns">
+        <button class="btn">Test <span>1</span></button>
+        <button id="btn">2</button>
+      </div>
+    `;
+    const btn = host.querySelector('button.btn') as HTMLButtonElement;
+    const btnSpan = btn.querySelector('span') as HTMLSpanElement;
+    const btn2 = host.querySelector('#btn') as HTMLButtonElement;
+
+    afterEach(() => EventUtils.unsubscribe(host));
+
+    test('Simple click subscription catches click', () => {
+      const handler = jest.fn();
+      EventUtils.subscribe(host, 'click', handler);
+      expect(handler).toBeCalledTimes(0);
+      btn.click();
+      expect(handler).toBeCalledTimes(1);
+      btn2.click();
+      expect(handler).toBeCalledTimes(2);
+    });
+
+    test('Subscription with selector catches click exact on selected target', () => {
+      const handler = jest.fn();
+      EventUtils.subscribe(host, {event: 'click', selector: '.btn'}, handler);
+      expect(handler).toBeCalledTimes(0);
+      btn.click();
+      expect(handler).toBeCalledTimes(1);
+      btn2.click();
+      expect(handler).toBeCalledTimes(1);
+    });
+
+    test('Subscription with selector catches click inside selected target', () => {
+      const handler = jest.fn();
+      EventUtils.subscribe(host, {event: 'click', selector: '.btn'}, handler);
+      expect(handler).toBeCalledTimes(0);
+      btnSpan.click();
+      expect(handler).toBeCalledTimes(1);
+    });
+
+    test('Subscription with target provided with query catches click', () => {
+      const handler = jest.fn();
+      EventUtils.subscribe(host, {event: 'click', target: '::find(#btn)'}, handler);
+      expect(handler).toBeCalledTimes(0);
+      btnSpan.click();
+      expect(handler).toBeCalledTimes(0);
+      btn2.click();
+      expect(handler).toBeCalledTimes(1);
+    });
+
+    test('Subscription with target provided by instance', () => {
+      const handler = jest.fn();
+      const el = document.createElement('button');
+      EventUtils.subscribe(host, {event: 'click', target: el}, handler);
+      expect(handler).toBeCalledTimes(0);
+      btn.click();
+      expect(handler).toBeCalledTimes(0);
+      btn2.click();
+      expect(handler).toBeCalledTimes(0);
+      el.click();
+      expect(handler).toBeCalledTimes(1);
+    });
+  });
+
+  describe('ESlEventListener subscription with target or selector defined by provider', () => {
+    const host = document.createElement('section');
+    host.innerHTML = `
+      <div class="btns">
+        <button class="btn1">Test</button>
+        <button class="btn2">2</button>
+      </div>
+    `;
+    const btn1 = host.querySelector('button.btn1') as HTMLButtonElement;
+    const btn2 = host.querySelector('button.btn2') as HTMLButtonElement;
+
+    afterEach(() => EventUtils.unsubscribe(host));
+
+    test('Selector provider receives host in arg and context', () => {
+      const handler = jest.fn();
+      const provider  = jest.fn(function () {
+        expect(this).toBe(host);
+        return 'button';
+      });
+      EventUtils.subscribe(host, {event: 'click', selector: provider}, handler);
+      btn1.click();
+      expect(provider).toHaveBeenCalledWith(host);
+    });
+
+    test('Selector defined with provider applies on fly', () => {
+      const handler = jest.fn();
+      const provider  = jest.fn();
+      EventUtils.subscribe(host, {event: 'click', selector: provider}, handler);
+      expect(handler).toBeCalledTimes(0);
+
+      provider.mockReturnValue('.btn1');
+      btn1.click();
+      expect(handler).toBeCalledTimes(1);
+      btn2.click();
+      expect(handler).toBeCalledTimes(1);
+
+      provider.mockReturnValue('.btn2');
+      btn1.click();
+      expect(handler).toBeCalledTimes(1);
+      btn2.click();
+      expect(handler).toBeCalledTimes(2);
+    });
+
+    test('Target provider receives host in arg and context', () => {
+      const handler = jest.fn();
+      const provider  = jest.fn(function () {
+        expect(this).toBe(host);
+        return '';
+      });
+      EventUtils.subscribe(host, {event: 'click', target: provider}, handler);
+      btn1.click();
+      expect(provider).toHaveBeenCalledWith(host);
+    });
+
+    test('Subscription use target provider value correctly', () => {
+      const handler = jest.fn();
+      const provider  = jest.fn(() => '::find(.btn2)');
+      EventUtils.subscribe(host, {event: 'click', target: provider}, handler);
+      btn1.click();
+      expect(handler).not.toBeCalled();
+      btn2.click();
+      expect(handler).toBeCalled();
+    });
+  });
+
+  test('ESlEventListener does not produce subscription if target provider returns null', () => {
+    jest.spyOn(console, 'warn').mockImplementationOnce(() => undefined); // Skip warn
+    const host = document.createElement('div');
+    const handler = jest.fn();
+    const provider  = jest.fn(() => null);
+
+    const listeners = EventUtils.subscribe(host, {event: 'click', target: provider}, handler);
+    host.click();
+    expect(listeners).toEqual([]);
+    expect(provider).toBeCalled();
+    expect(handler).not.toBeCalled();
+  });
+
+  describe('EventUtils.subscribe resubscribing event', () => {
+    const $host = document.createElement('div');
+    const handle = jest.fn();
+    const targ1 = document.createElement('button');
+    const targ2 = document.createElement('button');
+    const target = jest.fn(() => targ1);
+
+    const listeners1 = EventUtils.subscribe($host, {event: 'click', target}, handle);
+    test('EventUtils.subscribe subscription correctly first time', () => {
+      expect(listeners1.length).toBe(1);
+      expect(EventUtils.listeners($host).length).toBe(1);
+    });
+
+    target.mockImplementation(() => targ2);
+    const listeners2 = EventUtils.subscribe($host, {event: 'click', target}, handle);
+    test('EventUtils.subscribe subscription correctly second time', () => {
+      expect(listeners2.length).toBe(1);
+      expect(EventUtils.listeners($host).length).toBe(1);
+    });
+
+    test('Resubscribed listener does not observe initial target', () => {
+      targ1.click();
+      expect(handle).not.toBeCalled();
+    });
+
+    test('Resubscribed listener observes actual target', () => {
+      targ2.click();
+      expect(handle).toBeCalled();
+    });
+
+    test('EventUtils des not recreate instance', () => expect(listeners1[0]).toBe(listeners2[0]));
+  });
+
+  describe('EventUtils.subscribe subscribes single time', () => {
+    test('subscribe one event with the same handler does not leads to duplicate subscription', () => {
+      const host = document.createElement('div');
+      const fn = jest.fn();
+
+      EventUtils.subscribe(host, 'click', fn);
+      expect(EventUtils.listeners(host).length).toBe(1);
+      EventUtils.subscribe(host, 'click', fn);
+      expect(EventUtils.listeners(host).length).toBe(1);
+    });
+
+    test('subscribe one event with different handlers produces different subscriptions', () => {
+      const host = document.createElement('div');
+      const fn1 = jest.fn();
+      const fn2 = jest.fn();
+
+      EventUtils.subscribe(host, 'click', fn1);
+      expect(EventUtils.listeners(host).length).toBe(1);
+      EventUtils.subscribe(host, 'click', fn2);
+      expect(EventUtils.listeners(host).length).toBe(2);
+    });
+
+    test('subscribe considers descriptors with different selectors as different', () => {
+      const host = document.createElement('div');
+      const fn = jest.fn();
+
+      EventUtils.subscribe(host, {event: 'e', selector: 'a'}, fn);
+      expect(EventUtils.listeners(host).length).toBe(1);
+      EventUtils.subscribe(host, {event: 'e', selector: 'a'}, fn);
+      expect(EventUtils.listeners(host).length).toBe(1);
+      EventUtils.subscribe(host, {event: 'e', selector: 'b'}, fn);
+      expect(EventUtils.listeners(host).length).toBe(2);
+      EventUtils.subscribe(host, {event: 'e'}, fn);
+      expect(EventUtils.listeners(host).length).toBe(3);
+    });
+
+    test('subscribe considers descriptors with different targets as different', () => {
+      const host = document.createElement('div');
+      const target1 = document.createElement('div');
+      const target2 = document.createElement('div');
+      const fn = jest.fn();
+
+      EventUtils.subscribe(host, {event: 'e', target: target1}, fn);
+      expect(EventUtils.listeners(host).length).toBe(1);
+      EventUtils.subscribe(host, {event: 'e', target: target1}, fn);
+      expect(EventUtils.listeners(host).length).toBe(1);
+      EventUtils.subscribe(host, {event: 'e', target: target2}, fn);
+      expect(EventUtils.listeners(host).length).toBe(2);
+      EventUtils.subscribe(host, {event: 'e'}, fn);
+      expect(EventUtils.listeners(host).length).toBe(3);
+    });
+
+    test('subscribe considers descriptors with different capture phases as different', () => {
+      const host = document.createElement('div');
+      const fn = jest.fn();
+
+      EventUtils.subscribe(host, {event: 'e', capture: true}, fn);
+      expect(EventUtils.listeners(host).length).toBe(1);
+      EventUtils.subscribe(host, {event: 'e', capture: true}, fn);
+      expect(EventUtils.listeners(host).length).toBe(1);
+      EventUtils.subscribe(host, {event: 'e', capture: false}, fn);
+      expect(EventUtils.listeners(host).length).toBe(2);
+      EventUtils.subscribe(host, {event: 'e'}, fn);
+      expect(EventUtils.listeners(host).length).toBe(2);
+    });
+  });
+
+  // TODO: tests for `once` marker
+
+  // TODO: tests for find method
+
+  // TODO: Do we need to split it...
+});
diff --git a/src/modules/esl-event-listener/test/unsubscribe.test.ts b/src/modules/esl-event-listener/test/unsubscribe.test.ts
new file mode 100644
index 000000000..2fba42a9d
--- /dev/null
+++ b/src/modules/esl-event-listener/test/unsubscribe.test.ts
@@ -0,0 +1,48 @@
+import {EventUtils} from '../core';
+
+describe('EventUtils:unsubscribe successfully removes listener', () => {
+  const handle = jest.fn();
+  const div = document.createElement('div');
+
+  beforeEach(() => {
+    EventUtils.subscribe(div, {event: 'click'}, handle);
+    EventUtils.subscribe(div, {event: 'event'}, handle);
+  });
+
+  test('all', ()  => {
+    EventUtils.unsubscribe(div);
+    expect(EventUtils.listeners(div).length).toBe(0);
+  });
+
+  test('by event', ()  => {
+    EventUtils.unsubscribe(div, {event: 'mousedown'});
+    expect(EventUtils.listeners(div).length).toBe(2);
+    EventUtils.unsubscribe(div, {event: 'click'});
+    expect(EventUtils.listeners(div).length).toBe(1);
+    EventUtils.unsubscribe(div, {event: 'event'});
+    expect(EventUtils.listeners(div).length).toBe(0);
+  });
+
+  test('by event(string)', ()  => {
+    EventUtils.unsubscribe(div, 'test');
+    expect(EventUtils.listeners(div).length).toBe(2);
+    EventUtils.unsubscribe(div, 'click');
+    expect(EventUtils.listeners(div).length).toBe(1);
+    EventUtils.unsubscribe(div, 'event');
+    expect(EventUtils.listeners(div).length).toBe(0);
+  });
+
+  test('by handler', ()  => {
+    EventUtils.unsubscribe(div, () => 1);
+    expect(EventUtils.listeners(div).length).toBe(2);
+    EventUtils.unsubscribe(div, handle);
+    expect(EventUtils.listeners(div).length).toBe(0);
+  });
+
+  test('with multiple criteria', ()  => {
+    EventUtils.unsubscribe(div, handle, {event: 'key'});
+    expect(EventUtils.listeners(div).length).toBe(2);
+    EventUtils.unsubscribe(div, handle, {event: 'click'});
+    expect(EventUtils.listeners(div).length).toBe(1);
+  });
+});
diff --git a/src/modules/esl-event-listener/test/utils.test.ts b/src/modules/esl-event-listener/test/utils.test.ts
new file mode 100644
index 000000000..6e3c6046c
--- /dev/null
+++ b/src/modules/esl-event-listener/test/utils.test.ts
@@ -0,0 +1,49 @@
+import {EventUtils} from '../core';
+
+describe('dom/events: EventUtils', () => {
+  describe('dispatch', () => {
+    // TODO: more tests for dispatch method
+    test('dispatches event with custom event init on the provided element', () => {
+      const el = document.createElement('div');
+      jest.spyOn(el, 'dispatchEvent');
+
+      const eventName = `click${Math.random()}`;
+      const customEventInit = {detail: Math.random()};
+      EventUtils.dispatch(el, eventName, customEventInit);
+
+      expect(el.dispatchEvent).toHaveBeenCalled();
+
+      const event: CustomEvent = (el.dispatchEvent as jest.Mock).mock.calls[0][0];
+      expect(event.type).toBe(eventName);
+      expect((event as any).detail).toBe(customEventInit.detail);
+    });
+  });
+
+  // TODO: extend tests + possibly move descriptor finding to separate module?
+  describe('descriptors', () => {
+    test('basic test 1', () => {
+      const fn1 = () => undefined;
+      const fn2 = () => undefined;
+      fn1.event = fn2.event = 'test';
+
+      const obj = {onClick: fn1};
+      const proto = {onEvent: fn2};
+      Object.setPrototypeOf(obj, proto);
+
+      const desc = EventUtils.descriptors(obj);
+      expect(Array.isArray(desc)).toBe(true);
+      expect(desc.includes(fn1));
+      expect(desc.includes(fn2));
+    });
+    test('basic test 2', () => {
+      const obj: any = document.createElement('div');
+
+      expect(EventUtils.descriptors(obj)).toEqual([]);
+
+      obj.onEvent = Object.assign(() => undefined, {event: 'event', auto: true});
+
+      expect(EventUtils.descriptors(obj).length).toEqual(1);
+      expect(EventUtils.descriptors(obj)[0]).toEqual(obj.onEvent);
+    });
+  });
+});
diff --git a/src/modules/esl-mixin-element/test/mixin.listener.test.ts b/src/modules/esl-mixin-element/test/mixin.listener.test.ts
new file mode 100644
index 000000000..9ec565c6a
--- /dev/null
+++ b/src/modules/esl-mixin-element/test/mixin.listener.test.ts
@@ -0,0 +1,43 @@
+import {ESLMixinElement, listen} from '../core';
+import {EventUtils} from '../../esl-utils/dom/events';
+
+describe('ESLMixinElement: listeners', () => {
+  describe('ESLMixinElement auto subscribes to listener declarations', () => {
+    const mockHandler = jest.fn();
+    class TestElement extends ESLMixinElement {
+      static is = 'test-listen-mixin';
+
+      @listen('click')
+      public onClick(...args: any[]) { mockHandler(this, ...args); }
+    }
+    TestElement.register();
+
+    const el = document.createElement('div');
+    el.setAttribute(TestElement.is, '');
+
+    beforeAll(() => document.body.appendChild(el));
+
+    test('ESLMixinElement successfully auto subscribed', () => {
+      expect(EventUtils.listeners(el).length).toBe(1);
+      expect(EventUtils.listeners(el)[0].event).toBe('click');
+    });
+
+    test('ESLMixinElement subscription works correctly', () => {
+      mockHandler.mockReset();
+      el.click();
+      expect(mockHandler).toBeCalled();
+      expect(mockHandler).lastCalledWith(TestElement.get(el), expect.any(Event), expect.any(Object));
+    });
+
+    test('ESLMixinElement successfully auto unsubscribed', async () => {
+      document.body.removeChild(el);
+      await Promise.resolve(); // Wait for microtasks completed
+      expect(EventUtils.listeners(el).length).toBe(0);
+      return Promise.resolve();
+    });
+
+    afterAll(async () => el.parentElement && el.remove());
+  });
+
+  // TODO: $$on, $$off tests
+});
diff --git a/src/modules/esl-trigger/test/esl-trigger.a11y.test.ts b/src/modules/esl-trigger/test/esl-trigger.a11y.test.ts
index 81b2fb6a1..2d964c7e7 100644
--- a/src/modules/esl-trigger/test/esl-trigger.a11y.test.ts
+++ b/src/modules/esl-trigger/test/esl-trigger.a11y.test.ts
@@ -1,6 +1,6 @@
 import '../../../polyfills/es5-target-shim';
 import {SyntheticEventTarget} from '../../esl-utils/dom/events/target';
-import {EventUtils} from '../../esl-utils/dom/events/utils';
+import {EventUtils} from '../../esl-utils/dom/events';
 import {ESLTrigger} from '../core/esl-trigger';
 import type {ESLToggleable} from '../../esl-toggleable/core/esl-toggleable';
 
diff --git a/src/modules/esl-utils/decorators/test/listen.test.ts b/src/modules/esl-utils/decorators/test/listen.test.ts
index d67a0adbd..45d259903 100644
--- a/src/modules/esl-utils/decorators/test/listen.test.ts
+++ b/src/modules/esl-utils/decorators/test/listen.test.ts
@@ -33,7 +33,7 @@ describe('Decorator: @listen', () => {
     class Test extends HTMLElement {
       @listen({event: 'event1'})
       onEvent1() {}
-      @listen({event: 'event1', id: 'a'})
+      @listen({event: 'event1'})
       onEvent2() {}
     }
     customElements.define('test-listen-2', Test);
diff --git a/src/modules/esl-utils/dom/events/test/integration.test.ts b/src/modules/esl-utils/dom/events/test/integration.test.ts
deleted file mode 100644
index e8b0b2856..000000000
--- a/src/modules/esl-utils/dom/events/test/integration.test.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import {EventUtils} from '../utils';
-
-describe('EventUtils: integration', () => {
-  test('subscribe', ()  => {
-    const div = document.createElement('div');
-    const handle = jest.fn();
-
-    EventUtils.subscribe(div, {event: 'click'}, handle);
-
-    expect(EventUtils.listeners(div).length).toBe(1);
-
-    expect(handle).toBeCalledTimes(0);
-    div.click();
-    expect(handle).toBeCalledTimes(1);
-  });
-
-  test('subscribe delegate', ()  => {
-    const div = document.createElement('div');
-    const btn = document.createElement('button');
-    div.appendChild(btn);
-    const span = document.createElement('span');
-    div.appendChild(span);
-    const handle = jest.fn();
-    EventUtils.subscribe(div, {event: 'click', selector: 'button'}, handle);
-
-    expect(handle).toBeCalledTimes(0);
-    btn.click();
-    expect(handle).toBeCalledTimes(1);
-    span.click();
-    expect(handle).toBeCalledTimes(1);
-  });
-
-  describe('unsubscribe', () => {
-    const handle = jest.fn();
-    const div = document.createElement('div');
-
-    beforeEach(() => {
-      EventUtils.subscribe(div, {id: 'test', event: 'click'}, handle);
-    });
-
-    test('all', ()  => {
-      expect(EventUtils.listeners(div).length).toBe(1);
-      EventUtils.unsubscribe(div);
-      expect(EventUtils.listeners(div).length).toBe(0);
-    });
-
-    test('by id', ()  => {
-      EventUtils.unsubscribe(div, {id: 'testic'});
-      expect(EventUtils.listeners(div).length).toBe(1);
-      EventUtils.unsubscribe(div, {id: 'test'});
-      expect(EventUtils.listeners(div).length).toBe(0);
-    });
-
-    test('by event', ()  => {
-      EventUtils.unsubscribe(div, {event: 'mousedown'});
-      expect(EventUtils.listeners(div).length).toBe(1);
-      EventUtils.unsubscribe(div, {event: 'click'});
-      expect(EventUtils.listeners(div).length).toBe(0);
-    });
-
-    test('by event(string)', ()  => {
-      EventUtils.unsubscribe(div, 'test');
-      expect(EventUtils.listeners(div).length).toBe(1);
-      EventUtils.unsubscribe(div, 'click');
-      expect(EventUtils.listeners(div).length).toBe(0);
-    });
-
-    test('by handler', ()  => {
-      EventUtils.unsubscribe(div, () => 1);
-      expect(EventUtils.listeners(div).length).toBe(1);
-      EventUtils.unsubscribe(div, handle);
-      expect(EventUtils.listeners(div).length).toBe(0);
-    });
-
-    test('with multiple criteria', ()  => {
-      EventUtils.unsubscribe(div, handle, {event: 'key'});
-      expect(EventUtils.listeners(div).length).toBe(1);
-      EventUtils.unsubscribe(div, handle, {event: 'click'});
-      expect(EventUtils.listeners(div).length).toBe(0);
-    });
-  });
-});
diff --git a/src/modules/esl-utils/dom/events/test/listener.test.ts b/src/modules/esl-utils/dom/events/test/listener.test.ts
deleted file mode 100644
index fb3bff730..000000000
--- a/src/modules/esl-utils/dom/events/test/listener.test.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-import {ESLEventListener} from '../listener';
-
-describe('dom/events: ESLEventListener', () => {
-  describe('get', () => {
-    test('simple', () => {
-      expect(ESLEventListener.get()).toEqual([]);
-      expect(ESLEventListener.get({})).toEqual([]);
-    });
-    test('singleton', () => {
-      const obj = {};
-      const listeners = ESLEventListener.get(obj);
-      expect(ESLEventListener.get(obj)).toBe(listeners);
-    });
-  });
-
-  describe('create', () => {
-    test('without event type', () => {
-      const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => void 0);
-
-      const host = document.createElement('div');
-      const handler = jest.fn();
-      const result = ESLEventListener.create(host, handler, {event: ''});
-
-      expect(result.length).toBe(0);
-      expect(consoleSpy).toBeCalledTimes(1);
-      consoleSpy.mockClear();
-    });
-
-    test('one by string', () => {
-      const host = document.createElement('div');
-      const handler = jest.fn();
-      const result = ESLEventListener.create(host, handler, {event: 'click'});
-
-      expect(result.length).toBe(1);
-      expect(result[0].event).toBe('click');
-      expect(result[0].handler).toBe(handler);
-    });
-
-    test('via event provider', () => {
-      const host = document.createElement('div');
-      const handler = jest.fn();
-      const eventProvider = jest.fn(() => 'click');
-      const result = ESLEventListener.create(host, handler, {event: eventProvider});
-
-      expect(result.length).toBe(1);
-      expect(result[0].event).toBe('click');
-      expect(result[0].handler).toBe(handler);
-      expect(eventProvider).lastCalledWith(host);
-    });
-
-    test('via event provider with context', () => {
-      const context  = {};
-      const host = document.createElement('div');
-      const handler = jest.fn();
-      const eventProvider = jest.fn(() => 'click');
-      ESLEventListener.create(host, handler, {event: eventProvider, context});
-      expect(eventProvider).lastCalledWith(context);
-    });
-
-    test('multiple by string', () => {
-      const host = document.createElement('div');
-      const handler = jest.fn();
-      const result = ESLEventListener.create(host, handler, {event: 'click keydown keypress'});
-
-      expect(result.length).toBe(3);
-      expect(result[0].handler).toBe(handler);
-      expect(result[1].handler).toBe(handler);
-      expect(result[2].handler).toBe(handler);
-      expect(result.map((l: ESLEventListener) => l.event)).toEqual(['click', 'keydown', 'keypress']);
-    });
-
-    test('one by desc', () => {
-      const host = document.createElement('div');
-      const handler = jest.fn();
-      const result = ESLEventListener.create(host, handler, {event: 'keypress', id: 'test'});
-
-      expect(result.length).toBe(1);
-      expect(result[0].handler).toBe(handler);
-      expect(result[0].id).toBe('test');
-      expect(result[0].event).toBe('keypress');
-    });
-
-    test('multiple by desc', () => {
-      const host = document.createElement('div');
-      const handler = jest.fn();
-      const result = ESLEventListener.create(host, handler, {event: 'e1 e2', selector: 'button'});
-
-      expect(result.length).toBe(2);
-      expect(result[0].handler).toBe(handler);
-      expect(result[1].handler).toBe(handler);
-      expect(result[0].event).toBe('e1');
-      expect(result[1].event).toBe('e2');
-      expect(result[0].selector).toBe('button');
-      expect(result[1].selector).toBe('button');
-    });
-
-    test('option providers', () => {
-      const host = document.createElement('div');
-      const handler = jest.fn();
-      const eventProvider = jest.fn(() => 'test');
-      const targetProvider = jest.fn(() => '::not(btn)');
-      const selectorProvider = jest.fn(() => '.btn');
-
-      const [listener] = ESLEventListener.create(host, handler, {
-        event: eventProvider,
-        target: targetProvider,
-        selector: selectorProvider
-      });
-
-      expect(eventProvider).toBeCalledWith(host);
-      expect(targetProvider).toBeCalledWith(host);
-      expect(selectorProvider).toBeCalledWith(host);
-
-      expect(listener.event).toBe(eventProvider());
-      expect(listener.target).toBe(targetProvider());
-      expect(listener.selector).toBe(selectorProvider());
-    });
-
-    test('option providers vs context', () => {
-      const context  = {};
-      const host = document.createElement('div');
-      const handler = jest.fn();
-      const eventProvider = jest.fn(() => 'test');
-      const targetProvider = jest.fn(() => '::not(btn)');
-      const selectorProvider = jest.fn(() => '.btn');
-
-      ESLEventListener.create(host, handler, {
-        event: eventProvider,
-        target: targetProvider,
-        selector: selectorProvider,
-        context
-      });
-
-      expect(eventProvider).toBeCalledWith(context);
-      expect(targetProvider).toBeCalledWith(context);
-      expect(selectorProvider).toBeCalledWith(context);
-    });
-  });
-
-  describe('instance methods', () => {
-    const host = document.createElement('section');
-    host.innerHTML = `
-      <div class="btns">
-        <button class="btn">Test <span>1</span></button>
-        <button id="btn">2</button>
-      </div>
-    `;
-    const btn = host.querySelector('button.btn') as HTMLButtonElement;
-    const btnSpan = btn.querySelector('span') as HTMLSpanElement;
-    const btn2 = host.querySelector('#btn') as HTMLButtonElement;
-
-    test('simple subscribe', () => {
-      const handler = jest.fn();
-      const listener = new ESLEventListener(host, handler, {event: 'click'});
-      listener.subscribe();
-
-      expect(handler).toBeCalledTimes(0);
-      btnSpan.click();
-      expect(handler).toBeCalledTimes(1);
-    });
-
-    test('delegate subscribe', () => {
-      const handler = jest.fn();
-      const listener = new ESLEventListener(host, handler, {event: 'click', selector: '.btn'});
-      listener.subscribe();
-
-      expect(handler).toBeCalledTimes(0);
-      btnSpan.click();
-      expect(handler).toBeCalledTimes(1);
-      btn.click();
-      expect(handler).toBeCalledTimes(2);
-      btn2.click();
-      expect(handler).toBeCalledTimes(2);
-    });
-
-    test('target subscribe', () => {
-      const handler = jest.fn();
-      const listener = new ESLEventListener(host, handler, {event: 'click', target: '::find(#btn)'});
-      listener.subscribe();
-
-      expect(handler).toBeCalledTimes(0);
-      btn.click();
-      expect(handler).toBeCalledTimes(0);
-      btn2.click();
-      expect(handler).toBeCalledTimes(1);
-    });
-  });
-});
diff --git a/src/modules/esl-utils/dom/events/test/misc.test.ts b/src/modules/esl-utils/dom/events/test/misc.test.ts
index 1c2359402..ffd05a8fc 100644
--- a/src/modules/esl-utils/dom/events/test/misc.test.ts
+++ b/src/modules/esl-utils/dom/events/test/misc.test.ts
@@ -5,7 +5,8 @@ import {
   isPassiveByDefault,
   getTouchPoint,
   getOffsetPoint,
-  getCompositeTarget
+  getCompositeTarget,
+  splitEvents
 } from '../misc';
 
 describe('dom/events: misc', () => {
@@ -115,4 +116,35 @@ describe('dom/events: misc', () => {
       });
     });
   });
+
+  describe('splitEvents', () => {
+    test(
+      'Empty string parsed to an empty array without exceptions',
+      () => expect(splitEvents('')).toEqual([])
+    );
+    test(
+      'Single event string parsed to single event',
+      () => expect(splitEvents('abc')).toEqual(['abc'])
+    );
+    test(
+      'Single event string with extra spaces parsed to single event',
+      () => expect(splitEvents('  abc ')).toEqual(['abc'])
+    );
+    test(
+      'Multiple events string parsed to separate events',
+      () => expect(splitEvents('abc de f')).toEqual(['abc', 'de', 'f'])
+    );
+    test(
+      'Multiple events string with extra spaces parsed to separate events',
+      () => expect(splitEvents(' a b')).toEqual(['a', 'b'])
+    );
+    test(
+      'The same events count ones when parsed (two unique terms)',
+      () => expect(splitEvents(' a a b')).toEqual(['a', 'b'])
+    );
+    test(
+      'The same events count ones when parsed (one unique term)',
+      () => expect(splitEvents('a a a a a')).toEqual(['a'])
+    );
+  });
 });
diff --git a/src/modules/esl-utils/dom/events/test/utils.test.ts b/src/modules/esl-utils/dom/events/test/utils.test.ts
deleted file mode 100644
index 307e352f8..000000000
--- a/src/modules/esl-utils/dom/events/test/utils.test.ts
+++ /dev/null
@@ -1,163 +0,0 @@
-import {EventUtils} from '../utils';
-import {ESLEventListener} from '../listener';
-
-describe('dom/events: EventUtils', () => {
-  describe('dispatch', () => {
-    test('dispatches event with custom event init on the provided element', () => {
-      const el = document.createElement('div');
-      jest.spyOn(el, 'dispatchEvent');
-
-      const eventName = `click${Math.random()}`;
-      const customEventInit = {detail: Math.random()};
-      EventUtils.dispatch(el, eventName, customEventInit);
-
-      expect(el.dispatchEvent).toHaveBeenCalled();
-
-      const event: CustomEvent = (el.dispatchEvent as jest.Mock).mock.calls[0][0];
-      expect(event.type).toBe(eventName);
-      expect((event as any).detail).toBe(customEventInit.detail);
-    });
-  });
-
-  describe('descriptors', () => {
-    test('basic test 1', () => {
-      const fn1 = () => undefined;
-      const fn2 = () => undefined;
-      fn1.event = fn2.event = 'test';
-
-      const obj = {onClick: fn1};
-      const proto = {onEvent: fn2};
-      Object.setPrototypeOf(obj, proto);
-
-      const desc = EventUtils.descriptors(obj);
-      expect(Array.isArray(desc)).toBe(true);
-      expect(desc.includes(fn1));
-      expect(desc.includes(fn2));
-    });
-    test('basic test 2', () => {
-      const obj: any = document.createElement('div');
-
-      expect(EventUtils.descriptors(obj)).toEqual([]);
-
-      obj.onEvent = Object.assign(() => undefined, {event: 'event', auto: true});
-
-      expect(EventUtils.descriptors(obj).length).toEqual(1);
-      expect(EventUtils.descriptors(obj)[0]).toEqual(obj.onEvent);
-    });
-  });
-
-  describe('listeners', () => {
-    const list = [
-      {matches: jest.fn()},
-      {matches: jest.fn()}
-    ];
-    const host = {__listeners: list};
-
-    beforeEach(() => list.forEach(({matches}: any) => matches.mockReset()));
-    test('all', () => {
-      expect(EventUtils.listeners(host as any)).toEqual(list);
-    });
-    test('1 criteria', () => {
-      list[0].matches.mockReturnValue(false);
-      list[1].matches.mockReturnValue(true);
-
-      expect(EventUtils.listeners(host as any, 'a')).toEqual(list.slice(1));
-      expect(list[0].matches).lastCalledWith('a', expect.anything(), expect.anything());
-      expect(list[1].matches).lastCalledWith('a', expect.anything(), expect.anything());
-    });
-    test('2 criteria', () => {
-      list[0].matches.mockReturnValue(true);
-      list[1].matches.mockReturnValue(true);
-
-      expect(EventUtils.listeners(host as any, 'b', {})).toEqual(list);
-      expect(list[0].matches).toBeCalledTimes(2);
-      expect(list[1].matches).toBeCalledTimes(2);
-    });
-  });
-
-  describe('subscribe', () => {
-    const listener1 =
-      Object.assign(() => undefined, {auto: false, event: 'e1', subscribe: jest.fn()});
-
-    test('decorated handler', () => {
-      const host = {};
-      const createMock =
-        jest.spyOn(ESLEventListener, 'create').mockImplementation((el, cb, desc) => [desc] as any);
-
-      EventUtils.subscribe(host as any, listener1);
-      expect(listener1.subscribe).toBeCalled();
-      expect(createMock).toBeCalledWith(host, expect.anything(), expect.anything());
-    });
-
-    test('decorated handler with empty override', () => {
-      const host = {};
-      const createMock =
-        jest.spyOn(ESLEventListener, 'create').mockImplementation((el, cb, desc) => [desc] as any);
-
-      EventUtils.subscribe(host as any, {}, listener1);
-      expect(listener1.subscribe).toBeCalled();
-      expect(createMock).toBeCalledWith(host, expect.anything(), expect.anything());
-    });
-
-    test('merge decorated handler', () => {
-      const host = {};
-      const createMock =
-        jest.spyOn(ESLEventListener, 'create').mockImplementation((el, cb, desc) => [desc] as any);
-
-      EventUtils.subscribe(host as any, {event: 'e2'}, listener1);
-      expect(listener1.subscribe).toBeCalled();
-      const expDesc = Object.assign({}, listener1, {event: 'e2'});
-      expect(createMock).toBeCalledWith(host, expect.anything(), expDesc);
-    });
-
-    test('manual descriptor', () => {
-      const host = {};
-      const fn = jest.fn();
-      const desc = {event: 'test'};
-      const listener = {subscribe: jest.fn()};
-      const createMock =
-        jest.spyOn(ESLEventListener, 'create').mockImplementation(() => [listener] as any);
-
-      EventUtils.subscribe(host as any, desc, fn);
-      expect(listener.subscribe).toBeCalled();
-      expect(createMock).toBeCalledWith(host, fn, desc);
-    });
-
-    test('manual string descriptor', () => {
-      const host = {};
-      const fn = jest.fn();
-      const listener = {subscribe: jest.fn()};
-      const createMock =
-        jest.spyOn(ESLEventListener, 'create').mockImplementation(() => [listener] as any);
-
-      EventUtils.subscribe(host as any, 'click', fn);
-      expect(listener.subscribe).toBeCalled();
-      expect(createMock).toBeCalledWith(host, fn, {event: 'click'});
-    });
-  });
-
-  describe('unsubscribe', () => {
-    const list = [
-      {matches: jest.fn(), unsubscribe: jest.fn()},
-      {matches: jest.fn(), unsubscribe: jest.fn()}
-    ];
-    const host = {__listeners: list};
-
-    beforeEach(() => list.forEach(({matches, unsubscribe}: any) => {
-      matches.mockReset();
-      unsubscribe.mockReset();
-    }));
-    test('all', () => {
-      EventUtils.unsubscribe(host as any);
-      expect(list[0].unsubscribe).toBeCalled();
-      expect(list[1].unsubscribe).toBeCalled();
-    });
-    test('criteria', () => {
-      list[0].matches.mockReturnValue(false);
-      list[1].matches.mockReturnValue(true);
-      EventUtils.unsubscribe(host as any, 'test');
-      expect(list[0].unsubscribe).not.toBeCalled();
-      expect(list[1].unsubscribe).toBeCalled();
-    });
-  });
-});