Repository: exadel-inc/esl
Pull request #2552 (2024-07-30 16:51:23)

fix(esl-carousel): multiple carousel touch behavior fixes


Patch:
diff --git a/site/views/examples/carousel/centered-siblings.sample.njk b/site/views/examples/carousel/centered-siblings.sample.njk
index 7d82fbaf9..1d6bb5a0a 100644
--- a/site/views/examples/carousel/centered-siblings.sample.njk
+++ b/site/views/examples/carousel/centered-siblings.sample.njk
@@ -11,7 +11,7 @@ tags: carousel-sample
   </button>
 
   <esl-carousel demo-options-target
-                style="padding-inline: 60px; margin-inline: 0;"
+                style="padding-inline: 60px; margin-inline: 0"
                 esl-carousel-touch
                 type="centered"
                 media="all|@+MD"
@@ -20,10 +20,13 @@ tags: carousel-sample
     <ul esl-carousel-slides>
       {% for i in range(0, 6) -%}
         <li esl-carousel-slide style="max-width: 400px">
-          <div class="card">
+          <div class="card img-container">
             <esl-image mode="cover" lazy
                        data-alt="Alt Text Test"
                        data-src="{{ '/assets/carousel/' + loop.index + '-sm.jpg' | url }}"></esl-image>
+            <button type="button" class="close close-icon close-remove inverse"
+                    title="Remove" aria-label="Remove"
+                    onclick="this.closest('li').remove()"></button>
           </div>
         </li>
       {%- endfor %}
diff --git a/site/views/examples/carousel/siblings.sample.njk b/site/views/examples/carousel/siblings.sample.njk
index de984de9d..0c130c781 100644
--- a/site/views/examples/carousel/siblings.sample.njk
+++ b/site/views/examples/carousel/siblings.sample.njk
@@ -9,7 +9,7 @@ tags: carousel-sample
   </button>
 
   <esl-carousel demo-options-target
-                style="padding-inline: 60px; margin-inline: 0;"
+                style="padding-inline: 60px; margin-inline: 0"
                 esl-carousel-touch
                 count="2"
                 loop="true">
@@ -20,6 +20,9 @@ tags: carousel-sample
             <esl-image mode="cover" lazy
                        data-alt="Alt Text Test"
                        data-src="{{ '/assets/carousel/' + loop.index + '-sm.jpg' | url }}"></esl-image>
+            <button type="button" class="close close-icon close-remove inverse"
+                    title="Remove" aria-label="Remove"
+                    onclick="this.closest('li').remove()"></button>
           </div>
         </li>
       {%- endfor %}
diff --git a/src/modules/esl-carousel/core/esl-carousel.less b/src/modules/esl-carousel/core/esl-carousel.less
index 956a193d4..e5465f4ac 100644
--- a/src/modules/esl-carousel/core/esl-carousel.less
+++ b/src/modules/esl-carousel/core/esl-carousel.less
@@ -17,12 +17,16 @@ esl-carousel {
   overflow: clip;
   /* stylelint-enable */
 
-  &.esl-carousel-vertical [esl-carousel-slides] {
-    flex-direction: column;
+  &.esl-carousel-vertical {
+    [esl-carousel-slides] {
+      flex-direction: column;
+    }
     touch-action: pan-x;
   }
-  &.esl-carousel-horizontal [esl-carousel-slides] {
-    flex-direction: row;
+  &.esl-carousel-horizontal {
+    [esl-carousel-slides] {
+      flex-direction: row;
+    }
     touch-action: pan-y;
   }
 
@@ -37,7 +41,6 @@ esl-carousel {
 
   &[dragging] [esl-carousel-slides],
   &[animating] [esl-carousel-slides] {
-    touch-action: none;
     user-select: none;
     pointer-events: none;
   }
diff --git a/src/modules/esl-carousel/core/esl-carousel.renderer.ts b/src/modules/esl-carousel/core/esl-carousel.renderer.ts
index 16b9b4775..202586663 100644
--- a/src/modules/esl-carousel/core/esl-carousel.renderer.ts
+++ b/src/modules/esl-carousel/core/esl-carousel.renderer.ts
@@ -123,10 +123,18 @@ export abstract class ESLCarouselRenderer implements ESLCarouselConfig {
   /** Post-processing animation action. */
   public async onAfterAnimate(index: number, direction: ESLCarouselDirection): Promise<void> {}
 
-  /** Handles the slides transition. */
-  public abstract onMove(offset: number): void;
-  /** Ends current transition and makes permanent all changes performed in the transition. */
-  public abstract commit(offset?: number): void;
+  /**
+   * Moves slide by the passed offset in px.
+   * @param offset - offset in px
+   * @param from - start index (default: current active index)
+   */
+  public abstract move(offset: number, from?: number): void;
+  /**
+   * Normalizes move offset to the "nearest stable" slide position.
+   * @param offset - offset in px
+   * @param from - start index (default: current active index)
+   */
+  public abstract commit(offset?: number, from?: number): void;
 
   /** Sets active slides from passed index **/
   public setActive(current: number, event?: Partial<ESLCarouselSlideEventInit>): void {
diff --git a/src/modules/esl-carousel/plugin/touch/esl-carousel.touch.mixin.ts b/src/modules/esl-carousel/plugin/touch/esl-carousel.touch.mixin.ts
index 7fb41359b..f8d399c48 100644
--- a/src/modules/esl-carousel/plugin/touch/esl-carousel.touch.mixin.ts
+++ b/src/modules/esl-carousel/plugin/touch/esl-carousel.touch.mixin.ts
@@ -45,6 +45,8 @@ export class ESLCarouselTouchMixin extends ESLCarouselPlugin<ESLCarouselTouchCon
     swipeTimeout: 400
   };
 
+  /** Start index of the drag action */
+  protected startIndex: number;
   /** Start pointer event to detect action */
   protected startEvent?: PointerEvent;
   /** Initial scroll offsets, filled on touch action start */
@@ -110,6 +112,7 @@ export class ESLCarouselTouchMixin extends ESLCarouselPlugin<ESLCarouselTouchCon
     if (this.isDisabled) return;
 
     this.startEvent = event;
+    this.startIndex = this.$host.activeIndex;
     this.startScrollOffsets = getParentScrollOffsets(event.target as Element, this.$host);
 
     this.$$on({group: 'pointer'});
@@ -130,7 +133,7 @@ export class ESLCarouselTouchMixin extends ESLCarouselPlugin<ESLCarouselTouchCon
 
     this.$host.setPointerCapture(event.pointerId);
 
-    if (this.isDragMode) this.$host.renderer.onMove(offset);
+    if (this.isDragMode) this.$host.renderer.move(offset, this.startIndex);
   }
 
   /** Processes `mouseup` and `touchend` events. */
@@ -147,7 +150,7 @@ export class ESLCarouselTouchMixin extends ESLCarouselPlugin<ESLCarouselTouchCon
 
     const offset = this.getOffset(event);
     // Commit drag offset (should be commited to 0 if the event is canceled)
-    if (this.isDragMode) this.$host.renderer.commit(offset);
+    if (this.isDragMode) this.$host.renderer.commit(offset, this.startIndex);
     // Swipe final check
     if (this.isSwipeMode && offset && !this.isPrevented && this.isSwipeAccepted(event)) {
       const target = `${this.config.swipeType}:${offset < 0 ? 'next' : 'prev'}`;
diff --git a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts
index 75a4ec72a..b65683457 100644
--- a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts
+++ b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts
@@ -23,11 +23,21 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
 
   /** Slides gap size */
   protected gap: number = 0;
-  /** Slide size cached value */
-  protected slideSize: number = 0;
   /** First index of active slides. */
   protected currentIndex: number = 0;
 
+  /** Multiplier for the index move on the slide move */
+  protected get INDEX_MOVE_MULTIPLIER(): number {
+    return 1;
+  }
+
+  /** Actual slide size (uses average) */
+  protected get slideSize(): number {
+    return this.$slides.reduce((size, $slide) => {
+      return size + (this.vertical ? $slide.offsetHeight : $slide.offsetWidth);
+    }, 0) / this.$slides.length;
+  }
+
   /**
    * Processes binding of defined renderer to the carousel {@link ESLCarousel}.
    * Prepare to renderer animation.
@@ -121,17 +131,13 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
     this.$carousel.$$attr('animating', false);
   }
 
-  protected indexByOffset(count: number): number {
-    return this.$carousel.activeIndex + count;
-  }
-
   /** Handles the slides transition. */
-  public onMove(offset: number): void {
+  public move(offset: number, from = this.$carousel.activeIndex): void {
     this.$carousel.toggleAttribute('active', true);
 
     const slideSize = this.slideSize + this.gap;
     const count = Math.floor(Math.abs(offset) / slideSize);
-    const index = this.indexByOffset(count * (offset < 0 ? 1 : -1));
+    const index = from + count * this.INDEX_MOVE_MULTIPLIER * (offset < 0 ? 1 : -1);
 
     // check left border of non-loop state
     if (!this.loop && offset > 0 && index <= 0) return;
@@ -143,17 +149,22 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
 
     const stageOffset = this.getOffset(this.currentIndex) - (offset % slideSize);
     this.setTransformOffset(-stageOffset);
+
+    if (this.currentIndex !== this.$carousel.activeIndex) {
+      console.log('Apply active index %d (before %d)', this.currentIndex, this.$carousel.activeIndex);
+      this.setActive(this.currentIndex, {direction: offset < 0 ? 'next' : 'prev'});
+    }
   }
 
   /** Ends current transition and make permanent all changes performed in the transition. */
   // eslint-disable-next-line sonarjs/cognitive-complexity
-  public async commit(offset: number): Promise<void> {
+  public async commit(offset: number, from = this.$carousel.activeIndex): Promise<void> {
     const slideSize = this.slideSize + this.gap;
 
     const amount = Math.abs(offset) / slideSize;
     const tolerance = ESLDefaultCarouselRenderer.NEXT_SLIDE_TOLERANCE;
     const count = (amount - Math.floor(amount)) > tolerance ? Math.ceil(amount) : Math.floor(amount);
-    const index = this.indexByOffset(count * (offset < 0 ? 1 : -1));
+    const index = from + count * this.INDEX_MOVE_MULTIPLIER * (offset < 0 ? 1 : -1);
 
     this.currentIndex = normalizeIndex(index, this);
 
@@ -168,8 +179,11 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
 
     this.reorder();
     this.setTransformOffset(-this.getOffset(this.currentIndex));
-    this.setActive(this.currentIndex, {direction: offset < 0 ? 'next' : 'prev'});
     this.$carousel.$$attr('active', false);
+
+    if (this.currentIndex !== this.$carousel.activeIndex) {
+      this.setActive(this.currentIndex, {direction: offset < 0 ? 'next' : 'prev'});
+    }
   }
 
   /**
@@ -210,7 +224,7 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer {
 
     this.gap = parseFloat(this.vertical ? areaStyles.rowGap : areaStyles.columnGap);
     const areaSize = parseFloat(this.vertical ? areaStyles.height : areaStyles.width);
-    this.slideSize = Math.floor((areaSize - this.gap * (this.count - 1)) / this.count);
-    this.$area.style.setProperty(ESLDefaultCarouselRenderer.SIZE_PROP, this.slideSize + 'px');
+    const slideSize = Math.floor((areaSize - this.gap * (this.count - 1)) / this.count);
+    this.$area.style.setProperty(ESLDefaultCarouselRenderer.SIZE_PROP, slideSize + 'px');
   }
 }
diff --git a/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.ts
index 6910abccb..59dc02a7d 100644
--- a/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.ts
+++ b/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.ts
@@ -22,7 +22,12 @@ export class ESLGridCarouselRenderer extends ESLDefaultCarouselRenderer {
   @prop(2, {readonly: true})
   public readonly ROWS: number;
 
-  /** @returns count of fake slides to fill the last "row" or incomplete carousel state */
+  /** Multiplier for the index move on the slide move */
+  protected override get INDEX_MOVE_MULTIPLIER(): number {
+    return this.ROWS;
+  }
+
+  /** Count of fake slides to fill the last "row" or incomplete carousel state */
   public get fakeSlidesCount(): number {
     if (this.$carousel.$slides.length < this.count) {
       return this.count - this.$carousel.$slides.length;
@@ -41,7 +46,7 @@ export class ESLGridCarouselRenderer extends ESLDefaultCarouselRenderer {
     return Array.from({length}, this.buildFakeSlide.bind(this));
   }
 
-  /** @returns all slides including {@link ESLGridCarouselRenderer.$fakeSlides} slides created in grid mode */
+  /** All slides including {@link ESLGridCarouselRenderer.$fakeSlides} slides created in grid mode */
   public override get $slides(): HTMLElement[] {
     return (this.$carousel.$slides || []).concat(this.$fakeSlides);
   }
@@ -89,10 +94,6 @@ export class ESLGridCarouselRenderer extends ESLDefaultCarouselRenderer {
     while (this.currentIndex !== nextIndex) await this.onStepAnimate(step);
   }
 
-  protected override indexByOffset(offset: number): number {
-    return super.indexByOffset(offset * this.ROWS);
-  }
-
   /**
    * @returns count of slides to be rendered (reserved) before the first slide does not include fake slides
    */
@@ -109,7 +110,7 @@ export class ESLGridCarouselRenderer extends ESLDefaultCarouselRenderer {
     this.gap = parseFloat(this.vertical ? areaStyles.rowGap : areaStyles.columnGap);
     const areaSize = parseFloat(this.vertical ? areaStyles.height : areaStyles.width);
     const count = Math.floor(this.count / this.ROWS);
-    this.slideSize = Math.floor((areaSize - this.gap * (count - 1)) / count);
-    this.$area.style.setProperty(ESLDefaultCarouselRenderer.SIZE_PROP, this.slideSize + 'px');
+    const slideSize = Math.floor((areaSize - this.gap * (count - 1)) / count);
+    this.$area.style.setProperty(ESLDefaultCarouselRenderer.SIZE_PROP, slideSize + 'px');
   }
 }
diff --git a/src/modules/esl-carousel/renderers/esl-carousel.none.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.none.renderer.ts
index 7bd44861e..1042d6dfe 100644
--- a/src/modules/esl-carousel/renderers/esl-carousel.none.renderer.ts
+++ b/src/modules/esl-carousel/renderers/esl-carousel.none.renderer.ts
@@ -29,20 +29,12 @@ export class ESLNoneCarouselRenderer extends ESLCarouselRenderer {
     this.setActive(0);
   }
 
-  public override onUnbind(): void {
-    // this.$carousel.scrollTop = this.$carousel.scrollLeft = 0;
-  }
+  public override onUnbind(): void {}
 
   /** Processes animation. */
-  public async onAnimate(nextIndex: number, direction: ESLCarouselDirection): Promise<void> {
-  }
+  public async onAnimate(nextIndex: number, direction: ESLCarouselDirection): Promise<void> {}
 
-  /** Handles the slides transition. */
-  public onMove(offset: number): void {
-    // TODO: implement if scroll behaviour requested
-    // const property = this.vertical ? 'scrollTop' : 'scrollLeft';
-    // this.$carousel[property] = -offset;
-  }
-  public commit(offset?: number): void {
-  }
+  /* Handles the slide move actions */
+  public move(offset: number, from?: number): void {}
+  public commit(offset: number, from?: number): void {}
 }


Test patch:
diff --git a/src/modules/esl-carousel/test/common/esl-carousel.dummy.renderer.ts b/src/modules/esl-carousel/test/common/esl-carousel.dummy.renderer.ts
index 1c0cbd6f7..9c9091105 100644
--- a/src/modules/esl-carousel/test/common/esl-carousel.dummy.renderer.ts
+++ b/src/modules/esl-carousel/test/common/esl-carousel.dummy.renderer.ts
@@ -7,7 +7,6 @@ export class ESLCarouselDummyRenderer extends ESLCarouselRenderer {
   public override onBeforeAnimate = jest.fn();
   public override onAfterAnimate = jest.fn();
 
-  public override onMove = jest.fn();
-
+  public override move = jest.fn();
   public override commit = jest.fn();
 }