diff --git a/.commitlintrc.yml b/.commitlintrc.yml index 72c6e9510..78bf044a0 100644 --- a/.commitlintrc.yml +++ b/.commitlintrc.yml @@ -40,6 +40,7 @@ rules: - esl-forms - esl-image - esl-image-utils + - esl-lazy-template - esl-media - esl-media-query - esl-mixin-element diff --git a/site/src/esl-lazy-template-demo/distance-to-viewport-alert.ts b/site/src/esl-lazy-template-demo/distance-to-viewport-alert.ts new file mode 100644 index 000000000..78ef4fe27 --- /dev/null +++ b/site/src/esl-lazy-template-demo/distance-to-viewport-alert.ts @@ -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}); + } +} diff --git a/site/src/site.ts b/site/src/site.ts index a196de508..5ef1436d5 100644 --- a/site/src/site.ts +++ b/site/src/site.ts @@ -34,7 +34,6 @@ import { ESLAnimateMixin, ESLRelatedTarget, ESLOpenState, - ESLCarousel, ESLCarouselNavDots, ESLCarouselNavMixin, @@ -42,7 +41,8 @@ import { ESLCarouselWheelMixin, ESLCarouselKeyboardMixin, ESLCarouselRelateToMixin, - ESLCarouselAutoplayMixin + ESLCarouselAutoplayMixin, + ESLLazyTemplate } from '@exadel/esl/modules/all'; import {ESLRandomText} from '@exadel/esl/modules/esl-random-text/core'; @@ -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(); @@ -79,6 +80,7 @@ ESLDemoBanner.register(); ESLDemoSwipeArea.register(); ESLDemoWheelArea.register(); ESLDemoPopupGame.register(); +ESLDemoDistanceToViewportAlert.register(); // Test Content ESLRandomText.register('lorem-ipsum'); @@ -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'); diff --git a/site/static/assets/examples/lazy-template.svg b/site/static/assets/examples/lazy-template.svg new file mode 100644 index 000000000..ce8ee5411 --- /dev/null +++ b/site/static/assets/examples/lazy-template.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/site/static/assets/lazy-templates/template1.html b/site/static/assets/lazy-templates/template1.html new file mode 100644 index 000000000..5c76627ca --- /dev/null +++ b/site/static/assets/lazy-templates/template1.html @@ -0,0 +1,9 @@ +
+

...

+

I am content No.1 which was added to DOM by the lazy template and loaded from the specified URL

+

I am inside DIV with id="template-from-url-1".

+

...

+
diff --git a/site/static/assets/lazy-templates/template2.html b/site/static/assets/lazy-templates/template2.html new file mode 100644 index 000000000..9bdaa7e6b --- /dev/null +++ b/site/static/assets/lazy-templates/template2.html @@ -0,0 +1,9 @@ +
+

...

+

I am content No.2 which was added to DOM by the lazy template and loaded from the specified URL

+

I am inside DIV with id="template-from-url-2".

+

...

+
diff --git a/site/views/components/esl-lazy-template.njk b/site/views/components/esl-lazy-template.njk new file mode 100644 index 000000000..0a7dd92d3 --- /dev/null +++ b/site/views/components/esl-lazy-template.njk @@ -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' %} diff --git a/site/views/examples/lazy-template.njk b/site/views/examples/lazy-template.njk new file mode 100644 index 000000000..da6f4fd5f --- /dev/null +++ b/site/views/examples/lazy-template.njk @@ -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 %} + +
+
+ + + + + + + + + + + + + + +
+
diff --git a/src/modules/all.ts b/src/modules/all.ts index 4c764ffa5..cac1f24d3 100644 --- a/src/modules/all.ts +++ b/src/modules/all.ts @@ -56,3 +56,6 @@ export * from './esl-carousel/core'; // Anchornav export * from './esl-anchornav/core'; + +// Lazy Template +export * from './esl-lazy-template/core'; diff --git a/src/modules/esl-lazy-template/README.md b/src/modules/esl-lazy-template/README.md new file mode 100644 index 000000000..bf45b5f28 --- /dev/null +++ b/src/modules/esl-lazy-template/README.md @@ -0,0 +1,25 @@ +# [ESL](../../../) Lazy Template Mixin + +Version: *1.0.0-beta*. + +Authors: *Dmytro Shovchko*. + + + +**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 + +
+ + +``` diff --git a/src/modules/esl-lazy-template/core.ts b/src/modules/esl-lazy-template/core.ts new file mode 100644 index 000000000..889209a10 --- /dev/null +++ b/src/modules/esl-lazy-template/core.ts @@ -0,0 +1 @@ +export * from './core/esl-lazy-template'; diff --git a/src/modules/esl-lazy-template/core/esl-lazy-template.ts b/src/modules/esl-lazy-template/core/esl-lazy-template.ts new file mode 100644 index 000000000..0e217ac66 --- /dev/null +++ b/src/modules/esl-lazy-template/core/esl-lazy-template.ts @@ -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 { + 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 { + 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 { + 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';}; + } +}