Repository: exadel-inc/esl
Pull request #2439 (2024-05-29 01:17:10)

feat(esl-event-listener): add support for criteria-based subscriptions
Closes: #2382

Hey @exadel-inc/esl-core-team,

I've submitted my final PR in the series of event listener enhancements, aiming to simplify re-subscription use cases.

You'll notice that a significant number of tests were added and restructured prior to implementation. This approach helps ensure backward compatibility and basic functionality. The subscribe method should now appear both simple and powerful.

However, there is still a considerable risk of issues, and it is not fully type-secured. Therefore, I kindly ask for a very thorough review. If you manage to break the current subscribe method, it confirms my concerns about its potential problems.

Thank you!


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63f102835..6f78866da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,20 +1,3 @@
-## [5.0.0-beta.18](https://github.com/exadel-inc/esl/compare/v5.0.0-beta.17...v5.0.0-beta.18) (2024-05-24)
-
-
-### Features
-
-* **esl-carousel:** Default Renderer: the reordering algorithm improved to respect slides semantical order an available limit of slides ([b2efe6f](https://github.com/exadel-inc/esl/commit/b2efe6f2371ec97d96e943f50165f952a39893ac))
-* **esl-media:** `fill-mode` option updated to render through `aspect-ratio` and `wide` marker ([49fd5b6](https://github.com/exadel-inc/esl/commit/49fd5b6ffd29c43da4b23a25d11ec5a2481b47cd))
-
-
-### Bug Fixes
-
-* **deps:** bump puppeteer from 22.9.0 to 22.10.0 ([d82fa11](https://github.com/exadel-inc/esl/commit/d82fa11e6059fe34eaaca7f0990e8d013826fca7))
-* **esl-media:** fix missing reinitialization on start-time attribute changing ([e5922ed](https://github.com/exadel-inc/esl/commit/e5922ed801b08066c96dba10edef6e8abb6655b2))
-* **esl-media:** fix TSX shape for supporting start-time attribute ([1f43a22](https://github.com/exadel-inc/esl/commit/1f43a22094db5f204f0b69df36272d60beffb638))
-* **esl-media:** play-in-viewport tolerance changed to 50% (75% before) ([06d2a13](https://github.com/exadel-inc/esl/commit/06d2a13ca0bbde6c5d4fbd93e6a68f94fe93ef4e))
-* **esl-panel:** fix the falsy state of animation in the default open panel in esl-panel-group ([64925e0](https://github.com/exadel-inc/esl/commit/64925e0b47293124430f988474df821c423cc2bd))
-
 ## [5.0.0-beta.17](https://github.com/exadel-inc/esl/compare/v5.0.0-beta.16...v5.0.0-beta.17) (2024-05-21)
 
 
diff --git a/eslint/package.json b/eslint/package.json
index 7c6a1ddc6..35bb7e068 100644
--- a/eslint/package.json
+++ b/eslint/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@exadel/eslint-plugin-esl",
-  "version": "5.0.0-beta.18",
+  "version": "5.0.0-beta.17",
   "keywords": [
     "eslint",
     "eslint-plugin",
diff --git a/package-lock.json b/package-lock.json
index 9504dc575..9b521c059 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@exadel/esl",
-  "version": "5.0.0-beta.18",
+  "version": "5.0.0-beta.17",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@exadel/esl",
-      "version": "5.0.0-beta.18",
+      "version": "5.0.0-beta.17",
       "license": "MIT",
       "workspaces": [
         "e2e",
@@ -24,7 +24,7 @@
         "@semantic-release/changelog": "^6.0.3",
         "@semantic-release/commit-analyzer": "^12.0.0",
         "@semantic-release/git": "^10.0.1",
-        "@semantic-release/github": "^10.0.5",
+        "@semantic-release/github": "^10.0.4",
         "@semantic-release/npm": "^12.0.1",
         "@semantic-release/release-notes-generator": "^13.0.0",
         "@types/jest": "^29.5.12",
@@ -60,7 +60,7 @@
     },
     "e2e": {
       "name": "@exadel/esl-snapshot-tests",
-      "version": "5.0.0-beta.18",
+      "version": "5.0.0-beta.17",
       "license": "MIT",
       "dependencies": {
         "@types/jest-environment-puppeteer": "^5.0.6",
@@ -68,13 +68,13 @@
         "@types/puppeteer": "^7.0.4",
         "jest-image-snapshot": "^6.4.0",
         "jest-puppeteer": "^10.0.1",
-        "puppeteer": "^22.10.0",
+        "puppeteer": "^22.9.0",
         "stucumber": "^0.19.0"
       }
     },
     "eslint": {
       "name": "@exadel/eslint-plugin-esl",
-      "version": "5.0.0-beta.18",
+      "version": "5.0.0-beta.17",
       "license": "MIT",
       "peerDependencies": {
         "@exadel/esl": "*",
@@ -2921,9 +2921,9 @@
       }
     },
     "node_modules/@semantic-release/github": {
-      "version": "10.0.5",
-      "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-10.0.5.tgz",
-      "integrity": "sha512-hmuCDkfru/Uc9+ZBNOSremAupu6BCslvOVDiG0wYcL8TQodCycp6uvwDyeym1H0M4l3ob9c0s0xMBiZjjXQ2yA==",
+      "version": "10.0.4",
+      "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-10.0.4.tgz",
+      "integrity": "sha512-eHCfW3jlzax5OKd5nTovlvoCdg+b9YBa31M2JGT1KmzmJR3TIalu2ahVU+V/xATVeOx+S8fbCiPnBBgMlzH5Vw==",
       "dev": true,
       "dependencies": {
         "@octokit/core": "^6.0.0",
@@ -4839,9 +4839,9 @@
       "optional": true
     },
     "node_modules/bare-path": {
-      "version": "2.1.3",
-      "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz",
-      "integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==",
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.2.tgz",
+      "integrity": "sha512-o7KSt4prEphWUHa3QUwCxUI00R86VdjiuxmJK0iNVDHYPGo+HsDaVCnqCmPbf/MiW1ok8F4p3m8RTHlWk8K2ig==",
       "optional": true,
       "dependencies": {
         "bare-os": "^2.1.0"
@@ -17791,15 +17791,15 @@
       }
     },
     "node_modules/puppeteer": {
-      "version": "22.10.0",
-      "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.10.0.tgz",
-      "integrity": "sha512-ZOkZd6a6t0BdKcWb0wAYHWQqCfdlN1PPnXOmg/XNrbo6gJhYWFX4qCNb6ahSn8TpAqBqLCoD4Q010F7GwOM7mA==",
+      "version": "22.9.0",
+      "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.9.0.tgz",
+      "integrity": "sha512-yNux2cm6Sfik4lNLNjJ25Cdn9spJRbMXxl1YZtVZCEhEeej1sFlCvZ/Cr64LhgyJOuvz3iq2uk+RLFpQpGwrjw==",
       "hasInstallScript": true,
       "dependencies": {
         "@puppeteer/browsers": "2.2.3",
         "cosmiconfig": "9.0.0",
         "devtools-protocol": "0.0.1286932",
-        "puppeteer-core": "22.10.0"
+        "puppeteer-core": "22.9.0"
       },
       "bin": {
         "puppeteer": "lib/esm/puppeteer/node/cli.js"
@@ -17809,9 +17809,9 @@
       }
     },
     "node_modules/puppeteer-core": {
-      "version": "22.10.0",
-      "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.10.0.tgz",
-      "integrity": "sha512-I54J4Vy4I07UHsgB1QSmuFoF7KNQjJWcvFBPhtY+ezMdBfwgGDr8dzYrJa11aPgP9kxIUHjhktcMmmfJkOAtTw==",
+      "version": "22.9.0",
+      "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.9.0.tgz",
+      "integrity": "sha512-Q2SYVZ1SIE7jCd/Pp+1/mNLFtdJfGvAF+CqOTDG8HcCNCiBvoXfopXfOfMHQ/FueXhGfJW/I6DartWv6QzpNGg==",
       "dependencies": {
         "@puppeteer/browsers": "2.2.3",
         "chromium-bidi": "0.5.19",
@@ -21575,7 +21575,7 @@
     },
     "site": {
       "name": "@exadel/esl-website",
-      "version": "5.0.0-beta.18",
+      "version": "5.0.0-beta.17",
       "license": "MIT",
       "dependencies": {
         "@11ty/eleventy": "^2.0.1",
diff --git a/package.json b/package.json
index f8bec3705..8f626a3d0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@exadel/esl",
-  "version": "5.0.0-beta.18",
+  "version": "5.0.0-beta.17",
   "private": false,
   "description": "Exadel Smart Library (ESL) is the lightweight custom elements library that provide a set of super-flexible components",
   "license": "MIT",
@@ -100,7 +100,7 @@
     "@semantic-release/changelog": "^6.0.3",
     "@semantic-release/commit-analyzer": "^12.0.0",
     "@semantic-release/git": "^10.0.1",
-    "@semantic-release/github": "^10.0.5",
+    "@semantic-release/github": "^10.0.4",
     "@semantic-release/npm": "^12.0.1",
     "@semantic-release/release-notes-generator": "^13.0.0",
     "@types/jest": "^29.5.12",
diff --git a/site/package.json b/site/package.json
index f9f55f3e8..774ee89cb 100644
--- a/site/package.json
+++ b/site/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@exadel/esl-website",
-  "version": "5.0.0-beta.18",
+  "version": "5.0.0-beta.17",
   "private": true,
   "description": "ESL Website source",
   "homepage": "https://esl-ui.com/",
diff --git a/site/views/examples/carousel.marquee.njk b/site/views/examples/carousel.marquee.njk
index f76ed0116..2d5996fb7 100644
--- a/site/views/examples/carousel.marquee.njk
+++ b/site/views/examples/carousel.marquee.njk
@@ -16,7 +16,7 @@ icon: examples/carousel.svg
       <ul esl-carousel-slides>
         {% for item in slides %}
         <li esl-carousel-slide>
-          <div class="img-container img-container-16-9">
+          <div class="img-container">
             <esl-image mode="cover"
                        lazy
                        data-alt="{{ 'Carousel slide ' + loop.index }}"
diff --git a/site/views/examples/embedded-media.njk b/site/views/examples/embedded-media.njk
index 2949366b6..8f06c6b45 100644
--- a/site/views/examples/embedded-media.njk
+++ b/site/views/examples/embedded-media.njk
@@ -281,6 +281,7 @@ aside:
 
     <uip-snippets class="uip-toolbar" dropdown-view="@xs"></uip-snippets>
     <uip-preview></uip-preview>
+    <uip-editor label="Source code" collapsible copy></uip-editor>
     <uip-editor label="Source code (HTML)" collapsible copy></uip-editor>
     <uip-editor source="js" label="Source code (JS)" collapsible collapsed copy></uip-editor>
   </uip-root>
diff --git a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts
index 1b9a2187d..11e34e9a1 100644
--- a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts
+++ b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts
@@ -15,6 +15,8 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
   /** Tolerance to treat offset enough to move to the next slide. Relative (0-1) to slide width */
   public static readonly NEXT_SLIDE_TOLERANCE = 0.25;
 
+  /** Min slides to position from both sides if possible */
+  protected reserve: number = 1;
   /** Slides gap size */
   protected gap: number = 0;
   /** Slide size cached value */
@@ -87,7 +89,7 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
   /** Post-processing animation action. */
   public override async onAfterAnimate(): Promise<void> {
     // Make sure we end up in a defined state on transition end
-    this.reorder();
+    this.reorder(this.currentIndex);
     this.setTransformOffset(-this.getOffset(this.currentIndex));
     this.$carousel.$$attr('active', false);
   }
@@ -97,7 +99,7 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
     const index = normalize(this.currentIndex + (direction === 'next' ? 1 : -1), this.size);
 
     // Make sure there is a slide in required direction
-    this.reorder(direction === 'prev');
+    this.reorder(index, direction !== 'prev');
 
     const offsetFrom = -this.getOffset(this.currentIndex);
     this.setTransformOffset(offsetFrom);
@@ -129,8 +131,11 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
     // check right border of non-loop state
     if (!this.loop && offset < 0 && index + 1 + this.count > this.$carousel.size) return;
 
-    this.currentIndex = normalize(index, this.size);
-    this.reorder(offset > 0);
+    const currentIndex = normalize(index, this.size);
+    const orderIndex = offset < 0 ? currentIndex : normalize(currentIndex - 1, this.size);
+
+    this.reorder(orderIndex);
+    this.currentIndex = currentIndex;
 
     const stageOffset = this.getOffset(this.currentIndex) - (offset % slideSize);
     this.setTransformOffset(-stageOffset);
@@ -158,30 +163,22 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
     }
     this.$carousel.$$attr('animating', false);
 
-    this.reorder();
+    this.reorder(this.currentIndex);
     this.setTransformOffset(-this.getOffset(this.currentIndex));
     this.setActive(this.currentIndex, {direction: sign > 0 ? 'next' : 'prev'});
     this.$carousel.$$attr('active', false);
   }
 
-  /**
-   * Sets order style property for slides starting at index
-   * @param back - if true, ensures that there is a slide rendered before the current one
-   */
-  protected reorder(back?: boolean): void {
-    const {size, count, loop, currentIndex, $slides} = this;
-
-    const reserve = ((): number => {
-      const freeSlides = size - count;
-      // no need to reorder if there are no free slides or loop is disabled
-      if (!loop || !freeSlides) return 0;
-      // if back option is not set, prefer to reserve slides with respect to semantic order
-      if (typeof back !== 'boolean') back = !!currentIndex;
-      // otherwise, ensure that there are at least half of free slides reserved (if the back option is set - round up, otherwise - round down)
-      return Math.min(count, back ? Math.ceil(freeSlides / 2) : Math.floor(freeSlides / 2));
-    })();
-
-    const index = loop ? currentIndex : 0;
+  /** Sets order style property for slides starting at index */
+  protected reorder(index: number = this.currentIndex, back?: boolean): void {
+    if (index < 0 || index > this.$carousel.size) return;
+    const {size, $slides} = this;
+    if (!$slides.length) return;
+
+    // max reserve limited to a half of free slides, unless backward reorder requested (for back animation)
+    const maxReserve = Math.max(back ? 1 : 0, Math.floor((this.size - this.count) / 2));
+    // reserve slides from both sides if requested (or for loop carousel by default); reserve in normal direction is in priority
+    const reserve = Math.min((typeof back === 'boolean' ? back : this.loop) ? this.reserve : 0, maxReserve);
     for (let i = 0; i < size; ++i) {
       let offset = (size + i - index) % size;
       // inverses index for backward reserve
diff --git a/src/modules/esl-event-listener/core/api.ts b/src/modules/esl-event-listener/core/api.ts
index 7cd396ced..65f075433 100644
--- a/src/modules/esl-event-listener/core/api.ts
+++ b/src/modules/esl-event-listener/core/api.ts
@@ -6,6 +6,7 @@ import {getDescriptors, isEventDescriptor, initDescriptor} from './descriptors';
 
 import type {
   ESLListenerHandler,
+  ESLListenerCriteria,
   ESLListenerDescriptor,
   ESLListenerDescriptorFn
 } from './types';
@@ -56,12 +57,21 @@ export class ESLEventUtils {
    * @param host - host object (listeners context) to find descriptors and associate subscription
    * */
   public static subscribe(host: object): ESLEventListener[];
+  /** Subscribes (or resubscribes) all known descriptors that matches criteria */
+  public static subscribe(host: object, criteria: ESLListenerCriteria): ESLEventListener[];
   /**
    * Subscribes decorated as an {@link ESLListenerDescriptorFn} `handler` function
    * @param host - host object (listeners context) to associate subscription
    * @param handler - handler function decorated as {@link ESLListenerDescriptorFn}
    */
   public static subscribe(host: object, handler: ESLListenerHandler): ESLEventListener[];
+  /**
+   * Subscribes all descriptors that matches criteria, with a passed descriptor override
+   * @param host - host object (listeners context) to associate subscription
+   * @param descriptor - event or {@link ESLListenerDescriptor} with defined event type
+   * @param criteria - optional set of criteria {@link ESLListenerCriteria} to filter listeners list
+   */
+  public static subscribe(host: object, descriptor: Partial<ESLListenerDescriptor>, criteria: ESLListenerCriteria): ESLEventListener[];
   /**
    * Subscribes `handler` function with the passed event type or {@link ESLListenerDescriptor} with event type declared
    * @param host - host object (listeners context) to associate subscription
@@ -86,15 +96,15 @@ export class ESLEventUtils {
   ): ESLEventListener[];
   public static subscribe(
     host: object,
-    eventDesc?: string | Partial<ESLListenerDescriptor> | ESLListenerHandler,
-    handler: ESLListenerHandler = eventDesc as ESLListenerDescriptorFn
+    eventDesc: any = {auto: true},
+    handler: any = eventDesc
   ): ESLEventListener[] {
-    if (arguments.length === 1) {
-      const descriptors = getDescriptors(host, {auto: true});
-      return descriptors.flatMap((desc) => ESLEventUtils.subscribe(host, desc));
+    if (typeof eventDesc === 'string') eventDesc = {event: eventDesc} as ESLListenerDescriptor;
+    if (typeof handler === 'function') {
+      if (typeof eventDesc === 'function' && eventDesc !== handler) throw new Error('[ESL] Multiple handler functions passed');
+      return ESLEventListener.subscribe(host, handler, eventDesc);
     }
-    const desc = typeof eventDesc === 'string' ? {event: eventDesc} : eventDesc as ESLListenerDescriptor;
-    return ESLEventListener.subscribe(host, handler, desc);
+    return getDescriptors(host, handler).flatMap((desc) => ESLEventListener.subscribe(host, desc, eventDesc));
   }
 
   /**
diff --git a/src/modules/esl-event-listener/core/descriptors.ts b/src/modules/esl-event-listener/core/descriptors.ts
index 9bb4b8a02..8375d717a 100644
--- a/src/modules/esl-event-listener/core/descriptors.ts
+++ b/src/modules/esl-event-listener/core/descriptors.ts
@@ -79,12 +79,12 @@ export function initDescriptor<T extends object>(
   desc: ESLListenerDescriptorExt
 ): ESLListenerDescriptorFn {
   const fn = host[key];
-  if (typeof fn !== 'function') throw new TypeError(`[ESL] Init Descriptor: ${key} is not a function`);
+  if (typeof fn !== 'function') throw new TypeError(`[ESL] Descriptor '${key}' is not a function`);
 
   // Inherit event meta information from the prototype key
   if (desc.inherit) {
     const superDesc = Object.getPrototypeOf(host)[key];
-    if (!isEventDescriptor(superDesc)) throw new ReferenceError(`[ESL] Init Descriptor: no parent event descriptor found for ${key}`);
+    if (!isEventDescriptor(superDesc)) throw new ReferenceError(`[ESL] No parent event descriptor found for '${key}'`);
     desc = Object.assign({auto: false}, superDesc, desc);
   } else {
     desc = Object.assign({auto: false}, desc);
diff --git a/src/modules/esl-media/core/esl-media-iobserver.ts b/src/modules/esl-media/core/esl-media-iobserver.ts
index f24de4ddc..e4f78a424 100644
--- a/src/modules/esl-media/core/esl-media-iobserver.ts
+++ b/src/modules/esl-media/core/esl-media-iobserver.ts
@@ -1,6 +1,6 @@
 import type {ESLMedia} from './esl-media';
 
-const RATIO_TO_PLAY = 0.5; // TODO: customizable, at least global
+const RATIO_TO_PLAY = 0.75; // TODO: customizable, at least global
 const RATIO_TO_STOP = 0.20; // TODO: customizable, at least global
 
 const RATIO_TO_ACTIVATE = 0.05;
diff --git a/src/modules/esl-media/core/esl-media-provider.ts b/src/modules/esl-media/core/esl-media-provider.ts
index 5bec82062..c9f44d6f1 100644
--- a/src/modules/esl-media/core/esl-media-provider.ts
+++ b/src/modules/esl-media/core/esl-media-provider.ts
@@ -121,10 +121,6 @@ export abstract class BaseProvider {
     this._el.style.setProperty('height', height === 'auto' ? null : `${height}px`);
   }
 
-  public setAspectRatio(aspectRatio: number): void {
-    this._el?.style.setProperty('aspect-ratio', aspectRatio > 0 ? `${aspectRatio}` : null);
-  }
-
   /**
    * Executes toggle action:
    * If the player is PAUSED then it starts playing otherwise it pause playing
diff --git a/src/modules/esl-media/core/esl-media.less b/src/modules/esl-media/core/esl-media.less
index d1ab834eb..c54c90ee4 100644
--- a/src/modules/esl-media/core/esl-media.less
+++ b/src/modules/esl-media/core/esl-media.less
@@ -50,15 +50,5 @@
         min-height: 1px;
       }
     }
-
-    &:not([wide])[fill-mode='cover'] .esl-media-inner, &[wide][fill-mode='inscribe'] .esl-media-inner {
-      width: auto;
-      height: 100%;
-    }
-
-    &:not([wide])[fill-mode='inscribe'] .esl-media-inner, &[wide][fill-mode='cover'] .esl-media-inner {
-      width: 100%;
-      height: auto;
-    }
   }
 }
diff --git a/src/modules/esl-media/core/esl-media.shape.ts b/src/modules/esl-media/core/esl-media.shape.ts
index 7fae3df20..7cd2c7810 100644
--- a/src/modules/esl-media/core/esl-media.shape.ts
+++ b/src/modules/esl-media/core/esl-media.shape.ts
@@ -41,8 +41,6 @@ export type ESLMediaTagShape = ({
   'preload'?: 'none' | 'metadata' | 'auto' | '';
   /** Allow play media inline */
   'playsinline'?: boolean;
-  /** Allows to start viewing a resource from a specific time offset */
-  'start-time'?: number;
 
   /** Optional BC provider player id */
   'data-player-id'?: string;
diff --git a/src/modules/esl-media/core/esl-media.ts b/src/modules/esl-media/core/esl-media.ts
index 8585613ea..da41d2a31 100644
--- a/src/modules/esl-media/core/esl-media.ts
+++ b/src/modules/esl-media/core/esl-media.ts
@@ -45,8 +45,7 @@ export class ESLMedia extends ESLBaseElement {
     'muted',
     'loop',
     'controls',
-    'lazy',
-    'start-time'
+    'lazy'
   ];
 
   /** Event to dispatch on ready state */
@@ -119,8 +118,6 @@ export class ESLMedia extends ESLBaseElement {
   @boolAttr({readonly: true}) public played: boolean;
   /** @readonly Error state marker */
   @boolAttr({readonly: true}) public error: boolean;
-  /** @readonly Width is greater than height state marker */
-  @boolAttr({readonly: true}) public wide: boolean;
 
   private _provider: BaseProvider | null;
 
@@ -160,7 +157,6 @@ export class ESLMedia extends ESLBaseElement {
       case 'media-id':
       case 'media-src':
       case 'media-type':
-      case 'start-time':
         this.deferredReinitialize();
         break;
       case 'lazy':
@@ -305,9 +301,15 @@ export class ESLMedia extends ESLBaseElement {
   })
   protected _onResize(): void {
     if (!this._provider) return;
-    const {actualAspectRatio} = this;
-    this.$$attr('wide', this.offsetWidth / this.offsetHeight > actualAspectRatio);
-    this._provider.setAspectRatio(actualAspectRatio);
+    if (this.fillModeEnabled && this.actualAspectRatio > 0) {
+      let stretchVertically = this.offsetWidth / this.offsetHeight < this.actualAspectRatio;
+      if (this.fillMode === 'inscribe') stretchVertically = !stretchVertically; // Inscribe behaves inversely
+      stretchVertically ?
+        this._provider.setSize(this.actualAspectRatio * this.offsetHeight, this.offsetHeight) : // h
+        this._provider.setSize(this.offsetWidth, this.offsetWidth / this.actualAspectRatio);   // w
+    } else {
+      this._provider.setSize('auto', 'auto');
+    }
   }
 
   @listen({
diff --git a/src/modules/esl-panel-group/README.md b/src/modules/esl-panel-group/README.md
index 7c6c8e6e8..9b4d00b8d 100644
--- a/src/modules/esl-panel-group/README.md
+++ b/src/modules/esl-panel-group/README.md
@@ -34,7 +34,6 @@ ESLPanelGroup.register();
 
 - `has-opened` - readonly attribute that indicates whether the panel group has opened panels
 - `current-mode` - readonly attribute that indicates the currently applied rendering mode of the panel group
-- `animating` - a marker of animation process running
 
 ### Events
 
diff --git a/src/modules/esl-panel-group/core/esl-panel-group.ts b/src/modules/esl-panel-group/core/esl-panel-group.ts
index 64fa02fed..a95a76903 100644
--- a/src/modules/esl-panel-group/core/esl-panel-group.ts
+++ b/src/modules/esl-panel-group/core/esl-panel-group.ts
@@ -72,9 +72,6 @@ export class ESLPanelGroup extends ESLBaseElement {
   /** Readonly attribute that indicates whether the panel group has opened panels */
   @boolAttr({readonly: true}) public hasOpened: boolean;
 
-  /** Active while animation in progress */
-  @boolAttr({readonly: true}) public animating: boolean;
-
   /** Height of previous active panel */
   protected _previousHeight: number = 0;
 
@@ -271,7 +268,6 @@ export class ESLPanelGroup extends ESLBaseElement {
 
   /** Pre-processing animation action */
   protected beforeAnimate(): void {
-    this.$$attr('animating', true);
     CSSClassUtils.add(this, this.animationClass);
   }
 
@@ -279,7 +275,6 @@ export class ESLPanelGroup extends ESLBaseElement {
   protected afterAnimate(silent?: boolean): void {
     this.style.removeProperty('height');
     CSSClassUtils.remove(this, this.animationClass);
-    this.$$attr('animating', false);
 
     if (silent) return;
     this.$$fire(this.AFTER_ANIMATE_EVENT, {bubbles: false});
diff --git a/src/modules/esl-panel/core/esl-panel.ts b/src/modules/esl-panel/core/esl-panel.ts
index 26b5003b0..cc759f4c9 100644
--- a/src/modules/esl-panel/core/esl-panel.ts
+++ b/src/modules/esl-panel/core/esl-panel.ts
@@ -90,7 +90,7 @@ export class ESLPanel extends ESLToggleable {
 
   /** Handles post animation process to initiate after animate step */
   protected postAnimate(capturedBy?: ESLPanelGroup | null): void {
-    if (capturedBy && capturedBy.animating) {
+    if (capturedBy) {
       capturedBy.$$on({
         event: capturedBy.AFTER_ANIMATE_EVENT,
         once: true


Test patch:
diff --git a/e2e/package.json b/e2e/package.json
index cfee6567d..78620e131 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -1,7 +1,7 @@
 {
   "private": true,
   "name": "@exadel/esl-snapshot-tests",
-  "version": "5.0.0-beta.18",
+  "version": "5.0.0-beta.17",
   "description": "Integration snapshot tests for ESL",
   "homepage": "https://esl-ui.com/",
   "license": "MIT",
@@ -19,7 +19,7 @@
     "@types/puppeteer": "^7.0.4",
     "jest-image-snapshot": "^6.4.0",
     "jest-puppeteer": "^10.0.1",
-    "puppeteer": "^22.10.0",
+    "puppeteer": "^22.9.0",
     "stucumber": "^0.19.0"
   }
 }
diff --git a/src/modules/esl-event-listener/test/listener.resubscribtion.test.ts b/src/modules/esl-event-listener/test/listener.resubscribtion.test.ts
deleted file mode 100644
index 2411f9a4e..000000000
--- a/src/modules/esl-event-listener/test/listener.resubscribtion.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import {ESLBaseElement} from '../../esl-base-element/core';
-import {ESLEventUtils} from '../core/api';
-import {listen} from '../../esl-utils/decorators/listen';
-
-describe('ESLEventListener re-subscribtion usecase with condition', () => {
-  class TestSubsEl extends ESLBaseElement {
-    public static override is = 'test-subs-el';
-    public mockFn = jest.fn();
-
-    get enabled() {
-      return this.hasAttribute('enabled');
-    }
-    set enabled(value) {
-      this.$$attr('enabled', value);
-      this.$$off(this.onClick);
-      this.$$on(this.onClick);
-    }
-
-    @listen({event: 'click', condition: (that: TestSubsEl) => that.enabled})
-    onClick() {
-      this.mockFn();
-    }
-  }
-
-  TestSubsEl.register();
-
-  const host = TestSubsEl.create();
-
-  test('ESLEventListener initial subscription is rejected by condition', () => {
-    expect(ESLEventUtils.listeners(host).length).toBe(0);
-    host.click();
-    expect(host.mockFn).not.toBeCalled();
-  });
-
-  test('ESLEventListener subscription occurs after condition is resolved to true', () => {
-    host.enabled = true;
-    expect(ESLEventUtils.listeners(host).length).toBe(1);
-    host.click();
-    expect(host.mockFn).toBeCalled();
-  });
-
-  test('ESLEventListener subscription is removed after condition is resolved to false', () => {
-    host.enabled = false;
-    expect(ESLEventUtils.listeners(host).length).toBe(0);
-    host.click();
-    expect(host.mockFn).toBeCalledTimes(1);
-  });
-});
diff --git a/src/modules/esl-event-listener/test/listener.subscribe.test.ts b/src/modules/esl-event-listener/test/listener.subscribe.test.ts
index 7cecfde12..6dace9981 100644
--- a/src/modules/esl-event-listener/test/listener.subscribe.test.ts
+++ b/src/modules/esl-event-listener/test/listener.subscribe.test.ts
@@ -1,77 +1,107 @@
 import {ESLEventUtils} from '../core';
 
 describe('ESLEventUtils:subscribe tests', () => {
-  test('ESLEventUtils.subscribe successfully subscribes listener by descriptor', () => {
-    const $host = document.createElement('div');
-    const handle = jest.fn();
-    ESLEventUtils.subscribe($host, {event: 'click'}, handle);
-    expect(ESLEventUtils.listeners($host).length).toBe(1);
-    ESLEventUtils.unsubscribe($host);
-  });
+  describe('ESLEventUtils.subscribe subscribes listeners by separate descriptors', () => {
+    test('ESLEventUtils.subscribe successfully subscribes listener by descriptor', () => {
+      const $host = document.createElement('div');
+      const handle = jest.fn();
+      ESLEventUtils.subscribe($host, {event: 'click'}, handle);
+      expect(ESLEventUtils.listeners($host).length).toBe(1);
+      ESLEventUtils.unsubscribe($host);
+    });
 
-  test('ESLEventUtils.subscribe successfully subscribes listener by event name', () => {
-    const $host = document.createElement('div');
-    const handle = jest.fn();
-    ESLEventUtils.subscribe($host, 'click', handle);
-    expect(ESLEventUtils.listeners($host).length).toBe(1);
-    ESLEventUtils.unsubscribe($host);
-  });
+    test('ESLEventUtils.subscribe successfully subscribes listener by event name', () => {
+      const $host = document.createElement('div');
+      const handle = jest.fn();
+      ESLEventUtils.subscribe($host, 'click', handle);
+      expect(ESLEventUtils.listeners($host).length).toBe(1);
+      ESLEventUtils.unsubscribe($host);
+    });
 
-  test('ESLEventUtils.subscribe successfully subscribes listener by event provider', () => {
-    const $host = document.createElement('div');
-    const provider = jest.fn(function () {
-      expect(this).toBe($host);
-      return 'event';
-    });
-    const handle = jest.fn();
-    ESLEventUtils.subscribe($host, {event: provider}, handle);
-    expect(ESLEventUtils.listeners($host).length).toBe(1);
-    expect(provider).toBeCalledWith($host);
-    ESLEventUtils.unsubscribe($host);
-  });
+    test('ESLEventUtils.subscribe successfully subscribes listener by event provider', () => {
+      const $host = document.createElement('div');
+      const provider = jest.fn(function () {
+        expect(this).toBe($host);
+        return 'event';
+      });
+      const handle = jest.fn();
+      ESLEventUtils.subscribe($host, {event: provider}, handle);
+      expect(ESLEventUtils.listeners($host).length).toBe(1);
+      expect(provider).toBeCalledWith($host);
+      ESLEventUtils.unsubscribe($host);
+    });
 
-  test('ESLEventListener subscribes successfully by global selector',  () => {
-    const host = {};
-    const handle = jest.fn();
-    ESLEventUtils.subscribe(host, {event: 'click', target: 'body'}, handle);
-    expect(ESLEventUtils.listeners(host).length).toBe(1);
-    ESLEventUtils.unsubscribe(host);
+    test('ESLEventUtils.subscribe successfully subscribes listeners by string with multiple events', () => {
+      const $host = document.createElement('div');
+      const handle = jest.fn();
+      ESLEventUtils.subscribe($host, 'click keydown', handle);
+      expect(ESLEventUtils.listeners($host).length).toBe(2);
+      expect(ESLEventUtils.listeners($host, 'keydown').length).toBe(1);
+      expect(ESLEventUtils.listeners($host, 'click').length).toBe(1);
+      ESLEventUtils.unsubscribe($host);
+    });
   });
 
-  test('ESLEventListener subscribes successfully by target instance',  () => {
-    const host = {};
-    const el = document.createElement('div');
-    const handle = jest.fn();
-    ESLEventUtils.subscribe(host, {event: 'click', target: el}, handle);
-    expect(ESLEventUtils.listeners(host).length).toBe(1);
-    ESLEventUtils.unsubscribe(host);
-  });
+  describe('ESLEventUtils.subscribe subscribes listeners by function decorated with descriptor', () => {
+    const $target = document.createElement('div');
+    const $host = Object.assign(document.createElement('div'), {
+      onClick: jest.fn(),
+      onClickWithTarget: jest.fn()
+    });
+    ESLEventUtils.initDescriptor($host, 'onClick', {event: 'click'});
+    ESLEventUtils.initDescriptor($host, 'onClickWithTarget', {event: 'click', target: $target});
 
-  test('ESLEventUtils.subscribe successfully subscribes listeners by string with multiple events', () => {
-    const $host = document.createElement('div');
-    const handle = jest.fn();
-    ESLEventUtils.subscribe($host, 'click keydown', handle);
-    expect(ESLEventUtils.listeners($host).length).toBe(2);
-    expect(ESLEventUtils.listeners($host, 'keydown').length).toBe(1);
-    expect(ESLEventUtils.listeners($host, 'click').length).toBe(1);
-    ESLEventUtils.unsubscribe($host);
+    test('ESLEventUtils.subscribe successfully subscribes listener by descriptor function', () => {
+      ESLEventUtils.subscribe($host, $host.onClick);
+      expect(ESLEventUtils.listeners($host).length).toBe(1);
+      expect(ESLEventUtils.listeners($host)[0].matches($host.onClick)).toBe(true);
+      ESLEventUtils.unsubscribe($host);
+    });
+
+    test('ESLEventUtils.subscribe successfully subscribes listener by descriptor function with extra config', () => {
+      ESLEventUtils.subscribe($host, $host.onClickWithTarget);
+      expect(ESLEventUtils.listeners($host)[0].matches($host.onClickWithTarget)).toBe(true);
+      expect(ESLEventUtils.listeners($host)[0].$targets).toEqual([$target]);
+      ESLEventUtils.unsubscribe($host);
+    });
+
+    test('ESLEventUtils.subscribe merge descriptor function with descriptor passed explicitly', () => {
+      ESLEventUtils.subscribe($host, {event: 'keydown'}, $host.onClickWithTarget);
+      expect(ESLEventUtils.listeners($host)[0].matches('keydown')).toBe(true);
+      expect(ESLEventUtils.listeners($host)[0].matches($host.onClickWithTarget)).toBe(true);
+      expect(ESLEventUtils.listeners($host)[0].$targets).toEqual([$target]);
+      ESLEventUtils.unsubscribe($host);
+    });
   });
 
-  test('ESLEventListener observes target events', () => {
-    const $host = document.createElement('div');
-    const handle = jest.fn();
-    ESLEventUtils.subscribe($host, {event: 'click'}, handle);
-    $host.click();
-    expect(handle).toBeCalledTimes(1);
-    ESLEventUtils.unsubscribe($host);
+  describe('ESLEventUtils.subscribe uses correct target resolution', () => {
+    test('ESLEventListener subscribes successfully by global selector',  () => {
+      const host = {};
+      const handle = jest.fn();
+      ESLEventUtils.subscribe(host, {event: 'click', target: 'body'}, handle);
+      expect(ESLEventUtils.listeners(host).length).toBe(1);
+      ESLEventUtils.unsubscribe(host);
+    });
+
+    test('ESLEventListener subscribes successfully by target instance',  () => {
+      const host = {};
+      const el = document.createElement('div');
+      const handle = jest.fn();
+      ESLEventUtils.subscribe(host, {event: 'click', target: el}, handle);
+      expect(ESLEventUtils.listeners(host).length).toBe(1);
+      ESLEventUtils.unsubscribe(host);
+    });
   });
 
-  test('ESLEventListener does not subscribe if no targets',  () => {
-    jest.spyOn(console, 'warn').mockImplementationOnce(() => {});
-    const host = {};
-    const handle = jest.fn();
-    ESLEventUtils.subscribe(host, {event: 'click'}, handle);
-    expect(ESLEventUtils.listeners(host).length).toBe(0);
+  describe('Created by ESLEventUtils listener behaves correctly', () => {
+    test('ESLEventListener observes target events', () => {
+      const $host = document.createElement('div');
+      const handle = jest.fn();
+      ESLEventUtils.subscribe($host, {event: 'click'}, handle);
+      $host.click();
+      expect(handle).toBeCalledTimes(1);
+      ESLEventUtils.unsubscribe($host);
+    });
   });
 
   describe('ESLEventListener subscribes correctly for any object-like host', () => {
@@ -102,4 +132,92 @@ describe('ESLEventUtils:subscribe tests', () => {
       }).toThrow();
     });
   });
+
+  describe('ESLEventListener subscribes from descriptors correctly by criteria', () => {
+    const $host = Object.assign(document.createElement('div'), {
+      clickAuto: jest.fn(),
+      clickManual: jest.fn(),
+      clickWithGroup: jest.fn(),
+      clickWithTarget: jest.fn(),
+      keydownAuto: jest.fn(),
+      keydownWithGroup: jest.fn(),
+      keydownWithTarget: jest.fn(),
+      keydownWithTargetAndGroup: jest.fn()
+    });
+    const $tgt = document.createElement('div');
+    ESLEventUtils.initDescriptor($host, 'clickAuto', {event: 'click', auto: true});
+    ESLEventUtils.initDescriptor($host, 'clickManual', {event: 'click', auto: false});
+    ESLEventUtils.initDescriptor($host, 'clickWithGroup', {event: 'click', group: 'group'});
+    ESLEventUtils.initDescriptor($host, 'clickWithTarget', {event: 'click', target: $tgt});
+    ESLEventUtils.initDescriptor($host, 'keydownAuto', {event: 'keydown', auto: true});
+    ESLEventUtils.initDescriptor($host, 'keydownWithGroup', {event: 'keydown', group: 'group'});
+    ESLEventUtils.initDescriptor($host, 'keydownWithTarget', {event: 'keydown', target: $tgt});
+    ESLEventUtils.initDescriptor($host, 'keydownWithTargetAndGroup', {event: 'keydown', target: 'body', group: 'group'});
+
+    afterEach(() => ESLEventUtils.unsubscribe($host));
+
+    test('ESLEventListener.subscribe without arguments subscribes auto listeners', () => {
+      const listeners = ESLEventUtils.subscribe($host);
+      expect(listeners.length).toBe(2);
+      expect(listeners[0].matches($host.clickAuto)).toBe(true);
+      expect(listeners[1].matches($host.keydownAuto)).toBe(true);
+    });
+
+    test('ESLEventListener.subscribe with criteria subscribes listeners that match criteria (event)', () => {
+      const listeners = ESLEventUtils.subscribe($host, {event: 'click'});
+      expect(listeners.length).toBe(4);
+      expect(listeners[0].matches($host.clickAuto)).toBe(true);
+      expect(listeners[1].matches($host.clickManual)).toBe(true);
+      expect(listeners[2].matches($host.clickWithGroup)).toBe(true);
+      expect(listeners[3].matches($host.clickWithTarget)).toBe(true);
+    });
+
+    test('ESLEventListener.subscribe with criteria subscribes listeners that match criteria (group)', () => {
+      const listeners = ESLEventUtils.subscribe($host, {group: 'group'});
+      expect(listeners.length).toBe(3);
+      expect(listeners[0].matches($host.clickWithGroup)).toBe(true);
+      expect(listeners[1].matches($host.keydownWithGroup)).toBe(true);
+      expect(listeners[2].matches($host.keydownWithTargetAndGroup)).toBe(true);
+    });
+
+    test('ESLEventListener.subscribe with criteria subscribes listeners that match criteria (target)', () => {
+      const listeners = ESLEventUtils.subscribe($host, {target: $tgt});
+      expect(listeners.length).toBe(2);
+      expect(listeners[0].matches($host.clickWithTarget)).toBe(true);
+      expect(listeners[1].matches($host.keydownWithTarget)).toBe(true);
+    });
+
+    test('ESLEventListener.subscribe with criteria subscribes listeners that match criteria (event and group)', () => {
+      const listeners = ESLEventUtils.subscribe($host, {event: 'click', group: 'group'});
+      expect(listeners.length).toBe(1);
+      expect(listeners[0].matches($host.clickWithGroup)).toBe(true);
+    });
+  });
+
+  describe('ESLEventListener edge cases and exception control', () => {
+    test('ESLEventListener.subscribe throws an error if multiple handler functions are passed', () => {
+      const $host = document.createElement('div');
+      expect(() => ESLEventUtils.subscribe($host, (() => void 0) as any, () => void 0)).toThrow();
+    });
+
+    test('ESLEventListener does not subscribe if no targets',  () => {
+      jest.spyOn(console, 'warn').mockImplementationOnce(() => {});
+      const host = {};
+      const handle = jest.fn();
+      ESLEventUtils.subscribe(host, {event: 'click'}, handle);
+      expect(ESLEventUtils.listeners(host).length).toBe(0);
+    });
+
+    test('ESLEventListener does not fails or subscribes if non descriptor function passed',  () => {
+      const host = {
+        onClick: jest.fn()
+      };
+      const warnSpy = jest.spyOn(console, 'warn').mockImplementationOnce(() => {});
+
+      expect(ESLEventUtils.subscribe(host, host.onClick)).toEqual([]);
+      expect(ESLEventUtils.listeners(host).length).toBe(0);
+      expect(host.onClick).not.toHaveBeenCalled();
+      expect(warnSpy).toHaveBeenCalled();
+    });
+  });
 });