Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(esl-lazy-template): create Lazy Markup component #2535

Open
wants to merge 10 commits into
base: main-beta
Choose a base branch
from
1 change: 1 addition & 0 deletions .commitlintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ rules:
- esl-forms
- esl-image
- esl-image-utils
- esl-lazy-template
- esl-media
- esl-media-query
- esl-mixin-element
Expand Down
47 changes: 47 additions & 0 deletions site/src/esl-lazy-template-demo/distance-to-viewport-alert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {ESLMixinElement} from '@exadel/esl/modules/esl-mixin-element/core';
import {ready} from '@exadel/esl/modules/esl-utils/decorators';
import {ESLEventUtils} from '@exadel/esl/modules/esl-utils/dom/events';
import {Rect} from '@exadel/esl/modules/esl-utils/dom/rect';
import {getWindowRect} from '@exadel/esl/modules/esl-utils/dom/window';
import {getViewportForEl} from '@exadel/esl/modules/esl-utils/dom/scroll';

export class ESLDemoDistanceToViewportAlert extends ESLMixinElement {
public static override is = 'distance-to-viewport-alert';

@ready
protected override connectedCallback(): void {
super.connectedCallback();
this.showAlert();
}

protected calculateDistance(): number {
let topDistance;
let bottomDistance;
const elementRect = Rect.from(this.$host.getBoundingClientRect());
const $root = getViewportForEl(this.$host) as HTMLElement;
if (!$root) { // window
const windowRect = getWindowRect();
topDistance = elementRect.top - windowRect.height;
bottomDistance = -elementRect.bottom;
} else {
const windowRect = Rect.from($root.getBoundingClientRect());
topDistance = elementRect.top - windowRect.bottom;
bottomDistance = -elementRect.bottom;
}
return Math.max(topDistance, bottomDistance);
}

protected showAlert(): void {
const distance = this.calculateDistance();
const text = distance < 0
? `The element with id="${this.$host.id}" was connected to DOM when it was in viewport`
: `The element with id="${this.$host.id}" was connected to DOM at the distance ${distance}px from the viewport`;

const detail = {
text,
cls: 'alert-info',
hideDelay: 5000
};
ESLEventUtils.dispatch(document.body, 'esl:alert:show', {detail});
}
}
7 changes: 5 additions & 2 deletions site/src/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ import {
ESLAnimateMixin,
ESLRelatedTarget,
ESLOpenState,

ESLCarousel,
ESLCarouselNavDots,
ESLCarouselNavMixin,
ESLCarouselTouchMixin,
ESLCarouselWheelMixin,
ESLCarouselKeyboardMixin,
ESLCarouselRelateToMixin,
ESLCarouselAutoplayMixin
ESLCarouselAutoplayMixin,
ESLLazyTemplate
} from '@exadel/esl/modules/all';

import {ESLRandomText} from '@exadel/esl/modules/esl-random-text/core';
Expand All @@ -63,6 +63,7 @@ import {ESLDemoAnchorLink} from './anchor/anchor-link';
import {ESLDemoBanner} from './banner/banner';
import {ESLDemoSwipeArea, ESLDemoWheelArea} from './esl-events-demo/esl-events-demo';
import {ESLDemoPopupGame} from './esl-popup/esl-d-popup-game';
import {ESLDemoDistanceToViewportAlert} from './esl-lazy-template-demo/distance-to-viewport-alert';

if (!CSS.supports('(height: 100dvh) or (width: 100dvw)')) ESLVSizeCSSProxy.observe();

Expand All @@ -79,6 +80,7 @@ ESLDemoBanner.register();
ESLDemoSwipeArea.register();
ESLDemoWheelArea.register();
ESLDemoPopupGame.register();
ESLDemoDistanceToViewportAlert.register();

// Test Content
ESLRandomText.register('lorem-ipsum');
Expand Down Expand Up @@ -131,6 +133,7 @@ ESLAnimateMixin.register();
// Register ESL Mixins
ESLRelatedTarget.register();
ESLOpenState.register();
ESLLazyTemplate.register();

// Share component loading
import (/* webpackChunkName: 'common/esl-share' */'./esl-share/esl-share');
Expand Down
1 change: 1 addition & 0 deletions site/static/assets/examples/lazy-template.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions site/static/assets/lazy-templates/template1.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<div id="template-from-url-1"
class="text-center"
distance-to-viewport-alert
style="background: lightyellow; border: 3px dashed darkorange;">
<p>...</p>
<p>I am content No.1 which was added to DOM by the lazy template and loaded from the specified URL</p>
<p>I am inside DIV with id="template-from-url-1".</p>
<p>...</p>
</div>
9 changes: 9 additions & 0 deletions site/static/assets/lazy-templates/template2.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<div id="template-from-url-2"
class="text-center"
distance-to-viewport-alert
style="background: lightyellow; border: 3px dashed darkorange;">
<p>...</p>
<p>I am content No.2 which was added to DOM by the lazy template and loaded from the specified URL</p>
<p>I am inside DIV with id="template-from-url-2".</p>
<p>...</p>
</div>
13 changes: 13 additions & 0 deletions site/views/components/esl-lazy-template.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
layout: content
title: ESL Lazy Template
seoTitle: ESL Image - custom image element with lazy loading, renditions, and different modes of embedding
name: ESL Lazy Template
tags: components
aside:
source: src/modules/esl-lazy-template
examples:
- lazy-template
---

{% mdRender 'src/modules/esl-lazy-template/README.md', 'intro' %}
115 changes: 115 additions & 0 deletions site/views/examples/lazy-template.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
layout: content
title: Lazy Template
seoTitle: Lazy-loaded templates examples based on ESL web components
name: Lazy Template
tags: [examples, playground, beta]
icon: examples/lazy-template.svg
aside:
components:
- esl-lazy-template
---
{% import 'lorem.njk' as lorem %}

{% set imageSrcBase = '/assets/' | url %}

<section class="row">
<div class="col-12">
<uip-root>
<script type="text/html"
label="Markup from content of TEMPLATE element"
uip-snippet
uip-snippet-js="js-snippet-lazy-template">
<div class="d-flex">
<div class="col-sm-6">
<!-- paragraph 13 -->
<template esl-lazy-template>
<div id="i-am-from-markup-1"
class="text-center"
distance-to-viewport-alert
style="background: lightyellow; border: 3px dashed darkorange;">
<p>...</p>
<p>I am content No.1 which was added to DOM by the lazy template from its own content</p>
<p>I am inside DIV with id="i-am-from-markup-1".</p>
<p>...</p>
</div>
</template>
<!-- paragraph 12 -->
<template id="q2" esl-lazy-template>
<div id="i-am-from-markup-2"
class="text-center"
distance-to-viewport-alert
style="background: lightyellow; border: 3px dashed darkorange;">
<p>...</p>
<p>I am content No.2 which was added to DOM by the lazy template from its own content</p>
<p>I am inside DIV with id="i-am-from-markup-2".</p>
<p>...</p>
</div>
</template>
<!-- paragraph 11 -->
</div>
<div class="col-sm-6">
<h3>Lazy markup from content of TEMPLATE element</h3>
<p>The ESLLazyTemplate has to add its content to the DOM (replace itself with content). The content is not replaced immediately, but only when the location of the element with the deferred template is close to the viewport. In general, it is also possible that it will not be replaced at all if the user does not scroll the page.</p>
<p>If there is no content in the template, replacing it with nothing will actually turn it into a deletion.</p>
</div>
</div>
</script>

<script type="text/html"
label="Markup from URL on TEMPLATE tag"
uip-snippet
uip-snippet-js="js-snippet-lazy-template">
<div class="d-flex">
<div class="col-sm-6">
<!-- paragraph 14 -->
<template esl-lazy-template="/assets/lazy-templates/template1.html"></template>
<!-- paragraph 12 -->
<template esl-lazy-template="/assets/lazy-templates/template2.html"></template>
<!-- paragraph 11 -->
<template esl-lazy-template="/assets/lazy-templates/template3.html"></template>
</div>
<div class="col-sm-6">
<h3>Lazy markup from URL on TEMPLATE tag</h3>
<p>The ESLLazyTemplate should add the content to the DOM that is located at the specified URL. The content request is not made immediately, but only when the location of the element with the lazy template approaches the viewport. In general, it is also possible that it will not be loaded at all if the user does not scroll the page.</p>
<p>When it's time to load, ESLLazyTemplate attempts to fetch the content and, if the request is successful, replaces itself with this content. If it fails to load the content, it is simply deleted.</p>
</div>
</div>
</script>

<script type="text/html"
label="Markup from URL on DIV tag"
uip-snippet
uip-snippet-js="js-snippet-lazy-template">
<div class="d-flex">
<div class="col-sm-6">
<!-- paragraph 15 -->
<div esl-lazy-template="/assets/lazy-templates/template1.html">... I should be replaced by a loaded template ...</div>
<!-- paragraph 14 -->
<div esl-lazy-template="/assets/lazy-templates/template2.html">... I should be replaced by a loaded template ...</div>
<!-- paragraph 12 -->
<div esl-lazy-template="/assets/lazy-templates/template3.html">... I should be replaced by a loaded template ...</div>
</div>
<div class="col-sm-6">
<h3>Lazy markup from URL on DIV tag</h3>
<p>The ESLLazyTemplate should add the content to the DOM that is located at the specified URL. The content request is not made immediately, but only when the location of the element with the lazy template approaches the viewport. In general, it is also possible that it will not be loaded at all if the user does not scroll the page.</p>
<p>When it's time to load, ESLLazyTemplate attempts to fetch the content and, if the request is successful, replaces itself with this content. If it fails to load the content, it is simply deleted.</p>
<p>The DIV can have some custom content, but it will exist only before the template is loaded and the DIV is replaced with the content of the loaded template.</p>
<p></p>
</div>
</div>
</script>

<script id="js-snippet-lazy-template" type="text/plain">
import { ESLImage, getViewportForEl } from '@exadel/esl';
ESLLazyTemplate.viewportProvider = getViewportForEl;
ESLLazyTemplate.register();
</script>

<uip-snippets class="uip-toolbar" dropdown-view="@xs"></uip-snippets>
<uip-preview style="max-height: 500px"></uip-preview>
<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>
</div>
</section>
3 changes: 3 additions & 0 deletions src/modules/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,6 @@ export * from './esl-carousel/core';

// Anchornav
export * from './esl-anchornav/core';

// Lazy Template
export * from './esl-lazy-template/core';
25 changes: 25 additions & 0 deletions src/modules/esl-lazy-template/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# [ESL](../../../) Lazy Template Mixin

Version: *1.0.0-beta*.

Authors: *Dmytro Shovchko*.

<a name="intro"></a>

**ESLLazyTemplate** is a custom mixin that can be used in combination with any HTML elements (usually `template` or `div`) to make parts of page markup not present in DOM if they are outside the viewport. It is also possible to not include these invisible parts of the markup in the page markup at once but to load it with a separate request if the user has scrolled the page and the elements marked by the mixin are now in the viewport.

This can be useful if you don't want to load all the content on the page at once, but load it only if the user scrolls to it. For example, some ads from some advertising networks.

To use **ESLLazyTemplate** you need to include the following code:
```js
ESLLazyTemplate.register();
```

### Lazy Template Mixin Example

```html
<template esl-lazy-template="/assets/templates/lazy-markup.html"></template>
<div esl-lazy-template="/assets/templates/lazy-markup.html"></div>

<template esl-lazy-template> /* inline lazy content */ </template>
```
1 change: 1 addition & 0 deletions src/modules/esl-lazy-template/core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './core/esl-lazy-template';
110 changes: 110 additions & 0 deletions src/modules/esl-lazy-template/core/esl-lazy-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {ESLMixinElement} from '../../esl-mixin-element/core';
import {attr, listen, prop, memoize} from '../../esl-utils/decorators';
import {ESLIntersectionTarget, ESLIntersectionEvent} from '../../esl-event-listener/core/targets/intersection.target';
import {ExportNs} from '../../esl-utils/environment/export-ns';
import {getViewportForEl} from '../../esl-utils/dom/scroll';

@ExportNs('LazyTemplate')
export class ESLLazyTemplate extends ESLMixinElement {
public static override is = 'esl-lazy-template';

@prop(750) baseMargin: number;
@prop([0, 0.01]) protected INTERSECTION_THRESHOLD: number[];

/** URL to load content from */
@attr({name: ESLLazyTemplate.is})
public url?: string;

/** IntersectionObserver rootMargin value */
protected get rootMargin(): string {
return `${this.baseMargin * this.connectionRatio}px`;
}

/** Connection speed ratio */
protected get connectionRatio(): number {
switch (navigator.connection?.effectiveType) {
case 'slow-2g':
case '2g': return 2;
case '3g': return 1.5;
case '4g':
default: return 1;
}
}

/** Host element is a template */
protected get isHostTemplate(): boolean {
return this.$host instanceof HTMLTemplateElement;
}

/** LazyTemplate placeholder */
@memoize()
public get $placeholder(): HTMLElement {
const placeholder = document.createElement('div');
placeholder.className = `${ESLLazyTemplate.is}-placeholder`;
this.$host.before(placeholder);
return placeholder;
}

/** LazyTemplate viewport (root element for IntersectionObservers checking visibility) */
@memoize()
protected get $viewport(): Element | undefined {
return getViewportForEl(this.$host);
}

protected override disconnectedCallback(): void {
super.disconnectedCallback();
this.$placeholder.remove();
memoize.clear(this, '$placeholder');
}

/** Loads content from the URL */
@memoize()
protected async loadContent(url: string): Promise<Node | string> {
try {
const response = await fetch(url);
if (!response.ok) return '';
const $template = document.createElement('template');
$template.innerHTML = await response.text();
return $template.content.cloneNode(true);
} catch (e) {
return '';
}
}

/** Gets content from the URL or host template element */
protected async getContent(): Promise<string | Node> {
if (this.url) return this.loadContent(this.url);
if (this.isHostTemplate) return (this.$host as HTMLTemplateElement).content.cloneNode(true);
return '';
}

/** Replaces host element with content */
protected async replaceWithContent(): Promise<void> {
const content = await this.getContent();
this.$host.replaceWith(content);
}

@listen({
event: ESLIntersectionEvent.IN,
target: (that: ESLLazyTemplate) => ESLIntersectionTarget.for(that.$placeholder, {
root: that.$viewport,
rootMargin: that.rootMargin,
threshold: that.INTERSECTION_THRESHOLD
})
})
protected _onIntersect(e: ESLIntersectionEvent): void {
this.replaceWithContent();
}
}

declare global {
export interface ESLLibrary {
LazyTemplate: typeof ESLLazyTemplate;
}

interface Navigator extends NavigatorNetworkInformation {}
interface NavigatorNetworkInformation {
readonly connection?: {
readonly effectiveType: 'slow-2g' | '2g' | '3g' | '4g';};
}
}