Repository: exadel-inc/esl
Pull request #1685 (2023-05-30 03:42:31)

[esl-carousel]: restructuring wave 3


Patch:
diff --git a/pages/src/common/helpers.less b/pages/src/common/helpers.less
index a57aed2a7..ef44e663a 100644
--- a/pages/src/common/helpers.less
+++ b/pages/src/common/helpers.less
@@ -18,7 +18,7 @@
 .hide {
   @media @xs {
     &-xs {
-      display: none;
+      display: none !important;
     }
   }
 }
diff --git a/pages/views/examples/carousel.njk b/pages/views/examples/carousel.njk
index 32fbcf114..9c8e5ec2a 100644
--- a/pages/views/examples/carousel.njk
+++ b/pages/views/examples/carousel.njk
@@ -10,7 +10,7 @@ icon: examples/carousel.svg
 <h2>Multi-slide Carousel (cards, adaptive, loop): </h2>
 <section>
   <div esl-carousel-container>
-    <button class="esl-carousel-arrow icon-arrow-prev"
+    <button class="esl-carousel-arrow icon-arrow-prev hide-xs"
             esl-carousel-nav="group:prev"><span class="sr-only">Previous Slide</span></button>
 
     <esl-carousel esl-carousel-touch
@@ -39,7 +39,7 @@ icon: examples/carousel.svg
       </div>
     </esl-carousel>
 
-    <button class="esl-carousel-arrow icon-arrow-next"
+    <button class="esl-carousel-arrow icon-arrow-next hide-xs"
             esl-carousel-nav="group:next"><span class="sr-only">Next Slide</span></button>
 
     <esl-carousel-dots class="carousel-dots-wrapper"></esl-carousel-dots>
@@ -50,11 +50,14 @@ icon: examples/carousel.svg
 <h2>Multi-slide Carousel (simplified cards, adaptive, loop): </h2>
 <section>
   <div esl-carousel-container>
-     <button class="esl-carousel-arrow icon-arrow-prev" esl-carousel-nav="group:prev">
+     <button class="esl-carousel-arrow icon-arrow-prev hide-xs" esl-carousel-nav="group:prev">
         <span class="sr-only">Previous Slide</span>
      </button>
 
-     <esl-carousel media="@XS|@SM|@MD|@LG|@XL" count="2|3|4|5|6" loop="true">
+     <esl-carousel esl-carousel-touch="@mobile"
+                   media="@XS|@SM|@MD|@LG|@XL"
+                   count="2|3|4|5|6"
+                   loop="true">
         <div data-slides-area>
         {% for item in items %}
            <esl-carousel-slide {{ 'active' if loop.first }}>
@@ -66,7 +69,7 @@ icon: examples/carousel.svg
         </div>
      </esl-carousel>
 
-     <button class="esl-carousel-arrow icon-arrow-next" esl-carousel-nav="group:next">
+     <button class="esl-carousel-arrow icon-arrow-next hide-xs" esl-carousel-nav="group:next">
         <span class="sr-only">Next Slide</span>
      </button>
 
@@ -77,11 +80,13 @@ icon: examples/carousel.svg
 <h2>Multi-slide Carousel (simplified cards, adaptive, without loop): </h2>
 <section>
   <div esl-carousel-container>
-    <button class="esl-carousel-arrow icon-arrow-prev" esl-carousel-nav="group:prev">
+    <button class="esl-carousel-arrow icon-arrow-prev hide-xs" esl-carousel-nav="group:prev">
       <span class="sr-only">Previous Slide</span>
     </button>
 
-    <esl-carousel media="@XS|@SM|@MD|@LG|@XL" count="2|3|4|5|6">
+    <esl-carousel esl-carousel-touch="@mobile"
+                  media="@XS|@SM|@MD|@LG|@XL"
+                  count="2|3|4|5|6">
       <div data-slides-area>
         {% for item in items %}
           <esl-carousel-slide {{ 'active' if loop.first }}>
@@ -93,7 +98,7 @@ icon: examples/carousel.svg
       </div>
     </esl-carousel>
 
-    <button class="esl-carousel-arrow icon-arrow-next" esl-carousel-nav="group:next">
+    <button class="esl-carousel-arrow icon-arrow-next hide-xs" esl-carousel-nav="group:next">
       <span class="sr-only">Next Slide</span>
     </button>
 
@@ -104,11 +109,11 @@ icon: examples/carousel.svg
 <h2>Single active slide demo: </h2>
 <section>
   <div esl-carousel-container>
-     <button class="esl-carousel-arrow icon-arrow-prev" esl-carousel-nav="group:prev">
+     <button class="esl-carousel-arrow icon-arrow-prev hide-xs" esl-carousel-nav="group:prev">
         <span class="sr-only">Previous Slide</span>
      </button>
 
-     <esl-carousel esl-carousel-touch="@-md" loop="true">
+     <esl-carousel esl-carousel-touch="@mobile" loop="true">
         <div data-slides-area>
           {% for i in range(0, 4) -%}
            <esl-carousel-slide>
@@ -124,7 +129,7 @@ icon: examples/carousel.svg
         </div>
      </esl-carousel>
 
-     <button class="esl-carousel-arrow icon-arrow-next" esl-carousel-nav="group:next">
+     <button class="esl-carousel-arrow icon-arrow-next hide-xs" esl-carousel-nav="group:next">
         <span class="sr-only">Next Slide</span>
      </button>
 
diff --git a/src/modules/esl-carousel/core/esl-carousel.events.ts b/src/modules/esl-carousel/core/esl-carousel.events.ts
index ce506f52c..48f8492e2 100644
--- a/src/modules/esl-carousel/core/esl-carousel.events.ts
+++ b/src/modules/esl-carousel/core/esl-carousel.events.ts
@@ -1,16 +1,24 @@
 import type {ESLCarousel} from './esl-carousel';
 import type {ESLCarouselSlide} from './esl-carousel.slide';
-import type {ESLCarouselDirection} from './nav/esl-carousel.nav.types';
+import type {ESLCarouselDirection, ESLCarouselStaticState} from './nav/esl-carousel.nav.types';
 
+/** {@link ESLCarouselSlideEvent} init object */
 interface ESLCarouselSlideEventInit {
+  /** Current slide index */
   current: number;
+  /** Related slide index (target on pre-event, current on post-event) */
   related: number;
+  /** Direction of slide animation */
   direction: ESLCarouselDirection;
+  /** Auxiliary request attribute that represents object that initiate slide change */
   activator?: any;
 }
 
+/** {@link ESLCarousel} event that represents slide change event */
 export class ESLCarouselSlideEvent extends Event implements ESLCarouselSlideEventInit {
+  /** {@link ESLCarouselSlideEvent} event type dispatched before slide change (pre-event) */
   public static readonly BEFORE = 'esl:before:slide:change';
+  /** {@link ESLCarouselSlideEvent} event type dispatched after slide change (post-event) */
   public static readonly AFTER = 'esl:slide:change';
 
   public override readonly target: ESLCarousel;
@@ -36,17 +44,28 @@ export class ESLCarouselSlideEvent extends Event implements ESLCarouselSlideEven
   }
 }
 
+/** {@link ESLCarouselChangeEvent} init object */
 interface ESLCarouselChangeEventInit {
-  prop: string;
-  addedSlide?: ESLCarouselSlide;
-  removedSlide?: ESLCarouselSlide;
+  /** Current {@link ESLCarousel} instance config */
+  config: ESLCarouselStaticState;
+  /** Previous {@link ESLCarousel} instance config */
+  oldConfig?: ESLCarouselStaticState;
+  /** A list of {@link ESLCarouselSlide}s that have added to the current carousel instance */
+  added: ESLCarouselSlide[];
+  /** A list of {@link ESLCarouselSlide}s that have removed from the current carousel instance */
+  removed: ESLCarouselSlide[];
 }
 
+/** {@link ESLCarousel} event that represents slide configuration change */
 export class ESLCarouselChangeEvent extends Event implements ESLCarouselChangeEventInit {
+  /** {@link ESLCarouselSlideEvent} event type dispatched on carousel config changes */
   public static readonly TYPE = 'esl:carousel:change';
 
   public override readonly target: ESLCarousel;
-  public readonly prop: string;
+  public readonly config: ESLCarouselStaticState;
+  public readonly oldConfig?: ESLCarouselStaticState;
+  public readonly added: ESLCarouselSlide[] = [];
+  public readonly removed: ESLCarouselSlide[] = [];
 
   protected constructor(init: ESLCarouselChangeEventInit) {
     super(ESLCarouselChangeEvent.TYPE, {
diff --git a/src/modules/esl-carousel/core/esl-carousel.less b/src/modules/esl-carousel/core/esl-carousel.less
index 1aaa92e53..bd750a4d5 100644
--- a/src/modules/esl-carousel/core/esl-carousel.less
+++ b/src/modules/esl-carousel/core/esl-carousel.less
@@ -1,16 +1,20 @@
 // CORE styles
 esl-carousel {
+  position: relative;
   display: block;
   max-width: 100%;
-  padding: 5px 0;
-
-  position: relative;
+  // Important to ignore padding/margin hack
+  box-sizing: content-box;
+  margin: -5px;
+  padding: 5px;
   overflow: hidden;
 
+
   [data-slides-area] {
+    position: relative;
     width: 100%;
     display: flex;
-    position: relative;
+    gap: 20px;
     z-index: 0;
     transform: translate3d(0px, 0px, 0px);
   }
@@ -30,7 +34,7 @@ esl-carousel {
     display: flex;
     box-sizing: border-box;
     margin: 0;
-    padding: 0 10px;
+    padding: 0;
 
     position: relative;
     left: 0;
@@ -43,5 +47,4 @@ esl-carousel {
 
 [esl-carousel-container] {
   position: relative;
-  margin: 0 -10px;
 }
diff --git a/src/modules/esl-carousel/core/esl-carousel.renderer.ts b/src/modules/esl-carousel/core/esl-carousel.renderer.ts
index d045ee333..39c10749c 100644
--- a/src/modules/esl-carousel/core/esl-carousel.renderer.ts
+++ b/src/modules/esl-carousel/core/esl-carousel.renderer.ts
@@ -9,20 +9,28 @@ export abstract class ESLCarouselRenderer {
   public static is: string;
 
   protected readonly carousel: ESLCarousel;
-  protected slideWidth: number = 0;
 
   constructor(carousel: ESLCarousel) {
     this.carousel = carousel; // TODO: unsafe while lifecycle is not clear
   }
 
+  /** @returns renderer type name */
   public get type(): string {
     return (this.constructor as typeof ESLCarouselRenderer).is;
   }
 
-  /**  @returns count of carousel slides. */
+  /** @returns count of carousel slides */
   public get size(): number {
     return this.carousel.size;
   }
+  /** @returns count of visible carousel slides */
+  public get count(): number {
+    return this.carousel.config.count;
+  }
+  /** @returns if the carousel is in a loop mode */
+  public get loop(): boolean {
+    return this.carousel.config.loop;
+  }
 
   public bind(): void {
     const type = this.constructor as typeof ESLCarouselRenderer;
@@ -45,9 +53,9 @@ export abstract class ESLCarouselRenderer {
   public redraw(): void {}
   /** Process slide change process */
   public async navigate(index: number, direction: ESLCarouselDirection, {activator}: ESLCarouselActionParams): Promise<void> {
-    const {activeIndex, activeIndexes, count} = this.carousel;
+    const {activeIndex, activeIndexes} = this.carousel;
 
-    if (activeIndex === index && activeIndexes.length === count) return;
+    if (activeIndex === index && activeIndexes.length === this.count) return;
     if (!this.carousel.dispatchEvent(ESLCarouselSlideEvent.create('BEFORE', {
       direction,
       activator,
@@ -88,7 +96,7 @@ export abstract class ESLCarouselRenderer {
   /** Sets active slides from passed index **/
   public setActive(from: number): void {
     this.carousel.$slides.forEach((el) => el.active = false);
-    for (let i = 0; i < this.carousel.count; i++) {
+    for (let i = 0; i < this.count; i++) {
       this.carousel.slideAt(from + i).active = true;
     }
   }
diff --git a/src/modules/esl-carousel/core/esl-carousel.slide.ts b/src/modules/esl-carousel/core/esl-carousel.slide.ts
index e223bfc51..dbbfbec9b 100644
--- a/src/modules/esl-carousel/core/esl-carousel.slide.ts
+++ b/src/modules/esl-carousel/core/esl-carousel.slide.ts
@@ -22,7 +22,7 @@ export class ESLCarouselSlide extends ESLBaseElement {
 
   protected override connectedCallback(): void {
     super.connectedCallback();
-    this.$carousel?.updateSlide && this.$carousel.updateSlide(this);
+    this.$carousel?.addSlide && this.$carousel.addSlide(this);
     this.setAttribute('role', 'group');
     if (!this.hasAttribute('aria-label')) {
       this.setAttribute('aria-label', `slide ${this.index + 1}`);
@@ -30,15 +30,14 @@ export class ESLCarouselSlide extends ESLBaseElement {
   }
 
   protected override disconnectedCallback(): void {
-    this.$carousel?.updateSlide && this.$carousel.updateSlide(this);
+    this.$carousel?.removeSlide && this.$carousel.removeSlide(this);
     memoize.clear(this, '$carousel');
     super.disconnectedCallback();
   }
 
   /** @returns index of the slide in the carousel. */
   public get index(): number {
-    if (!this.parentNode) return -1;
-    // TODO: refactor (check type of Element)
+    if (!this.parentNode) return NaN;
     return Array.prototype.indexOf.call(this.parentNode.children, this);
   }
 
diff --git a/src/modules/esl-carousel/core/esl-carousel.ts b/src/modules/esl-carousel/core/esl-carousel.ts
index ff9c04bc1..5d22ed299 100644
--- a/src/modules/esl-carousel/core/esl-carousel.ts
+++ b/src/modules/esl-carousel/core/esl-carousel.ts
@@ -1,7 +1,10 @@
 import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {ESLBaseElement} from '../../esl-base-element/core';
-import {attr, boolAttr, listen, memoize} from '../../esl-utils/decorators';
-import {parseBoolean} from '../../esl-utils/misc/format';
+import {attr, boolAttr, decorate, listen, memoize} from '../../esl-utils/decorators';
+
+import {microtask} from '../../esl-utils/async';
+import {parseBoolean, isEqual} from '../../esl-utils/misc';
+
 import {ESLMediaRuleList} from '../../esl-media-query/core';
 import {ESLResizeObserverTarget} from '../../esl-event-listener/core';
 
@@ -9,10 +12,9 @@ import {normalizeIndex, toIndex, canNavigate} from './nav/esl-carousel.nav.utils
 
 import {ESLCarouselSlide} from './esl-carousel.slide';
 import {ESLCarouselRenderer} from './esl-carousel.renderer';
-import {ESLCarouselChangeEvent, ESLCarouselSlideEvent} from './esl-carousel.events';
-
-import type {ESLCarouselState, ESLCarouselDirection, ESLCarouselSlideTarget} from './nav/esl-carousel.nav.types';
+import {ESLCarouselChangeEvent} from './esl-carousel.events';
 
+import type {ESLCarouselState, ESLCarouselDirection, ESLCarouselSlideTarget, ESLCarouselStaticState} from './nav/esl-carousel.nav.types';
 
 /** {@link ESLCarousel} action params interface */
 export interface ESLCarouselActionParams {
@@ -31,7 +33,7 @@ export interface ESLCarouselActionParams {
  * ESLCarousel - a slideshow component for cycling through slides {@link ESLCarouselSlide}.
  */
 @ExportNs('Carousel')
-export class ESLCarousel extends ESLBaseElement implements ESLCarouselState {
+export class ESLCarousel extends ESLBaseElement {
   public static override is = 'esl-carousel';
   public static observedAttributes = ['media', 'type', 'loop', 'count'];
 
@@ -63,15 +65,21 @@ export class ESLCarousel extends ESLBaseElement implements ESLCarouselState {
     return ESLMediaRuleList.parse(this.mediaCfg, this.countCfg, parseInt);
   }
 
-  /** @returns if the carousel is in a loop mode */
+  /** Carousel instance current {@link ESLCarouselStaticState} */
   @memoize()
-  public get loop(): boolean {
-    return this.loopRule.value || false;
+  public get config(): ESLCarouselStaticState {
+    return {
+      size: this.$slides.length,
+      count: this.countRule.value || 1,
+      loop: !!this.loopRule.value
+    };
   }
-  /** @returns count of active (visible) slides */
-  @memoize()
-  public get count(): number {
-    return this.countRule.value || 1;
+
+  /** Carousel instance current {@link ESLCarouselState} */
+  public get state(): ESLCarouselState {
+    return Object.assign({}, this.config, {
+      activeIndex: this.activeIndex
+    });
   }
 
   /** @returns currently active renderer */
@@ -79,14 +87,13 @@ export class ESLCarousel extends ESLBaseElement implements ESLCarouselState {
   public get renderer(): ESLCarouselRenderer {
     const type = this.typeRule.value || 'multi';
     const renderer = ESLCarouselRenderer.registry.create(type, this);
-    renderer && renderer.bind();
+    renderer && renderer.bind(); // TODO: small small - getter side effect
     return renderer;
   }
 
   protected override connectedCallback(): void {
     super.connectedCallback();
-
-    this.update(true);
+    this.update();
     this.updateA11y();
   }
 
@@ -96,42 +103,45 @@ export class ESLCarousel extends ESLBaseElement implements ESLCarouselState {
     this.update();
   }
 
-  /** Updates the config and the state that is associated with. */
-  public update(force: boolean = false): void {
-    const cfgChanged = this.count !== this.countRule.value || this.loop !== this.loopRule.value;
-    const typeChanged = this.renderer?.type !== this.typeRule.value;
+  /** Updates the config and the state that is associated with */
+  @decorate(microtask)
+  public update(): void {
+    const oldType = this.renderer.type;
+    const oldConfig = this.config;
+    const $oldSlides = this.$slides;
 
-    if (typeChanged) {
+    memoize.clear(this, ['config', '$slides']);
+    if (oldType !== this.typeRule.value) {
       this.renderer && this.renderer.unbind();
       memoize.clear(this, 'renderer');
     }
+    this.renderer.redraw();
 
-    if (force || typeChanged || cfgChanged) {
-      memoize.clear(this, ['loop', 'count']);
+    const added = this.$slides.filter((slide) => !$oldSlides.includes(slide));
+    const removed = $oldSlides.filter((slide) => !this.$slides.includes(slide));
+    const config = this.config;
 
-      this.dispatchEvent(ESLCarouselChangeEvent.create({
-        prop: 'config'
-      }));
-    }
+    if (!added.length && !removed.length && isEqual(config, oldConfig)) return;
+    this.dispatchEvent(ESLCarouselChangeEvent.create({added, removed, config, oldConfig}));
+  }
 
-    this.renderer.redraw();
+  /** Appends slide instance to the current carousel */
+  public addSlide(slide: ESLCarouselSlide): void {
+    if (slide.parentNode !== this.$slidesArea) this.$slidesArea?.appendChild(slide);
+    this.update();
   }
 
-  protected updateA11y(): void {
-    // TODO: update a11y -> check a11y everywhere
-    const ariaLabel = this.hasAttribute('aria-label');
-    !ariaLabel && this.setAttribute('aria-label', 'Carousel');
+  /** Remove slide instance from the current carousel */
+  public removeSlide(slide: ESLCarouselSlide): void {
+    if (slide.parentNode === this.$slidesArea) this.$slidesArea?.removeChild(slide);
+    this.update();
   }
 
-  public updateSlide(slide: ESLCarouselSlide): void {
-    memoize.clear(this, '$slides');
-    this.renderer && this.renderer.redraw();
-    const isAdded = this.$slides.includes(slide);
-    this.dispatchEvent(ESLCarouselChangeEvent.create({
-      prop: 'slides',
-      addedSlide: isAdded ? slide : undefined,
-      removedSlide: isAdded ? undefined : slide
-    }));
+  protected updateA11y(): void {
+    // TODO: update a11y -> check a11y everywhere
+    // this.$slidesArea?.setAttribute('aria-live', 'polite');
+    // const ariaLabel = this.hasAttribute('aria-label');
+    // !ariaLabel && this.setAttribute('aria-label', 'Carousel');
   }
 
   @listen({
@@ -211,7 +221,7 @@ export class ESLCarousel extends ESLBaseElement implements ESLCarouselState {
   /** Goes to the target according to passed params */
   public goTo(target: ESLCarouselSlideTarget, params: ESLCarouselActionParams = {}): void {
     if (!this.renderer) return;
-    const {index, dir} = toIndex(target, this);
+    const {index, dir} = toIndex(target, this.state);
     const direction = params.direction || dir || 'next';
     this.renderer.navigate(index, direction, params);
   }
@@ -223,7 +233,7 @@ export class ESLCarousel extends ESLBaseElement implements ESLCarouselState {
 
   /** @returns if the passed slide target can be reached */
   public canNavigate(target: ESLCarouselSlideTarget): boolean {
-    return canNavigate(target, this);
+    return canNavigate(target, this.state);
   }
 
   /**
diff --git a/src/modules/esl-carousel/core/nav/esl-carousel.nav.types.ts b/src/modules/esl-carousel/core/nav/esl-carousel.nav.types.ts
index 9d158f1a1..223fef656 100644
--- a/src/modules/esl-carousel/core/nav/esl-carousel.nav.types.ts
+++ b/src/modules/esl-carousel/core/nav/esl-carousel.nav.types.ts
@@ -23,7 +23,7 @@ export type ESLCarouselSlideTarget = string | ESLCarouselNavIndex | `slide:${ESL
 export type ESLCarouselStaticState = {
   size: number;
   count: number;
-  loop?: boolean;
+  loop: boolean;
 };
 
 /** Object describing carousel current configuration (contains active slide data) */
diff --git a/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.dots.ts b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.dots.ts
index 4908e8b24..2b0310fcc 100644
--- a/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.dots.ts
+++ b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.dots.ts
@@ -52,7 +52,7 @@ export class ESLCarouselNavDots extends ESLBaseElement {
   /** Renders dots according to the carousel state. */
   public rerender(): void {
     if (!this.$carousel) return;
-    const {activeIndex, count, size} = this.$carousel;
+    const {activeIndex, count, size} = this.$carousel.state;
     this.$$attr('disabled', size < 2);
     let html = '';
     const activeDot = indexToGroup(activeIndex, count, size);
diff --git a/src/modules/esl-carousel/renderers/multi/esl-multi-carousel.ts b/src/modules/esl-carousel/renderers/multi/esl-multi-carousel.ts
index 373192a02..35578213b 100644
--- a/src/modules/esl-carousel/renderers/multi/esl-multi-carousel.ts
+++ b/src/modules/esl-carousel/renderers/multi/esl-multi-carousel.ts
@@ -10,6 +10,10 @@ import type {ESLCarouselDirection} from '../../core/nav/esl-carousel.nav.types';
 export class ESLMultiCarouselRenderer extends ESLCarouselRenderer {
   public static override is = 'multi';
 
+  /** Slides gap width */
+  protected gapWidth: number = 0;
+  /** Slide width cached value */
+  protected slideWidth: number = 0;
   /** First index of active slides. */
   protected currentIndex: number = 0;
 
@@ -108,7 +112,8 @@ export class ESLMultiCarouselRenderer extends ESLCarouselRenderer {
     this.carousel.$slides.forEach((el) => el.toggleAttribute('visible', true));
 
     const sign = offset < 0 ? 1 : -1;
-    const count = Math.floor(Math.abs(offset) / this.slideWidth);
+    const slideWidth = this.slideWidth + this.gapWidth;
+    const count = Math.floor(Math.abs(offset) / slideWidth);
     const currentIndex = normalizeIndex(this.carousel.activeIndex + count * sign, this.size);
 
     if (!this._checkNonLoop(offset)) return;
@@ -117,23 +122,23 @@ export class ESLMultiCarouselRenderer extends ESLCarouselRenderer {
     this.reindex(orderIndex);
     this.currentIndex = currentIndex;
 
-    const stageOffset = offset < 0 ? offset + count * this.slideWidth : offset - (count + 1) * this.slideWidth;
+    const stageOffset = offset < 0 ? offset + count * slideWidth : offset - (count + 1) * slideWidth;
     this.carousel.$slidesArea!.style.transform = `translateX(${stageOffset}px)`;
   }
 
   protected _checkNonLoop(offset: number): boolean {
     const sign = offset < 0 ? 1 : -1;
-    const count = Math.floor(Math.abs(offset) / this.slideWidth);
+    const count = Math.floor(Math.abs(offset) / (this.slideWidth + this.gapWidth));
     const nextIndex = this.carousel.activeIndex + count * sign;
     const currentIndex = normalizeIndex(this.carousel.activeIndex + count * sign, this.size);
 
-    if (this.carousel.loop) return true;
+    if (this.loop) return true;
     // check non-loop state
     if (nextIndex >= this.carousel.size || nextIndex < 0) return false;
     // check left border of non-loop state
     if (offset > 0 && currentIndex - 1 < 0) return false;
     // check right border of non-loop state
-    return !(offset < 0 && currentIndex + 1 + this.carousel.count > this.carousel.size);
+    return !(offset < 0 && currentIndex + 1 + this.count > this.carousel.size);
   }
 
   /** Ends current transition and make permanent all changes performed in the transition. */
@@ -161,10 +166,10 @@ export class ESLMultiCarouselRenderer extends ESLCarouselRenderer {
       Math.ceil(Math.abs(offset) / this.slideWidth) : Math.floor(Math.abs(offset) / this.slideWidth);
     const nextIndex = this.carousel.activeIndex + count * sign;
 
-    if (!this.carousel.loop && offset > 0 && nextIndex - 1 < 0) {
+    if (!this.loop && offset > 0 && nextIndex - 1 < 0) {
       this.currentIndex = 0;
-    } else if (!this.carousel.loop && offset < 0 && nextIndex + this.carousel.count >= this.carousel.size) {
-      this.currentIndex = this.carousel.size - this.carousel.count;
+    } else if (!this.loop && offset < 0 && nextIndex + this.count >= this.carousel.size) {
+      this.currentIndex = this.carousel.size - this.count;
     } else {
       this.currentIndex = normalizeIndex(nextIndex, this.size);
     }
@@ -186,10 +191,11 @@ export class ESLMultiCarouselRenderer extends ESLCarouselRenderer {
 
   /** Sets order style property for slides starting at index */
   protected resize(): void {
-    const {count, $slides, $slidesArea} = this.carousel;
+    const {$slides, $slidesArea} = this.carousel;
     if (!$slidesArea || !$slides.length) return;
     const slidesAreaStyles = getComputedStyle($slidesArea);
-    this.slideWidth =  parseFloat(slidesAreaStyles.width) / count;
+    this.gapWidth = parseFloat(slidesAreaStyles.columnGap);
+    this.slideWidth = (parseFloat(slidesAreaStyles.width) - this.gapWidth * (this.count - 1)) / this.count;
     $slides.forEach((slide) => slide.style.minWidth = this.slideWidth + 'px');
   }
 
diff --git a/src/modules/esl-utils/async.ts b/src/modules/esl-utils/async.ts
index 4fd8308ba..d23b0e03e 100644
--- a/src/modules/esl-utils/async.ts
+++ b/src/modules/esl-utils/async.ts
@@ -3,4 +3,5 @@ export * from './async/promise';
 export * from './async/raf';
 export * from './async/debounce';
 export * from './async/throttle';
+export * from './async/microtask';
 export * from './async/delayed-task';
diff --git a/src/modules/esl-utils/async/microtask.ts b/src/modules/esl-utils/async/microtask.ts
new file mode 100644
index 000000000..440529814
--- /dev/null
+++ b/src/modules/esl-utils/async/microtask.ts
@@ -0,0 +1,15 @@
+/**
+ * A decorator utility to postpone callback execution once after the main task execution
+ * (as a microtask produced with Promise)
+ */
+export function microtask<T>(fn: (...arg: [T?]) => void, thisArg?: object): (arg?: T) => void {
+  const args: T[] = [];
+  return function microtaskFn(arg: T): void {
+    args.push(arg);
+    if ((microtaskFn as any).request) return;
+    (microtaskFn as any).request = Promise.resolve().then(() => {
+      delete (microtaskFn as any).request;
+      fn.call(thisArg || this, args);
+    });
+  };
+}


Test patch:
diff --git a/src/modules/esl-carousel/test/nav/esl-carousel.nav-utils.test.ts b/src/modules/esl-carousel/test/nav/esl-carousel.nav-utils.test.ts
index 864359225..e34a5bee0 100644
--- a/src/modules/esl-carousel/test/nav/esl-carousel.nav-utils.test.ts
+++ b/src/modules/esl-carousel/test/nav/esl-carousel.nav-utils.test.ts
@@ -89,10 +89,10 @@ describe('ESLCarousel: Nav Utils', () => {
   describe('toIndex', () => {
     describe('numeric input', () => {
       test.each([
-        [0, {size: 5, count: 1, activeIndex: 2}, 0],
-        [1, {size: 5, count: 2, activeIndex: 2}, 1],
-        [5, {size: 5, count: 1, activeIndex: 0}, 4],
-        [6, {size: 5, count: 3, activeIndex: 1}, 2],
+        [0, {size: 5, count: 1, activeIndex: 2, loop: false}, 0],
+        [1, {size: 5, count: 2, activeIndex: 2, loop: false}, 1],
+        [5, {size: 5, count: 1, activeIndex: 0, loop: false}, 4],
+        [6, {size: 5, count: 3, activeIndex: 1, loop: false}, 2],
         [5, {size: 5, count: 1, activeIndex: 0, loop: true}, 0],
         [6, {size: 5, count: 3, activeIndex: 1, loop: true}, 1]
       ])(
@@ -103,10 +103,10 @@ describe('ESLCarousel: Nav Utils', () => {
 
     describe('numeric string input', () => {
       test.each([
-        [' 0', {size: 5, count: 1, activeIndex: 2}, 0],
-        [' 1 ', {size: 5, count: 2, activeIndex: 2}, 1],
-        ['5', {size: 5, count: 1, activeIndex: 0}, 4],
-        [' 6 ', {size: 5, count: 3, activeIndex: 1}, 2],
+        [' 0', {size: 5, count: 1, activeIndex: 2, loop: false}, 0],
+        [' 1 ', {size: 5, count: 2, activeIndex: 2, loop: false}, 1],
+        ['5', {size: 5, count: 1, activeIndex: 0, loop: false}, 4],
+        [' 6 ', {size: 5, count: 3, activeIndex: 1, loop: false}, 2],
         [' 0', {size: 5, count: 1, activeIndex: 2, loop: true}, 0],
         [' 1 ', {size: 5, count: 2, activeIndex: 2, loop: true}, 1],
         ['5', {size: 5, count: 1, activeIndex: 0, loop: true}, 0],
@@ -129,10 +129,10 @@ describe('ESLCarousel: Nav Utils', () => {
 
     describe('relative slide target', () => {
       test.each([
-        ['-1', {size: 5, count: 1, activeIndex: 2}, 1],
-        ['+1', {size: 5, count: 2, activeIndex: 2}, 3],
-        ['-3', {size: 5, count: 1, activeIndex: 2}, 0],
-        ['+3', {size: 5, count: 3, activeIndex: 2}, 2],
+        ['-1', {size: 5, count: 1, activeIndex: 2, loop: false}, 1],
+        ['+1', {size: 5, count: 2, activeIndex: 2, loop: false}, 3],
+        ['-3', {size: 5, count: 1, activeIndex: 2, loop: false}, 0],
+        ['+3', {size: 5, count: 3, activeIndex: 2, loop: false}, 2],
         ['-3', {size: 5, count: 1, activeIndex: 2, loop: true}, 4],
         ['+3', {size: 5, count: 3, activeIndex: 2, loop: true}, 0]
       ])(
@@ -153,12 +153,12 @@ describe('ESLCarousel: Nav Utils', () => {
 
     describe('slide target relative full', () => {
       test.each([
-        ['slide:-1', {size: 5, count: 1, activeIndex: 2}, 1],
-        ['slide:+1', {size: 5, count: 2, activeIndex: 2}, 3],
+        ['slide:-1', {size: 5, count: 1, activeIndex: 2, loop: false}, 1],
+        ['slide:+1', {size: 5, count: 2, activeIndex: 2, loop: false}, 3],
         ['slide:-3', {size: 5, count: 1, activeIndex: 2, loop: true}, 4],
         ['slide:+3', {size: 5, count: 3, activeIndex: 2, loop: true}, 0],
-        ['slide:-3', {size: 5, count: 1, activeIndex: 2}, 0],
-        ['slide:+3', {size: 5, count: 3, activeIndex: 2}, 2]
+        ['slide:-3', {size: 5, count: 1, activeIndex: 2, loop: false}, 0],
+        ['slide:+3', {size: 5, count: 3, activeIndex: 2, loop: false}, 2]
       ])(
         '(target = %s, cfg = %p) = %d',
         (target: ESLCarouselSlideTarget, cfg: ESLCarouselState, result: number) => expect(toIndex(target, cfg).index).toBe(result)
diff --git a/src/modules/esl-utils/async/test/microtask.test.ts b/src/modules/esl-utils/async/test/microtask.test.ts
new file mode 100644
index 000000000..52ba082b1
--- /dev/null
+++ b/src/modules/esl-utils/async/test/microtask.test.ts
@@ -0,0 +1,26 @@
+import {microtask} from '../microtask';
+
+describe('sync/microtask', () => {
+  test('Decorated as microtask callback call does not lead to original function immediate execution', () => {
+    const fn = jest.fn();
+    const decorated = microtask(fn);
+    for (let i = 0; i < 5; i++) decorated();
+    expect(fn).toBeCalledTimes(0);
+  });
+  test('Decorated as microtask callback called once the macrotask done', async () => {
+    const fn = jest.fn();
+    const decorated = microtask(fn);
+    for (let i = 0; i < 5; i++) decorated();
+    await Promise.resolve();
+    expect(fn).toBeCalledTimes(1);
+  });
+  test('Decorated as microtask callback receives a list of call arguments', async () => {
+    const fn = jest.fn();
+    const decorated = microtask(fn);
+    const params = [Symbol('Arg 1'), Symbol('Arg 2'), Symbol('Arg 3')];
+
+    for (const param of params) decorated(param);
+    await Promise.resolve();
+    expect(fn).toBeCalledWith(expect.arrayContaining(params));
+  });
+});