Repository: exadel-inc/esl (ts)

Issue:
[🚀esl-media-query]: normalize data instead of fail
As an `ESLMediaRuleList` consumer I want to have a little bit more smart tuple syntax parsing, to cover common cases.

**Business case:**
The component uses a shared or fixed mask to create an `ESLMediaRuleList` based value, but  the value appears to be single (equal for each case of the media query)

**Current behavior:** 
`ESLMediaRuleList` throws an error if the query and values cartages have a different size
```
ESLMediaRuleList.parse('@sm|@md', '1') // throws error
```

**Proposed behavior:**
`ESLMediaRuleList` duplicate the last value if values cortege is smaller than the queries one

**Priority**: Major

**Additional context**
- Provide related test cases
- Do not extend media-query module code base with more then a couple of lines to normalize the data

Pull request:
feat(esl-media-query): normalize data instead of fail
add condition for case if data in `valueList`  less than in `queries`

Closes: #1044 


Patch:
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 44609355c..38e65c203 100644
--- a/src/modules/esl-base-element/core/esl-base-element.ts
+++ b/src/modules/esl-base-element/core/esl-base-element.ts
@@ -1,5 +1,4 @@
 import {setAttr} from '../../esl-utils/dom/attr';
-import {prop} from '../../esl-utils/decorators/prop';
 import {EventUtils} from '../../esl-utils/dom/events';
 import {CSSClassUtils} from '../../esl-utils/dom/class';
 
@@ -19,9 +18,6 @@ export abstract class ESLBaseElement extends HTMLElement {
   /** Custom element tag name */
   public static is = '';
 
-  /** Event to indicate component significant state change that may affect other components state */
-  @prop('esl:refresh') public REFRESH_EVENT: string;
-
   protected _connected: boolean = false;
 
   protected connectedCallback(): void {
@@ -85,6 +81,7 @@ export abstract class ESLBaseElement extends HTMLElement {
 
   /**
    * Dispatches component custom event.
+   * Uses 'esl:' prefix for event name, overridable to customize event namespaces.
    * @param eventName - event name
    * @param eventInit - custom event init. See {@link CustomEventInit}
    */
diff --git a/src/modules/esl-image/README.md b/src/modules/esl-image/README.md
index 4a140b1af..971c7b918 100644
--- a/src/modules/esl-image/README.md
+++ b/src/modules/esl-image/README.md
@@ -1,6 +1,6 @@
 # [ESL](../../../) Image
 
-Version: *1.3.0*
+Version: *1.2.1*
 
 Authors: *Alexey Stsefanovich (ala'n)*, *Yuliya Adamskaya*
 
diff --git a/src/modules/esl-image/core/esl-image.ts b/src/modules/esl-image/core/esl-image.ts
index 7e206b541..920d3ba93 100644
--- a/src/modules/esl-image/core/esl-image.ts
+++ b/src/modules/esl-image/core/esl-image.ts
@@ -1,6 +1,5 @@
 import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {bind} from '../../esl-utils/decorators/bind';
-import {prop} from '../../esl-utils/decorators/prop';
 import {CSSClassUtils} from '../../esl-utils/dom/class';
 import {ESLBaseElement, attr, boolAttr} from '../../esl-base-element/core';
 import {ESLMediaRuleList} from '../../esl-media-query/core';
@@ -25,7 +24,7 @@ export class ESLImage extends ESLBaseElement {
   public static is = 'esl-image';
   public static observedAttributes = ['alt', 'role', 'mode', 'aria-label', 'data-src', 'data-src-base', 'lazy-triggered'];
 
-  /** Default container class value */
+  // Default container class value
   public static DEFAULT_CONTAINER_CLS = 'img-container-loaded';
 
   public static get STRATEGIES(): ESLImageStrategyMap {
@@ -36,13 +35,6 @@ export class ESLImage extends ESLBaseElement {
     return 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
   }
 
-  /** Event that represents ready state of {@link ESLImage} */
-  @prop('ready') public READY_EVENT: string;
-  /** Event that represents successfully loaded state of {@link ESLImage} */
-  @prop('load') public LOAD_EVENT: string;
-  /** Event that represents error state of {@link ESLImage} */
-  @prop('error') public ERROR_EVENT: string;
-
   @attr() public alt: string;
   @attr({defaultValue: 'save-ratio'}) public mode: string;
   @attr({dataAttr: true}) public src: string;
@@ -267,7 +259,7 @@ export class ESLImage extends ESLBaseElement {
 
   protected get _shadowImgError(): boolean {
     if (!this._shadowImg.complete) return false;
-    if (this._shadowImg.src.substring(-4) === '.svg') return false;
+    if (this._shadowImg.src.substr(-4) === '.svg') return false;
     return this._shadowImg.naturalHeight <= 0;
   }
 
@@ -294,9 +286,8 @@ export class ESLImage extends ESLBaseElement {
     this.toggleAttribute('loaded', successful);
     this.toggleAttribute('error', !successful);
     this.toggleAttribute('ready', true);
-
-    this.$$fire(successful ? this.LOAD_EVENT : this.ERROR_EVENT, {bubbles: false});
-    this.$$fire(this.READY_EVENT, {bubbles: false});
+    this.$$fire(successful ? 'load' : 'error', {bubbles: false});
+    this.$$fire('ready', {bubbles: false});
   }
 
   public updateContainerClasses(): void {
diff --git a/src/modules/esl-media-query/core/esl-media-rule-list.ts b/src/modules/esl-media-query/core/esl-media-rule-list.ts
index 90264994f..297c833d4 100644
--- a/src/modules/esl-media-query/core/esl-media-rule-list.ts
+++ b/src/modules/esl-media-query/core/esl-media-rule-list.ts
@@ -130,7 +130,8 @@ export class ESLMediaRuleList<T = any> extends SyntheticEventTarget {
   public static parseTuple(mask: string, values: string, parser: RulePayloadParser<any> = String): ESLMediaRuleList {
     const queries = mask.split('|');
     const valueList = values.split('|');
-    if (valueList.length !== queries.length) throw new Error('Value doesn\'t correspond to mask');
+    while (valueList.length < queries.length && valueList.length !== 0) valueList.push(valueList[valueList.length - 1]);
+    if (valueList.length !== queries.length) throw Error('Value doesn\'t correspond to mask');
     const rules: (ESLMediaRule | undefined)[] = queries.map((query, i) => ESLMediaRule.create(valueList[i], query, parser));
     const validRules = rules.filter((rule) => !!rule) as ESLMediaRule[];
     return new ESLMediaRuleList(validRules);
diff --git a/src/modules/esl-media/README.md b/src/modules/esl-media/README.md
index e7757e117..4c93c18a2 100644
--- a/src/modules/esl-media/README.md
+++ b/src/modules/esl-media/README.md
@@ -1,6 +1,6 @@
 # [ESL](../../../) Media
 
-Version: *1.3.0*
+Version: *1.2.0*
 
 Authors: *Alexey Stsefanovich (ala'n)*, *Yuliya Adamskaya*, *Julia Murashko*, *Natallia Harshunova*
 
@@ -86,7 +86,7 @@ using a single tag as well as work with external providers using simple native-l
  - `esl:media:paused` - (bubbles) fires when esl-media is paused
  - `esl:media:ended` - (bubbles) fires when esl-media is ended
  - `esl:media:detach` - (bubbles) fires after esl-media provider is detached (reinitialized / disconnected from the DOM)
- - `esl:media:managedpause` - (bubbles) fires when media was paused by esl-media group restriction manager
+ - `esl:media:mangedpause` - (bubbles) fires when media was paused by esl-media group restriction manager
  
 ### Examples:
 ```html
diff --git a/src/modules/esl-media/core/esl-media-iobserver.ts b/src/modules/esl-media/core/esl-media-iobserver.ts
index 2905ff5d5..566cb6766 100644
--- a/src/modules/esl-media/core/esl-media-iobserver.ts
+++ b/src/modules/esl-media/core/esl-media-iobserver.ts
@@ -25,7 +25,7 @@ function handleViewport(entry: IntersectionObserverEntry): void {
   if (video.active && entry.intersectionRatio <= RATIO_TO_DEACTIVATE) {
     video.pause();
   }
-  // Play should start only for inactive and background(muted) videos that are visible more than on RATIO_TO_ACTIVATE
+  // Play should starts only for inactive and background(muted) videos that are visible more then on RATIO_TO_ACTIVATE
   if (!video.active && video.autoplay && entry.intersectionRatio >= RATIO_TO_ACTIVATE) {
     video.play();
   }
diff --git a/src/modules/esl-media/core/esl-media-manager.ts b/src/modules/esl-media/core/esl-media-manager.ts
index a0766dc32..3c88d30ce 100644
--- a/src/modules/esl-media/core/esl-media-manager.ts
+++ b/src/modules/esl-media/core/esl-media-manager.ts
@@ -20,7 +20,9 @@ export class MediaGroupRestrictionManager {
       const current = managerMap.get(instance.group);
       managerMap.set(instance.group, instance);
       if (!current || current === instance || !current.active) return;
-      if (current.$$fire(current.MANAGED_PAUSE_EVENT)) current.pause();
+      if (current.$$fire('mangedpause')) {
+        current.pause();
+      }
     }
   }
 
diff --git a/src/modules/esl-media/core/esl-media.ts b/src/modules/esl-media/core/esl-media.ts
index 10daf6f5c..c20acc0fa 100644
--- a/src/modules/esl-media/core/esl-media.ts
+++ b/src/modules/esl-media/core/esl-media.ts
@@ -1,10 +1,10 @@
 import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {ESLBaseElement, attr, boolAttr} from '../../esl-base-element/core';
 import {bind} from '../../esl-utils/decorators/bind';
-import {prop} from '../../esl-utils/decorators/prop';
 import {CSSClassUtils} from '../../esl-utils/dom/class';
 import {rafDecorator} from '../../esl-utils/async/raf';
 import {debounce} from '../../esl-utils/async/debounce';
+import {EventUtils} from '../../esl-utils/dom/events';
 import {parseAspectRatio} from '../../esl-utils/misc/format';
 
 import {ESLMediaQuery} from '../../esl-media-query/core';
@@ -29,6 +29,7 @@ export type ESLMediaFillMode = 'cover' | 'inscribe' | '';
 @ExportNs('Media')
 export class ESLMedia extends ESLBaseElement {
   public static is = 'esl-media';
+  public static eventNs = 'esl:media:';
   public static observedAttributes = [
     'disabled',
     'load-condition',
@@ -43,21 +44,6 @@ export class ESLMedia extends ESLBaseElement {
     'controls'
   ];
 
-  /** Event to dispatch on ready state */
-  @prop('esl:media:ready') public READY_EVENT: string;
-  /** Event to dispatch on error state */
-  @prop('esl:media:error') public ERROR_EVENT: string;
-  /** Event to dispatch when player plays */
-  @prop('esl:media:play') public PLAY_EVENT: string;
-  /** Event to dispatch when player paused */
-  @prop('esl:media:paused') public PAUSED_EVENT: string;
-  /** Event to dispatch when player ended */
-  @prop('esl:media:ended') public ENDED_EVENT: string;
-  /** Event to dispatch when player detached */
-  @prop('esl:media:detached') public DETACHED_EVENT: string;
-  /** Event to dispatch when player paused by another instance in group */
-  @prop('esl:media:managedpause') public MANAGED_PAUSE_EVENT: string;
-
   /** Media resource identifier */
   @attr() public mediaId: string;
   /** Media resource src/url path */
@@ -188,7 +174,7 @@ export class ESLMedia extends ESLBaseElement {
     if (this.fillModeEnabled) {
       window.addEventListener('resize', this.deferredResize);
     }
-    window.addEventListener(this.REFRESH_EVENT, this._onRefresh);
+    window.addEventListener('esl:refresh', this._onRefresh);
     this.addEventListener('keydown', this._onKeydown);
   }
   protected unbindEvents(): void {
@@ -197,7 +183,7 @@ export class ESLMedia extends ESLBaseElement {
     if (this.fillModeEnabled) {
       window.removeEventListener('resize', this.deferredResize);
     }
-    window.removeEventListener(this.REFRESH_EVENT, this._onRefresh);
+    window.removeEventListener('esl:refresh', this._onRefresh);
     this.removeEventListener('keydown', this._onKeydown);
   }
 
@@ -278,14 +264,14 @@ export class ESLMedia extends ESLBaseElement {
     this.toggleAttribute('error', false);
     this.updateReadyClass();
     this.deferredResize();
-    this.$$fire(this.READY_EVENT);
+    this.$$fire('ready');
   }
 
   public _onError(detail?: any, setReadyState = true): void {
     this.toggleAttribute('ready', true);
     this.toggleAttribute('error', true);
-    this.$$fire(this.ERROR_EVENT, {detail});
-    setReadyState && this.$$fire(this.READY_EVENT);
+    this.$$fire('error', {detail});
+    setReadyState && this.$$fire('ready');
   }
 
   public _onDetach(): void {
@@ -293,7 +279,7 @@ export class ESLMedia extends ESLBaseElement {
     this.removeAttribute('ready');
     this.removeAttribute('played');
     this.updateReadyClass();
-    this.$$fire(this.DETACHED_EVENT);
+    this.$$fire('detach');
   }
 
   public _onPlay(): void {
@@ -301,19 +287,19 @@ export class ESLMedia extends ESLBaseElement {
     this.deferredResize();
     this.setAttribute('active', '');
     this.setAttribute('played', '');
-    this.$$fire(this.PLAY_EVENT);
+    this.$$fire('play');
     MediaGroupRestrictionManager.registerPlay(this);
   }
 
   public _onPaused(): void {
     this.removeAttribute('active');
-    this.$$fire(this.PAUSED_EVENT);
+    this.$$fire('paused');
     MediaGroupRestrictionManager.unregister(this);
   }
 
   public _onEnded(): void {
     this.removeAttribute('active');
-    this.$$fire(this.ENDED_EVENT);
+    this.$$fire('ended');
     MediaGroupRestrictionManager.unregister(this);
   }
 
@@ -412,6 +398,11 @@ export class ESLMedia extends ESLBaseElement {
     const observer = getIObserver(true);
     observer && observer.unobserve(this);
   }
+
+  public $$fire(eventName: string, eventInit?: CustomEventInit): boolean {
+    const ns = (this.constructor as typeof ESLMedia).eventNs;
+    return EventUtils.dispatch(this, ns + eventName, eventInit);
+  }
 }
 
 declare global {
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 094bd2085..5de93d824 100644
--- a/src/modules/esl-mixin-element/ui/esl-mixin-element.ts
+++ b/src/modules/esl-mixin-element/ui/esl-mixin-element.ts
@@ -1,5 +1,4 @@
 import {setAttr} from '../../esl-utils/dom/attr';
-import {prop} from '../../esl-utils/decorators/prop';
 import {EventUtils} from '../../esl-utils/dom/events';
 import {CSSClassUtils} from '../../esl-utils/dom/class';
 import {ESLMixinRegistry} from './esl-mixin-registry';
@@ -24,9 +23,6 @@ export class ESLMixinElement implements AttributeTarget {
   /** Additional observed attributes */
   static observedAttributes: string[] = [];
 
-  /** Event to indicate component significant state change that may affect other components state */
-  @prop('esl:refresh') public REFRESH_EVENT: string;
-
   /** Additional attributes observer */
   private _attr$$: MutationObserver;
 
diff --git a/src/modules/esl-scrollbar/core/esl-scrollbar.ts b/src/modules/esl-scrollbar/core/esl-scrollbar.ts
index a67ba6c1c..32b38fe6c 100644
--- a/src/modules/esl-scrollbar/core/esl-scrollbar.ts
+++ b/src/modules/esl-scrollbar/core/esl-scrollbar.ts
@@ -107,7 +107,7 @@ export class ESLScrollbar extends ESLBaseElement {
   protected bindEvents(): void {
     window.MouseEvent && this.addEventListener('mousedown', this._onPointerDown);
     window.TouchEvent && this.addEventListener('touchstart', this._onPointerDown, {passive: true});
-    window.addEventListener(this.REFRESH_EVENT, this._onRefresh);
+    window.addEventListener('esl:refresh', this._onRefresh);
   }
 
   protected bindTargetEvents(): void {
@@ -141,7 +141,7 @@ export class ESLScrollbar extends ESLBaseElement {
     window.MouseEvent && this.removeEventListener('mousedown', this._onPointerDown);
     window.TouchEvent && this.removeEventListener('touchstart', this._onPointerDown);
     this.unbindTargetEvents();
-    window.removeEventListener(this.REFRESH_EVENT, this._onRefresh);
+    window.removeEventListener('esl:refresh', this._onRefresh);
   }
 
   protected unbindTargetEvents(): void {
@@ -347,13 +347,13 @@ export class ESLScrollbar extends ESLBaseElement {
 
   /**
    * Handler for refresh events to update the scroll.
-   * @param event - instance of 'resize' or 'scroll' or {@link ESLScrollbar.REFRESH_EVENT} event.
+   * @param event - instance of 'resize' or 'scroll' or 'esl:refresh' event.
    */
   @bind
   protected _onRefresh(event: Event): void {
     const target = event.target as HTMLElement;
     if (event.type === 'scroll' && this.dragging) return;
-    if (event.type === this.REFRESH_EVENT && !isRelativeNode(target.parentNode, this.$target)) return;
+    if (event.type === 'esl:refresh' && !isRelativeNode(target.parentNode, this.$target)) return;
     this._deferredRefresh();
   }
 }
diff --git a/src/modules/esl-toggleable/core/esl-toggleable.ts b/src/modules/esl-toggleable/core/esl-toggleable.ts
index 682b134ed..8eead1f30 100644
--- a/src/modules/esl-toggleable/core/esl-toggleable.ts
+++ b/src/modules/esl-toggleable/core/esl-toggleable.ts
@@ -224,13 +224,13 @@ export class ESLToggleable extends ESLBaseElement {
   /**
    * Actions to execute on show toggleable.
    * Inner state and 'open' attribute are not affected and updated before `onShow` execution.
-   * Adds CSS classes, update a11y and fire {@link ESLToggleable.REFRESH_EVENT} event by default.
+   * Adds CSS classes, update a11y and fire esl:refresh event by default.
    */
   protected onShow(params: ToggleableActionParams): void {
     CSSClassUtils.add(this, this.activeClass);
     CSSClassUtils.add(document.body, this.bodyClass, this);
     this.updateA11y();
-    this.$$fire(this.REFRESH_EVENT); // To notify other components about content change
+    this.$$fire('esl:refresh'); // To notify other components about content change
   }
 
   /**


Test patch:
diff --git a/src/modules/esl-media-query/test/esl-media-rule-list.test.ts b/src/modules/esl-media-query/test/esl-media-rule-list.test.ts
index 5aa406983..1c0f11f87 100644
--- a/src/modules/esl-media-query/test/esl-media-rule-list.test.ts
+++ b/src/modules/esl-media-query/test/esl-media-rule-list.test.ts
@@ -124,6 +124,46 @@ describe('ESLMediaRuleList', () => {
     mockMdMatchMedia.matches = true;
     expect(mrl.value).toBe('3');
   });
+  test('all|@sm|@md ==> 1|2', () => {
+    const mrl = ESLMediaRuleList.parseTuple('all|@sm|@md', '1|2');
+    expect(mrl.rules.length).toBe(3);
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('1');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('2');
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('2');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('2');
+  });
+  test('all|@sm|@md ==> 1', () => {
+    const mrl = ESLMediaRuleList.parseTuple('all|@sm|@md', '1');
+    expect(mrl.rules.length).toBe(3);
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('1');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = false;
+    expect(mrl.value).toBe('1');
+
+    mockSmMatchMedia.matches = false;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('1');
+
+    mockSmMatchMedia.matches = true;
+    mockMdMatchMedia.matches = true;
+    expect(mrl.value).toBe('1');
+  });
 
   afterEach(() => {
     mockSmMatchMedia.matches = false;