Repository: exadel-inc/esl
Pull request #1243 (2022-09-26 15:52:58)

fix(esl-event-listener): `ESLMixinElement` + `ESLEventListener` complex cases


Patch:
diff --git a/pages/views/examples/tabs.njk b/pages/views/examples/tabs.njk
index fbbb5f0e8..7a15c088b 100644
--- a/pages/views/examples/tabs.njk
+++ b/pages/views/examples/tabs.njk
@@ -493,18 +493,85 @@ aside:
   </div>
 </section>
 
+
+<section>
+  <h2>Tabs (center aligned)</h2>
+  <p>Scrollable tabs component example</p>
+  {% code 'html', 'code-block' %}
+    <esl-tabs class="esl-d-tab-control center-alignment"/>
+    <esl-panel-group class="esl-d-tab-group"
+                     mode="tabs"
+                     mode-cls-target="::parent"/>
+  {% endcode %}
+
+  <div class="esl-d-tabs">
+    <esl-tabs class="esl-d-tab-control center-alignment">
+      <esl-a11y-group targets="::find(esl-tab)" activate-selected></esl-a11y-group>
+
+      <div class="arrow-prev" data-tab-direction="left">
+        <span class="icon-arrow-prev"></span>
+      </div>
+
+      <ul class="esl-d-tab-list esl-tab-container" role="tablist">
+        <li class="esl-d-tab-item">
+          <esl-tab class="h5"
+                   target="::parent(.esl-d-tabs)::find(.tab-1)">
+            Tab #1 Lorem ipsum dolor
+          </esl-tab>
+        </li>
+        <li class="esl-d-tab-item">
+          <esl-tab class="h5"
+                   target="::parent(.esl-d-tabs)::find(.tab-2)">
+            Tab #2 Lorem
+          </esl-tab>
+        </li>
+        <li class="esl-d-tab-item">
+          <esl-tab class="h5"
+                   target="::parent(.esl-d-tabs)::find(.tab-3)">
+            Tab #3 Lorem ipsum
+          </esl-tab>
+        </li>
+      </ul>
+
+      <div class="arrow-next" data-tab-direction="right">
+        <span class="icon-arrow-next"></span>
+      </div>
+    </esl-tabs>
+
+    <esl-panel-group class="esl-d-tab-group"
+                     mode="tabs"
+                     mode-cls-target="::parent">
+      <esl-panel class="esl-d-tab-panel tab-1" tabindex="0">
+        <div class="esl-d-tab-body">
+          {{ lorem.paragraphs(2) }}
+        </div>
+      </esl-panel>
+      <esl-panel open class="esl-d-tab-panel tab-2" tabindex="0">
+        <div class="esl-d-tab-body">
+          {{ lorem.paragraphs(1) }}
+        </div>
+      </esl-panel>
+      <esl-panel class="esl-d-tab-panel tab-3" tabindex="0">
+        <div class="esl-d-tab-body">
+          {{ lorem.paragraphs(3) }}
+        </div>
+      </esl-panel>
+    </esl-panel-group>
+  </div>
+</section>
+
 <section>
   <h2>Tabs (scrollable, center aligned)</h2>
   <p>Scrollable tabs component example</p>
   {% code 'html', 'code-block' %}
-    <esl-tabs class="esl-d-tab-control" scrollable="center"/>
+    <esl-tabs class="esl-d-tab-control center-alignment" scrollable="center"/>
     <esl-panel-group class="esl-d-tab-group"
                      mode="tabs"
                      mode-cls-target="::parent"/>
   {% endcode %}
 
   <div class="esl-d-tabs">
-    <esl-tabs class="esl-d-tab-control" scrollable="center">
+    <esl-tabs class="esl-d-tab-control center-alignment" scrollable="center">
       <esl-a11y-group targets="::find(esl-tab)" activate-selected></esl-a11y-group>
 
       <div class="arrow-prev" data-tab-direction="left">
diff --git a/src/modules/esl-base-element/core/esl-base-element.ts b/src/modules/esl-base-element/core/esl-base-element.ts
index c652d0dc4..bdd60c533 100644
--- a/src/modules/esl-base-element/core/esl-base-element.ts
+++ b/src/modules/esl-base-element/core/esl-base-element.ts
@@ -28,8 +28,7 @@ export abstract class ESLBaseElement extends HTMLElement {
     this._connected = true;
     this.classList.add((this.constructor as typeof ESLBaseElement).is);
 
-    ESLEventUtils.descriptors(this)
-      .forEach((desc) => ESLEventUtils.subscribe(this, desc));
+    ESLEventUtils.descriptors(this).forEach((desc) => ESLEventUtils.subscribe(this, desc));
   }
   protected disconnectedCallback(): void {
     this._connected = false;
diff --git a/src/modules/esl-event-listener/core/api.ts b/src/modules/esl-event-listener/core/api.ts
index ad57b488e..6ca892848 100644
--- a/src/modules/esl-event-listener/core/api.ts
+++ b/src/modules/esl-event-listener/core/api.ts
@@ -28,8 +28,8 @@ export class ESLEventUtils {
   }
 
   /** Gets {@link ESLListenerDescriptorFn}s of the passed object */
-  public static descriptors(target: any, auto: boolean = true): ESLListenerDescriptorFn[] {
-    return extractValues(target, (value) => ESLEventUtils.isEventDescriptor(value) && value.auto === auto);
+  public static descriptors(host: unknown, auto: boolean = true): ESLListenerDescriptorFn[] {
+    return extractValues(host, (value) => ESLEventUtils.isEventDescriptor(value) && value.auto === auto);
   }
 
   /**
@@ -37,26 +37,26 @@ export class ESLEventUtils {
    * @param host - host element (listeners context)
    * @param criteria - optional set of criteria {@link ESLListenerCriteria} to filter listeners list
    */
-  public static listeners(host: HTMLElement, ...criteria: ESLListenerCriteria[]): ESLEventListener[] {
+  public static listeners(host: unknown, ...criteria: ESLListenerCriteria[]): ESLEventListener[] {
     return ESLEventListener.get(host, ...criteria);
   }
 
   /** Subscribes decorated `handler` method of the `host` */
-  public static subscribe(host: HTMLElement, handler: ESLListenerHandler): ESLEventListener[];
+  public static subscribe(host: unknown, handler: ESLListenerHandler): ESLEventListener[];
   /** Subscribes `handler` function with the passed event type */
   public static subscribe<EType extends keyof ESLListenerEventMap>(
-    host: HTMLElement,
+    host: unknown,
     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>(
-    host: HTMLElement,
+    host: unknown,
     descriptor: Partial<ESLListenerDescriptor>,
     handler: ESLListenerDescriptorFn<EType>
   ): ESLEventListener[];
   public static subscribe(
-    host: HTMLElement,
+    host: unknown,
     eventDesc: string | Partial<ESLListenerDescriptor> | ESLListenerHandler,
     handler: ESLListenerHandler = eventDesc as ESLListenerDescriptorFn
   ): ESLEventListener[] {
@@ -75,7 +75,7 @@ export class ESLEventUtils {
    * @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(host: HTMLElement, ...criteria: ESLListenerCriteria[]): ESLEventListener[] {
+  public static unsubscribe(host: unknown, ...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/listener.ts b/src/modules/esl-event-listener/core/listener.ts
index 1667341d6..bf8b9010b 100644
--- a/src/modules/esl-event-listener/core/listener.ts
+++ b/src/modules/esl-event-listener/core/listener.ts
@@ -1,6 +1,7 @@
 import {wrap} from '../../esl-utils/misc/array';
 import {resolveProperty} from '../../esl-utils/misc/functions';
 import {memoize} from '../../esl-utils/decorators/memoize';
+import {isObject} from '../../esl-utils/misc/object/types';
 import {isSimilar} from '../../esl-utils/misc/object/compare';
 import {ESLTraversingQuery} from '../../esl-traversing-query/core';
 import {isPassiveByDefault} from '../../esl-utils/dom/events/misc';
@@ -45,10 +46,9 @@ export class ESLEventListener implements ESLListenerDefinition, EventListenerObj
   public readonly once?: boolean;
   public readonly auto?: boolean;
   public readonly passive?: boolean;
-  public readonly context?: unknown;
 
   protected constructor(
-    public readonly $host: HTMLElement,
+    public readonly host: object,
     public readonly event: string,
     public readonly handler: ESLListenerHandler,
     desc: ESLListenerDescriptor
@@ -63,16 +63,19 @@ export class ESLEventListener implements ESLListenerDefinition, EventListenerObj
   /** @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 ESLTraversingQuery.all(target, this.$host);
-    if (typeof target === 'object' && target) return wrap(target);
+    if (!isObject(this.host)) return [];
+    const target = resolveProperty(this.target, this.host);
+    if (isObject(target)) return wrap(target);
+    const $host  = '$host' in this.host ? this.host.$host : this.host;
+    if (!($host instanceof HTMLElement)) return [];
+    if (typeof target === 'undefined') return [$host];
+    if (typeof target === 'string') return ESLTraversingQuery.all(target, $host);
     return [];
   }
 
   /** @returns resolved selector to check event target */
   public get delegate(): string | undefined {
-    return resolveProperty(this.selector, this.context || this.$host);
+    return resolveProperty(this.selector, this.host);
   }
 
   /**
@@ -92,7 +95,7 @@ export class ESLEventListener implements ESLListenerDefinition, EventListenerObj
   /** 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);
+    this.handler.call(this.host, e, this);
     if (this.once) this.unsubscribe();
   }
 
@@ -116,7 +119,7 @@ export class ESLEventListener implements ESLListenerDefinition, EventListenerObj
     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);
+    ESLEventListener.add(this.host, this);
     return true;
   }
 
@@ -125,7 +128,7 @@ export class ESLEventListener implements ESLListenerDefinition, EventListenerObj
     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);
+    ESLEventListener.remove(this.host, this);
   }
 
   /**
@@ -152,9 +155,9 @@ export class ESLEventListener implements ESLListenerDefinition, EventListenerObj
   }
 
   /** 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);
-
+  public static createOrResolve(host: unknown, handler: ESLListenerHandler, desc: ESLListenerDescriptor): ESLEventListener[] {
+    if (!isObject(host)) return [];
+    const eventString = resolveProperty(desc.event, host);
     const listeners: ESLEventListener[] = [];
     for (const event of splitEvents(eventString)) {
       const subscribed = ESLEventListener.get(host, event, handler, {
diff --git a/src/modules/esl-event-listener/core/types.ts b/src/modules/esl-event-listener/core/types.ts
index 9a76b7e07..818d67555 100644
--- a/src/modules/esl-event-listener/core/types.ts
+++ b/src/modules/esl-event-listener/core/types.ts
@@ -34,12 +34,6 @@ export type ESLListenerDescriptor<EType extends keyof ESLListenerEventMap = stri
    */
   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 */
diff --git a/src/modules/esl-mixin-element/ui/esl-mixin-element.ts b/src/modules/esl-mixin-element/ui/esl-mixin-element.ts
index 38e5eefa1..421a97857 100644
--- a/src/modules/esl-mixin-element/ui/esl-mixin-element.ts
+++ b/src/modules/esl-mixin-element/ui/esl-mixin-element.ts
@@ -42,15 +42,14 @@ export class ESLMixinElement implements AttributeTarget {
       this._attr$$.observe(this.$host, {attributes: true, attributeFilter: constructor.observedAttributes});
     }
 
-    ESLEventUtils.descriptors(this)
-      .forEach((desc) => ESLEventUtils.subscribe(this.$host, {context: this}, desc));
+    ESLEventUtils.descriptors(this).forEach((desc) => ESLEventUtils.subscribe(this, desc));
   }
 
   /** Callback to execute on mixin instance destroy */
   public disconnectedCallback(): void {
     if (this._attr$$) this._attr$$.disconnect();
 
-    ESLEventUtils.unsubscribe(this.$host, {context: this});
+    ESLEventUtils.unsubscribe(this);
   }
 
   /** Callback to handle changing of additional attributes */
@@ -73,13 +72,12 @@ export class ESLMixinElement implements AttributeTarget {
     handler: ESLListenerHandler<ESLListenerEventMap[EType]>
   ): ESLEventListener[];
   public $$on(event: any, handler?: any): ESLEventListener[] {
-    event = Object.assign(typeof event === 'string' ? {event} : event, {context: this});
-    return ESLEventUtils.subscribe(this.$host, event, handler);
+    return ESLEventUtils.subscribe(this, event, handler);
   }
 
   /** Unsubscribes event listener */
   public $$off(...condition: ESLListenerCriteria[]): ESLEventListener[] {
-    return ESLEventUtils.unsubscribe(this.$host, {context: this}, ...condition);
+    return ESLEventUtils.unsubscribe(this, ...condition);
   }
 
   /**


Test patch:
diff --git a/src/modules/esl-mixin-element/test/mixin.listener-herpers.test.ts b/src/modules/esl-mixin-element/test/mixin.listener-herpers.test.ts
new file mode 100644
index 000000000..749133ff8
--- /dev/null
+++ b/src/modules/esl-mixin-element/test/mixin.listener-herpers.test.ts
@@ -0,0 +1,32 @@
+import {ESLMixinElement} from '../ui/esl-mixin-element';
+import {EventUtils} from '../../esl-event-listener/core/api';
+
+describe('Shortcut helpers for EventUtils', () => {
+  class TestHelpersMixin extends ESLMixinElement {
+    static is = 'esl-test-helpers-mixin';
+
+    public onEvent = jest.fn();
+  }
+  TestHelpersMixin.register();
+  const $el = document.createElement('div');
+  $el.setAttribute(TestHelpersMixin.is, '');
+
+  beforeAll(() => document.body.appendChild($el));
+  afterAll(() => document.body.removeChild($el));
+
+  test('$$on call leads to correct subscribe call with mixin as a host', () => {
+    const mock = jest.spyOn(EventUtils, 'subscribe');
+    const mixin = TestHelpersMixin.get($el) as TestHelpersMixin;
+    const props = {event: 'test'};
+    mixin.$$on(props, mixin.onEvent);
+    expect(mock).lastCalledWith(mixin, props, mixin.onEvent);
+  });
+
+  test('$$off call leads to correct unsubscribe call with mixin as a host', () => {
+    const mock = jest.spyOn(EventUtils, 'unsubscribe');
+    const mixin = TestHelpersMixin.get($el) as TestHelpersMixin;
+    const props = {event: 'test'};
+    mixin.$$off(props, mixin.onEvent);
+    expect(mock).lastCalledWith(mixin, props, mixin.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
index d2fd16b00..a31c1ef74 100644
--- a/src/modules/esl-mixin-element/test/mixin.listener.test.ts
+++ b/src/modules/esl-mixin-element/test/mixin.listener.test.ts
@@ -18,8 +18,9 @@ describe('ESLMixinElement: listeners', () => {
     beforeAll(() => document.body.appendChild(el));
 
     test('ESLMixinElement successfully auto subscribed', () => {
-      expect(ESLEventUtils.listeners(el).length).toBe(1);
-      expect(ESLEventUtils.listeners(el)[0].event).toBe('click');
+      const host = TestElement.get(el);
+      expect(ESLEventUtils.listeners(host).length).toBe(1);
+      expect(ESLEventUtils.listeners(host)[0].event).toBe('click');
     });
 
     test('ESLMixinElement subscription works correctly', () => {
@@ -38,6 +39,4 @@ describe('ESLMixinElement: listeners', () => {
 
     afterAll(async () => el.parentElement && el.remove());
   });
-
-  // TODO: $$on, $$off tests
 });