Repository: exadel-inc/esl
Pull request #1854 (2023-08-08 21:22:11)

feat(esl-media): add extended `lazy` attribute support⚠️
Closes:  #1641 





codeclimate[bot]: Code Climate has analyzed commit d9d4fb26 and detected **0 issues** on this pull request.

The test coverage on the diff in this pull request is **22.2%** (**50%** is the threshold).

This pull request will bring the total coverage in the repository to **49.3%** (**-0.1%** change).

View more on [Code Climate](https://codeclimate.com/github/exadel-inc/esl/pull/1854).

codeclimate[bot]: Code Climate has analyzed commit 573be283 and detected **0 issues** on this pull request.

The test coverage on the diff in this pull request is **22.2%** (**50%** is the threshold).

This pull request will bring the total coverage in the repository to **49.3%** (**-0.1%** change).

View more on [Code Climate](https://codeclimate.com/github/exadel-inc/esl/pull/1854).

codeclimate[bot]: Code Climate has analyzed commit ba483e17 and detected **0 issues** on this pull request.

The test coverage on the diff in this pull request is **22.2%** (**50%** is the threshold).

This pull request will bring the total coverage in the repository to **49.3%** (**-0.1%** change).

View more on [Code Climate](https://codeclimate.com/github/exadel-inc/esl/pull/1854).


Patch:
diff --git a/pages/views/examples/embedded-video.njk b/pages/views/examples/embedded-video.njk
index 51a28cc3a..f53ae4588 100644
--- a/pages/views/examples/embedded-video.njk
+++ b/pages/views/examples/embedded-video.njk
@@ -20,7 +20,7 @@ aside:
       And you can use esl media component the same way as a native video element</p>
     <hr/>
     <div class="img-container img-container-16-9 img-container-loaded">
-      <esl-media group="demo-media"
+      <esl-media  group="demo-media"
                   media-type="youtube"
                   media-id="hqc8EbfR5BE"
                   load-condition="(min-width: 320px)"
@@ -82,11 +82,13 @@ aside:
       for the block.</p>
     <hr/>
     <div class="demo-marquee" style="height: 400px">
-      <esl-media class="bg-content esl-media-bg"
+      <esl-media class="bg-content esl-media-bg esl-animate-fade"
+                 ready-class="in"
                  media-type="video"
                  media-src="{{ '/assets/media/video.mp4' | url }}"
                  fill-mode="cover"
                  play-in-viewport
+                 lazy
                  autoplay muted loop playsinline>
       </esl-media>
       <div class="demo-content contrast">
@@ -102,12 +104,14 @@ aside:
     <p class="text-center">We still can use any source in background mode</p>
     <hr/>
     <div class="demo-marquee" style="height: 400px">
-      <esl-media class="bg-content esl-media-bg"
+      <esl-media class="bg-content esl-media-bg esl-animate-fade"
+                 ready-class="in"
                  media-type="youtube"
                  media-id="5ryf1AVl8Wg"
                  fill-mode="cover"
                  aspect-ratio="4:3"
                  play-in-viewport
+                 lazy="auto"
                  autoplay muted loop playsinline>
       </esl-media>
       <div class="demo-content contrast">
diff --git a/src/modules/esl-media/README.md b/src/modules/esl-media/README.md
index 7c237cac2..22609f228 100644
--- a/src/modules/esl-media/README.md
+++ b/src/modules/esl-media/README.md
@@ -45,8 +45,16 @@ using a single tag as well as work with external providers using simple native-l
  - `ready-class` - class to add when the resource is ready
  - `ready-class-target` - [ESLTraversingQuery](../esl-traversing-query/README.md) to define a target for `ready-class`
 
- - `disabled` (boolean) - marker that prevents media api initialization
- 
+ - `disabled` (boolean) - marker that prevents media api initialization. Deprecated alias for *manual* mode of `lazy` attribute
+
+ - `lazy` - an attribute that governs the loading behavior of media resources on a webpage. 
+ This attribute provides enhanced control over when media content is fetched and displayed.
+   - `none` (or attribute absence) - triggers the immediate loading of media content as soon as the webpage is loaded;
+   - `manual` - in this mode, media content loading is blocked until the attribute is removed manually from the consumer's code;
+   - `auto` - the auto mode ensures that media content starts loading when it becomes visible in the browser viewport or is in close proximity to it. 
+ This behavior is determined using the Intersection Observer API, optimizing loading times for content that is likely to be viewed by the user.
+ If the media is both intersecting and has a sufficient intersection ratio, the lazy attribute is removed from the media element.
+
  - `fill-mode` (optional) - enables resource size management. Available options:
    - `auto` - default, media area will be stretched to element size
    - `cover` - media area will be zoomed in/out, cropped and centered to cover element area
diff --git a/src/modules/esl-media/core/esl-media-iobserver.ts b/src/modules/esl-media/core/esl-media-iobserver.ts
index 125bc51f7..e4f78a424 100644
--- a/src/modules/esl-media/core/esl-media-iobserver.ts
+++ b/src/modules/esl-media/core/esl-media-iobserver.ts
@@ -1,7 +1,9 @@
 import type {ESLMedia} from './esl-media';
 
-const RATIO_TO_ACTIVATE = 0.75; // TODO: customizable, at least global
-const RATIO_TO_DEACTIVATE = 0.20; // 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;
 
 let iObserver: IntersectionObserver;
 
@@ -11,7 +13,7 @@ export function getIObserver(lazy: boolean = false): IntersectionObserver {
     iObserver = new IntersectionObserver(function (entries) {
       entries.forEach(handleViewport);
     }, {
-      threshold: [RATIO_TO_DEACTIVATE, RATIO_TO_ACTIVATE]
+      threshold: [RATIO_TO_STOP, RATIO_TO_PLAY]
     });
   }
   return iObserver;
@@ -19,13 +21,17 @@ export function getIObserver(lazy: boolean = false): IntersectionObserver {
 
 function handleViewport(entry: IntersectionObserverEntry): void {
   const video = entry.target as ESLMedia;
+  // Removes `lazy` attribute when media is in the viewport with min ratio RATIO_TO_ACTIVATE
+  if (entry.isIntersecting && entry.intersectionRatio >= RATIO_TO_ACTIVATE && video.lazy === 'auto') {
+    video.$$attr('lazy', false);
+  }
 
-  // Videos that playing and out of min ratio RATIO_TO_DEACTIVATE should be stopped
-  if (video.active && entry.intersectionRatio <= RATIO_TO_DEACTIVATE) {
+  // Videos that playing and out of min ratio RATIO_TO_STOP should be stopped
+  if (video.active && entry.intersectionRatio <= RATIO_TO_STOP) {
     video.pause();
   }
-  // Play should start only for inactive and background(muted) videos that are visible more than on RATIO_TO_ACTIVATE
-  if (!video.active && video.autoplay && entry.intersectionRatio >= RATIO_TO_ACTIVATE) {
+  // Play should start only for inactive and background(muted) videos that are visible more than on RATIO_TO_PLAY
+  if (!video.active && video.autoplay && entry.intersectionRatio >= RATIO_TO_PLAY) {
     video.play();
   }
 }
diff --git a/src/modules/esl-media/core/esl-media.shape.ts b/src/modules/esl-media/core/esl-media.shape.ts
index 344723a9c..0dbcfc095 100644
--- a/src/modules/esl-media/core/esl-media.shape.ts
+++ b/src/modules/esl-media/core/esl-media.shape.ts
@@ -1,5 +1,5 @@
 import type {ESLBaseElementShape} from '../../esl-base-element/core/esl-base-element.shape';
-import type {ESLMedia, ESLMediaFillMode} from './esl-media';
+import type {ESLMedia, ESLMediaFillMode, ESLMediaLazyMode} from './esl-media';
 
 /**
  * Tag declaration interface of ESLMedia element
@@ -23,8 +23,13 @@ export type ESLMediaTagShape = ({
   /** Define preferable aspect ratio */
   'aspect-ratio'?: string;
 
-  /** Define prevent loading marker */
+  /**
+   * Define prevent loading marker
+   * @deprecated Use {@link lazy} instead
+   */
   disabled?: boolean;
+  /** Define lazy loading mode */
+  lazy?: ESLMediaLazyMode;
   /** Define autoplay marker */
   autoplay?: boolean;
   /** Define auto grab focus on play marker */
diff --git a/src/modules/esl-media/core/esl-media.ts b/src/modules/esl-media/core/esl-media.ts
index 7505d413f..8bc89efb0 100644
--- a/src/modules/esl-media/core/esl-media.ts
+++ b/src/modules/esl-media/core/esl-media.ts
@@ -19,6 +19,10 @@ import type {BaseProvider} from './esl-media-provider';
 
 export type ESLMediaFillMode = 'cover' | 'inscribe' | '';
 
+export type ESLMediaLazyMode = 'auto' | 'manual' | 'none';
+const isLazyAttr = (v: string): v is ESLMediaLazyMode => ['auto', 'manual', 'none'].includes(v);
+const parseLazyAttr = (v: string): ESLMediaLazyMode => isLazyAttr(v) ? v : 'auto';
+
 /**
  * ESLMedia - custom element, that provides an ability to add and configure media (video / audio)
  * using a single tag as well as work with external providers using simple native-like API.
@@ -39,7 +43,8 @@ export class ESLMedia extends ESLBaseElement {
     'play-in-viewport',
     'muted',
     'loop',
-    'controls'
+    'controls',
+    'lazy'
   ];
 
   /** Event to dispatch on ready state */
@@ -70,10 +75,13 @@ export class ESLMedia extends ESLBaseElement {
   @attr() public fillMode: ESLMediaFillMode;
   /** Strict aspect ratio definition */
   @attr() public aspectRatio: string;
-
-
-  /** Disabled marker to prevent rendering */
+  /**
+   * Disabled marker to prevent rendering
+   * @deprecated replaced with {@link lazy} = "manual" functionality
+   */
   @boolAttr() public disabled: boolean;
+  /** Allows lazy load resource */
+  @attr({parser: parseLazyAttr, defaultValue: 'none'}) public lazy: ESLMediaLazyMode;
   /** Autoplay resource marker */
   @boolAttr() public autoplay: boolean;
   /** Autofocus on play marker */
@@ -139,8 +147,8 @@ export class ESLMedia extends ESLBaseElement {
     }
     this.innerHTML += '<!-- Inner Content, do not modify it manually -->';
     this.bindEvents();
-    this.attachViewportConstraint();
     this.deferredReinitialize();
+    this.reattachViewportConstraint();
   }
 
   protected override disconnectedCallback(): void {
@@ -159,6 +167,10 @@ export class ESLMedia extends ESLBaseElement {
       case 'media-type':
         this.deferredReinitialize();
         break;
+      case 'lazy':
+        this.reattachViewportConstraint();
+        this.deferredReinitialize();
+        break;
       case 'loop':
       case 'muted':
       case 'controls':
@@ -169,9 +181,7 @@ export class ESLMedia extends ESLBaseElement {
         this.deferredResize();
         break;
       case 'play-in-viewport':
-        this.playInViewport ?
-          this.attachViewportConstraint() :
-          this.detachViewportConstraint();
+        this.reattachViewportConstraint();
         break;
       case 'load-condition':
         ESLMediaQuery.for(oldVal).removeEventListener(this.deferredReinitialize);
@@ -201,7 +211,7 @@ export class ESLMedia extends ESLBaseElement {
   }
 
   public canActivate(): boolean {
-    if (this.disabled) return false;
+    if (this.lazy !== 'none' || this.disabled) return false;
     return this.conditionQuery.matches;
   }
 
@@ -239,10 +249,11 @@ export class ESLMedia extends ESLBaseElement {
 
   /**
    * Start playing media
-   * @param allowActivate - allows to remove disabled marker
+   * @param allowActivate - allows to remove manual lazy or disabled marker
    */
   public play(allowActivate: boolean = false): Promise<void> | null {
-    if (this.disabled && allowActivate) {
+    if (!this.ready && allowActivate) {
+      this.lazy = 'none';
       this.disabled = false;
       this.deferredReinitialize.cancel();
       this.reinitInstance();
@@ -400,10 +411,10 @@ export class ESLMedia extends ESLBaseElement {
     return this._provider ? this._provider.defaultAspectRatio : 0;
   }
 
-  protected attachViewportConstraint(): void {
-    if (this.playInViewport) {
-      getIObserver().observe(this);
-    }
+  protected reattachViewportConstraint(): void {
+    this.detachViewportConstraint();
+    if (!this.playInViewport && this.lazy !== 'auto') return;
+    getIObserver().observe(this);
   }
   protected detachViewportConstraint(): void {
     const observer = getIObserver(true);


Test patch:
diff --git a/src/modules/esl-media/test/esl-media.lazy.test.ts b/src/modules/esl-media/test/esl-media.lazy.test.ts
new file mode 100644
index 000000000..389221954
--- /dev/null
+++ b/src/modules/esl-media/test/esl-media.lazy.test.ts
@@ -0,0 +1,93 @@
+import '../providers/html5/video-provider';
+
+import {ESLMedia} from '../core';
+import {promisifyTimeout} from '../../esl-utils/async/promise';
+import {IntersectionObserverMock} from '../../esl-utils/test/intersectionObserver.mock';
+
+describe('esl-media: lazy loading unit tests', () => {
+
+  beforeAll(() => {
+    IntersectionObserverMock.mock();
+    ESLMedia.register();
+  });
+  afterAll(() => IntersectionObserverMock.unmock());
+
+  test('ESLMedia is loading immediately unless the lazy and disabled attributes are set', async () => {
+    const $media = ESLMedia.create();
+    $media.setAttribute('media-src', 'https://esl-ui.com/assets/media/video.mp4');
+    document.body.appendChild($media);
+
+    await promisifyTimeout(100);
+    $media.querySelectorAll('video').forEach((v) => v.dispatchEvent(new Event('loadedmetadata')));
+    await promisifyTimeout(0);
+
+    expect($media.ready).toBe(true);
+    expect($media.querySelectorAll('*')).toEqual(expect.objectContaining({length: 1}));
+  });
+
+  test('ESLMedia is not loading when lazy attribute is set to manual', async () => {
+    const $media = ESLMedia.create();
+    $media.setAttribute('media-src', 'https://esl-ui.com/assets/media/video.mp4');
+    $media.setAttribute('lazy', 'manual');
+    document.body.appendChild($media);
+
+    await promisifyTimeout(100);
+
+    expect($media.ready).toBe(false);
+    expect($media.querySelectorAll('*')).toEqual(expect.objectContaining({length: 0}));
+  });
+
+  test('ESLMedia with auto lazy is not loading until it is intersected', async () => {
+    const $media = ESLMedia.create();
+    $media.setAttribute('media-src', 'https://esl-ui.com/assets/media/video.mp4');
+    $media.setAttribute('lazy', '');
+    document.body.appendChild($media);
+
+    await promisifyTimeout(100);
+
+    expect($media.ready).toBe(false);
+    expect($media.querySelectorAll('*')).toEqual(expect.objectContaining({length: 0}));
+
+    IntersectionObserverMock.trigger($media, {isIntersecting: true, intersectionRatio: 1});
+    await promisifyTimeout(100);
+
+    expect($media.querySelectorAll('*')).toEqual(expect.objectContaining({length: 1}));
+  });
+
+  test('ESLMedia with lazy="manual" prevents loading until manual removal', async () => {
+    const $media = ESLMedia.create();
+    $media.setAttribute('media-src', 'https://esl-ui.com/assets/media/video.mp4');
+    $media.setAttribute('lazy', 'manual');
+    document.body.appendChild($media);
+
+    await promisifyTimeout(100);
+    IntersectionObserverMock.trigger($media, {isIntersecting: true, intersectionRatio: 1});
+
+    await promisifyTimeout(100);
+    expect($media.querySelectorAll('*')).toEqual(expect.objectContaining({length: 0}));
+
+    $media.removeAttribute('lazy');
+    await promisifyTimeout(100);
+
+    expect($media.querySelectorAll('*')).toEqual(expect.objectContaining({length: 1}));
+  });
+
+  test('ESLMedia (with play-in-viewport) with lazy="manual" prevents loading until manual removal', async () => {
+    const $media = ESLMedia.create();
+    $media.setAttribute('media-src', 'https://esl-ui.com/assets/media/video.mp4');
+    $media.setAttribute('play-in-viewport', '');
+    $media.setAttribute('lazy', 'manual');
+    document.body.appendChild($media);
+
+    await promisifyTimeout(100);
+    IntersectionObserverMock.trigger($media, {isIntersecting: true, intersectionRatio: 1});
+
+    await promisifyTimeout(100);
+    expect($media.querySelectorAll('*')).toEqual(expect.objectContaining({length: 0}));
+
+    $media.removeAttribute('lazy');
+    await promisifyTimeout(100);
+
+    expect($media.querySelectorAll('*')).toEqual(expect.objectContaining({length: 1}));
+  });
+});