Repository: exadel-inc/esl
Pull request #2522 (2024-07-18 02:05:03)

refactor(esl-carousel): complete rework of carousel container & events API

## Container Feature Rework Notes

Hey folks (@exadel-inc/esl-core-team),

We had discussed the following implementation for the container class feature:

```html
<esl-carousel 
                media="@XS|@SM|@MD|@LG|@XL"
                count="1|2|3|4|5"
                loop="true"
                esl-carousel-container="{target: '::parent(div)', incompleteCls: 'incomplete'}">
    <ul esl-carousel-slides>
        <li esl-carousel-slide esl-carousel-container-class="some-cls">....</li>
    </ul>
</esl-carousel>
```

It took a lot of effort and time, but I implemented it. Unfortunately, I was not satisfied with many of the technical results achieved with the current implementation:

1. **Simplicity and Performance (Plugin Size)**
   - Contrary to expectations, the implementation of the plugin became complex. It resulted in a full plugin with approximately 200 LOC and additional complications for the user.

2. **User Experience**
   - The solution is still not very obvious from a UX perspective.

3. **Missing Information**
   - A lot of necessary information was not available in the carousel events.

### Alternative Solution (Presented in the Current PR)

Regarding the first two points, I took the risk to make the implementation straightforward and simple with one obvious advantage - it became the responsibility of the carousel itself:

```html
<esl-carousel 
                media="@XS|@SM|@MD|@LG|@XL"
                count="1|2|3|4|5"
                loop="true"
                container="::parent(div)"
                container-empty-class="optional-empty" 
                container-incomplete-class="optional-incomplete">
    <ul esl-carousel-slides>
        <li esl-carousel-slide container-class="some-cls">....</li>
    </ul>
</esl-carousel>
```

**Pros:**
- Around 20 LOC (10 times shorter)
- Highly performant with no plugins and original hooks
- Very simple
- Original API for slides
- More logical UX:
    - No pre-requirements for the container-class of slides, with clean and predictable behavior
    - Marker classes are optional and easy to provide
    - No future problems with the plugin API. We expect to see plugins potentially dependent on media conditions. It sounds excessive and unnecessary to have that for the container classes feature.

Regarding 3-rd point - it have found -> it got fixed (I hope :) )



Patch:
diff --git a/site/src/common/close.less b/site/src/common/close.less
index 8b234922c..22e541789 100644
--- a/site/src/common/close.less
+++ b/site/src/common/close.less
@@ -16,6 +16,21 @@
   &:not(:disabled):focus {
     opacity: 0.75;
   }
+
+  &.inverse {
+    color: #fff;
+    text-shadow: 0 1px 0 #000;
+    &:hover {
+      color: #fff;
+    }
+  }
+
+  .img-container & {
+    position: absolute;
+    top: 0.25rem;
+    right: 0.5rem;
+    z-index: 10;
+  }
 }
 
 button.close {
diff --git a/site/views/draft/carousel.njk b/site/views/draft/carousel.njk
index 438a34986..0a3f412df 100644
--- a/site/views/draft/carousel.njk
+++ b/site/views/draft/carousel.njk
@@ -9,7 +9,7 @@ icon: examples/carousel.svg
 
 <section>
   <h2>Multi-slide Carousel (cards, adaptive, loop, with footnotes example): </h2>
-  <div esl-carousel-container>
+  <div class="esl-carousel-nav-container">
     <button class="esl-carousel-arrow prev hide-xs"
             esl-carousel-nav="group:prev"><span class="sr-only">Previous Slide</span></button>
 
@@ -52,7 +52,7 @@ icon: examples/carousel.svg
 
 <section>
   <h2>Multi-slide Carousel (cards, adaptive, loop): </h2>
-  <div esl-carousel-container>
+  <div class="esl-carousel-nav-container">
     <button class="esl-carousel-arrow prev hide-xs"
             esl-carousel-nav="group:prev"><span class="sr-only">Previous Slide</span></button>
 
@@ -93,7 +93,7 @@ icon: examples/carousel.svg
 <a href="#"> Something accessible</a>
 <section>
   <h2>Carousel with siblings: </h2>
-  <div esl-carousel-container>
+  <div class="esl-carousel-nav-container">
     <button class="esl-carousel-arrow inner prev hide-xs" esl-carousel-nav="group:prev">
       <span class="sr-only">Previous Slide</span>
     </button>
@@ -125,7 +125,7 @@ icon: examples/carousel.svg
 
 <section>
   <h2>Multi-slide Carousel (simplified cards, adaptive, loop): </h2>
-  <div esl-carousel-container>
+  <div class="esl-carousel-nav-container">
      <button class="esl-carousel-arrow prev hide-xs" esl-carousel-nav="group:prev">
         <span class="sr-only">Previous Slide</span>
      </button>
@@ -156,7 +156,7 @@ icon: examples/carousel.svg
 
 <section>
   <h2>Multi-slide Carousel (simplified cards, adaptive, without loop): </h2>
-  <div esl-carousel-container>
+  <div class="esl-carousel-nav-container">
     <button class="esl-carousel-arrow prev hide-xs" esl-carousel-nav="group:prev">
       <span class="sr-only">Previous Slide</span>
     </button>
@@ -186,7 +186,7 @@ icon: examples/carousel.svg
 
 <section>
   <h2>Single active slide demo: </h2>
-  <div esl-carousel-container>
+  <div class="esl-carousel-nav-container">
      <button class="esl-carousel-arrow prev hide-xs" esl-carousel-nav="group:prev">
         <span class="sr-only">Previous Slide</span>
      </button>
diff --git a/site/views/examples/carousel/default.sample.njk b/site/views/examples/carousel/default.sample.njk
index 5fdc2e220..827483b59 100644
--- a/site/views/examples/carousel/default.sample.njk
+++ b/site/views/examples/carousel/default.sample.njk
@@ -3,7 +3,7 @@ title: Carousel (Multi-slide)
 order: 1
 tags: carousel-sample
 ---
-<div class="mx-6" esl-carousel-container>
+<div class="mx-6 esl-carousel-nav-container">
   <button class="esl-carousel-arrow prev hide-xs"
           esl-carousel-nav="group:prev"><span class="sr-only">Previous Slide</span></button>
 
@@ -13,12 +13,17 @@ tags: carousel-sample
                 tabindex="0"
                 media="@XS|@SM|@MD|@LG|@XL"
                 count="1|2|3|4|5"
-                loop="true">
+                loop="true"
+                container="::parent(div)"
+                container-incomplete-class="!carousel-filled">
     <ul esl-carousel-slides>
       {% for item in range(0, 16) %}
         <li esl-carousel-slide {{ 'active' if loop.first }}>
           <div class="card">
             <div class="card-image img-container img-container-16-9">
+              <button type="button" class="close inverse" aria-label="Close" onclick="this.closest('li').remove()">
+                <span aria-hidden="true">×</span>
+              </button>
               <esl-image lazy
                          mode="cover"
                          data-alt="{{ 'Carousel slide ' + loop.index }}"
@@ -37,6 +42,5 @@ tags: carousel-sample
 
   <button class="esl-carousel-arrow next hide-xs"
           esl-carousel-nav="group:next"><span class="sr-only">Next Slide</span></button>
-
-  <esl-carousel-dots class="carousel-dots-wrapper" tabindex="0"></esl-carousel-dots>
 </div>
+<esl-carousel-dots class="carousel-dots-wrapper" target="::prev::child(esl-carousel)" tabindex="0"></esl-carousel-dots>
diff --git a/site/views/examples/carousel/multirow.sample.njk b/site/views/examples/carousel/multirow.sample.njk
index cc078d41e..74f7f4b2f 100644
--- a/site/views/examples/carousel/multirow.sample.njk
+++ b/site/views/examples/carousel/multirow.sample.njk
@@ -3,7 +3,7 @@ title: Carousel (Multi-row, multi-slide)
 order: 4
 tags: carousel-sample
 ---
-<div class="mx-6" esl-carousel-container>
+<div class="mx-6 esl-carousel-nav-container">
   <button class="esl-carousel-arrow prev hide-xs"
           esl-carousel-nav="group:prev"><span class="sr-only">Previous Slide</span></button>
 
@@ -38,6 +38,5 @@ tags: carousel-sample
 
   <button class="esl-carousel-arrow next hide-xs"
           esl-carousel-nav="group:next"><span class="sr-only">Next Slide</span></button>
-
-  <esl-carousel-dots class="carousel-dots-wrapper" tabindex="0"></esl-carousel-dots>
 </div>
+<esl-carousel-dots class="carousel-dots-wrapper" target="::prev::find(esl-carousel)" tabindex="0"></esl-carousel-dots>
diff --git a/site/views/examples/carousel/nav-carousel.sample.njk b/site/views/examples/carousel/nav-carousel.sample.njk
index f015ba446..1bbbe4648 100644
--- a/site/views/examples/carousel/nav-carousel.sample.njk
+++ b/site/views/examples/carousel/nav-carousel.sample.njk
@@ -3,7 +3,7 @@ title: Carousel (Nav Carousel)
 order: 10
 tags: carousel-sample
 ---
-<div class="flex-column main-carousel" esl-carousel-container>
+<div class="flex-column main-carousel esl-carousel-nav-container">
   <esl-carousel demo-options-target
                 id="main-carousel"
                 type="default"
@@ -23,7 +23,7 @@ tags: carousel-sample
     </ul>
   </esl-carousel>
 </div>
-<div class="flex-column marquee-nav-carousel" esl-carousel-container>
+<div class="flex-column marquee-nav-carousel esl-carousel-nav-container">
   <button class="esl-carousel-arrow inner prev" esl-carousel-nav="-4"></button>
 
   <esl-carousel id="nav-carousel"
diff --git a/site/views/examples/carousel/siblings.sample.njk b/site/views/examples/carousel/siblings.sample.njk
index b9e092926..b3d3c1c70 100644
--- a/site/views/examples/carousel/siblings.sample.njk
+++ b/site/views/examples/carousel/siblings.sample.njk
@@ -3,7 +3,7 @@ title: Carousel (Sibling)
 order: 3
 tags: carousel-sample
 ---
-<div esl-carousel-container>
+<div class="esl-carousel-nav-container">
   <button class="esl-carousel-arrow inner prev hide-xs" esl-carousel-nav="group:prev">
     <span class="sr-only">Previous Slide</span>
   </button>
@@ -29,6 +29,6 @@ tags: carousel-sample
   <button class="esl-carousel-arrow inner 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>
 </div>
+
+<esl-carousel-dots class="carousel-dots-wrapper" target="::prev::child(esl-carousel)"></esl-carousel-dots>
diff --git a/site/views/examples/carousel/single.sample.njk b/site/views/examples/carousel/single.sample.njk
index c5f7d148b..bff0556cb 100644
--- a/site/views/examples/carousel/single.sample.njk
+++ b/site/views/examples/carousel/single.sample.njk
@@ -4,7 +4,7 @@ order: 2
 tags: carousel-sample
 ---
 
-<div class="esl-carousel-no-extra-space" esl-carousel-container>
+<div class="esl-carousel-no-extra-space esl-carousel-nav-container">
   <button class="esl-carousel-arrow inner prev hide-xs" esl-carousel-nav="group:prev">
     <span class="sr-only">Previous Slide</span>
   </button>
diff --git a/site/views/examples/carousel/vertical.sample.njk b/site/views/examples/carousel/vertical.sample.njk
index fc13642bc..e396c94d4 100644
--- a/site/views/examples/carousel/vertical.sample.njk
+++ b/site/views/examples/carousel/vertical.sample.njk
@@ -4,7 +4,7 @@ order: 5
 tags: carousel-sample
 ---
 
-<div class="esl-carousel-no-extra-space" esl-carousel-container>
+<div class="esl-carousel-no-extra-space esl-carousel-nav-container">
   <button class="esl-carousel-arrow inner prev hide-xs" esl-carousel-nav="group:prev">
     <span class="sr-only">Previous 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 fb2b9b27d..d3bfa3d6a 100644
--- a/src/modules/esl-carousel/core/esl-carousel.events.ts
+++ b/src/modules/esl-carousel/core/esl-carousel.events.ts
@@ -3,10 +3,10 @@ import type {ESLCarouselDirection, ESLCarouselStaticState} from './nav/esl-carou
 
 /** {@link ESLCarouselSlideEvent} init object */
 export interface ESLCarouselSlideEventInit {
-  /** Current slide index */
-  current: number;
-  /** Related slide index (target on pre-event, current on post-event) */
-  related: number;
+  /** A list of indexes of slides that were active before the change */
+  indexesBefore: number[];
+  /** A list of indexes of slides that are active after the change */
+  indexesAfter: number[];
   /** Direction of slide animation */
   direction: ESLCarouselDirection;
   /** Auxiliary request attribute that represents object that initiates slide change */
@@ -21,8 +21,8 @@ export class ESLCarouselSlideEvent extends Event implements ESLCarouselSlideEven
   public static readonly AFTER = 'esl:slide-change';
 
   public override readonly target: ESLCarousel;
-  public readonly current: number;
-  public readonly related: number;
+  public readonly indexesBefore: number[];
+  public readonly indexesAfter: number[];
   public readonly direction: ESLCarouselDirection;
   public readonly activator?: any;
 
@@ -38,14 +38,24 @@ export class ESLCarouselSlideEvent extends Event implements ESLCarouselSlideEven
     Object.assign(this, init);
   }
 
-  /** @returns current slide element */
-  public get $currentSlide(): HTMLElement | null {
-    return this.target.slideAt(this.current);
+  /** @returns first index of before sate */
+  public get indexBefore(): number {
+    return this.indexesBefore[0];
   }
 
-  /** @returns related slide element */
-  public get $relatedSlide(): HTMLElement | null {
-    return this.target.slideAt(this.related);
+  /** @returns first index of after state */
+  public get indexAfter(): number {
+    return this.indexesAfter[0];
+  }
+
+  /** @returns list of slides that are active before the change */
+  public get $slidesBefore(): HTMLElement[] {
+    return this.indexesBefore.map((index) => this.target.slideAt(index));
+  }
+
+  /** @returns list of slides that are active after the change */
+  public get $slidesAfter(): HTMLElement[] {
+    return this.indexesAfter.map((index) => this.target.slideAt(index));
   }
 
   public static create(type: 'BEFORE' | 'AFTER', init: ESLCarouselSlideEventInit): ESLCarouselSlideEvent {
@@ -55,6 +65,8 @@ export class ESLCarouselSlideEvent extends Event implements ESLCarouselSlideEven
 
 /** {@link ESLCarouselChangeEvent} init object */
 interface ESLCarouselChangeEventInit {
+  /** Whether the event is initial (on carousel creation) */
+  initial?: boolean;
   /** Current {@link ESLCarousel} instance config */
   config: ESLCarouselStaticState;
   /** Previous {@link ESLCarousel} instance config */
@@ -69,16 +81,16 @@ interface ESLCarouselChangeEventInit {
 export class ESLCarouselChangeEvent extends Event implements ESLCarouselChangeEventInit {
   /** {@link ESLCarouselSlideEvent} event type dispatched on carousel config changes */
   public static readonly TYPE = 'esl:carousel:change';
-  public static readonly INITIAL = 'esl:carousel:init';
 
   public override readonly target: ESLCarousel;
+  public readonly initial: boolean = false;
   public readonly config: ESLCarouselStaticState;
   public readonly oldConfig?: ESLCarouselStaticState;
   public readonly added: HTMLElement[] = [];
   public readonly removed: HTMLElement[] = [];
 
   protected constructor(
-    type: typeof ESLCarouselChangeEvent.TYPE | typeof ESLCarouselChangeEvent.INITIAL,
+    type: typeof ESLCarouselChangeEvent.TYPE,
     init: ESLCarouselChangeEventInit
   ) {
     super(type, {
@@ -92,11 +104,4 @@ export class ESLCarouselChangeEvent extends Event implements ESLCarouselChangeEv
   public static create(init: ESLCarouselChangeEventInit): ESLCarouselChangeEvent {
     return new ESLCarouselChangeEvent(ESLCarouselChangeEvent.TYPE, init);
   }
-  public static createInitial(carousel: ESLCarousel): ESLCarouselChangeEvent {
-    return new ESLCarouselChangeEvent(ESLCarouselChangeEvent.INITIAL, {
-      added: carousel.$slides,
-      removed: [],
-      config: carousel.config
-    });
-  }
 }
diff --git a/src/modules/esl-carousel/core/esl-carousel.less b/src/modules/esl-carousel/core/esl-carousel.less
index 5988c5796..956a193d4 100644
--- a/src/modules/esl-carousel/core/esl-carousel.less
+++ b/src/modules/esl-carousel/core/esl-carousel.less
@@ -1,3 +1,7 @@
+:root {
+  --esl-carousel-side-space: 5px;
+}
+
 // CORE styles
 esl-carousel {
   position: relative;
@@ -60,16 +64,6 @@ esl-carousel {
   list-style: none;
 }
 
-[esl-carousel-container] {
-  --esl-carousel-control-visibility: visible;
-  --esl-carousel-side-space: 5px;
-  position: relative;
-}
-
-[esl-carousel-container][incomplete] {
-  --esl-carousel-control-visibility: hidden;
-}
-
 .esl-carousel-no-extra-space {
   --esl-carousel-side-space: 0;
 }
diff --git a/src/modules/esl-carousel/core/esl-carousel.renderer.ts b/src/modules/esl-carousel/core/esl-carousel.renderer.ts
index 8fdf170b0..16b9b4775 100644
--- a/src/modules/esl-carousel/core/esl-carousel.renderer.ts
+++ b/src/modules/esl-carousel/core/esl-carousel.renderer.ts
@@ -2,7 +2,7 @@ import {memoize} from '../../esl-utils/decorators';
 import {isEqual} from '../../esl-utils/misc/object';
 import {SyntheticEventTarget} from '../../esl-utils/dom';
 import {ESLCarouselSlideEvent} from './esl-carousel.events';
-import {calcDirection, normalize} from './nav/esl-carousel.nav.utils';
+import {calcDirection, normalize, sequence} from './nav/esl-carousel.nav.utils';
 
 import type {ESLCarousel, ESLCarouselActionParams} from './esl-carousel';
 import type {ESLCarouselConfig, ESLCarouselDirection} from './nav/esl-carousel.nav.types';
@@ -31,6 +31,11 @@ export abstract class ESLCarouselRenderer implements ESLCarouselConfig {
     this.vertical = options.vertical;
   }
 
+  /** @returns marker if the renderer is applied to the carousel */
+  public get bound(): boolean {
+    return this._bound;
+  }
+
   /** @returns renderer type name */
   public get type(): string {
     return (this.constructor as typeof ESLCarouselRenderer).is;
@@ -70,6 +75,8 @@ export abstract class ESLCarouselRenderer implements ESLCarouselConfig {
     this.onBind();
   }
   public unbind(): void {
+    if (!this._bound) return;
+
     const type = this.constructor as typeof ESLCarouselRenderer;
     const orientationCls = ['esl-carousel-vertical', 'esl-carousel-horizontal'];
     this.$carousel.classList.remove(...orientationCls, ...type.classes);
@@ -92,8 +99,8 @@ export abstract class ESLCarouselRenderer implements ESLCarouselConfig {
     if (!this.$carousel.dispatchEvent(ESLCarouselSlideEvent.create('BEFORE', {
       direction,
       activator,
-      current: activeIndex,
-      related: index
+      indexesBefore: activeIndexes,
+      indexesAfter: sequence(index, this.count, this.size)
     }))) return;
 
     this.setPreActive(index);
@@ -124,11 +131,15 @@ export abstract class ESLCarouselRenderer implements ESLCarouselConfig {
   /** Sets active slides from passed index **/
   public setActive(current: number, event?: Partial<ESLCarouselSlideEventInit>): void {
     const related = this.$carousel.activeIndex;
+    const indexesBefore = this.$carousel.activeIndexes;
     const count = Math.min(this.count, this.size);
+    const indexesAfter = [];
 
     for (let i = 0; i < this.size; i++) {
       const position = normalize(i + current, this.size);
       const $slide = this.$slides[position];
+      if (i < count) indexesAfter.push(position);
+
       $slide.toggleAttribute('active', i < count);
       $slide.toggleAttribute('pre-active', false);
       $slide.toggleAttribute('next', i === count && (this.loop || position !== 0));
@@ -137,7 +148,7 @@ export abstract class ESLCarouselRenderer implements ESLCarouselConfig {
 
     if (event && typeof event === 'object') {
       const direction = event.direction || calcDirection(related, current, this.size);
-      const details = {...event, direction, current, related};
+      const details = {...event, direction, indexesBefore, indexesAfter};
       this.$carousel.dispatchEvent(ESLCarouselSlideEvent.create('AFTER', details));
     }
   }
diff --git a/src/modules/esl-carousel/core/esl-carousel.slide.ts b/src/modules/esl-carousel/core/esl-carousel.slide.ts
index b261a4e01..b72fc37f2 100644
--- a/src/modules/esl-carousel/core/esl-carousel.slide.ts
+++ b/src/modules/esl-carousel/core/esl-carousel.slide.ts
@@ -85,7 +85,9 @@ export class ESLCarouselSlide extends ESLMixinElement {
       this.$$attr('inert', !this.active);
     }
     if (!this.$carousel) return;
-    CSSClassUtils.toggle(this.$carousel.$container || this.$carousel, this.containerClass, this.active, this.$host);
+    if (this.$carousel.$container) {
+      CSSClassUtils.toggle(this.$carousel.$container, this.containerClass, this.active, this.$host);
+    }
     if (!this.active) this.blurIfInactive();
   }
 
diff --git a/src/modules/esl-carousel/core/esl-carousel.ts b/src/modules/esl-carousel/core/esl-carousel.ts
index 3b845252a..a32a55be4 100644
--- a/src/modules/esl-carousel/core/esl-carousel.ts
+++ b/src/modules/esl-carousel/core/esl-carousel.ts
@@ -21,6 +21,8 @@ import type {
   ESLCarouselStaticState,
   ESLCarouselConfig
 } from './nav/esl-carousel.nav.types';
+import {CSSClassUtils} from '../../esl-utils/dom/class';
+import {ESLTraversingQuery} from '../../esl-traversing-query/core/esl-traversing-query';
 
 /** {@link ESLCarousel} action params interface */
 export interface ESLCarouselActionParams {
@@ -41,21 +43,32 @@ export interface ESLCarouselActionParams {
 @ExportNs('Carousel')
 export class ESLCarousel extends ESLBaseElement {
   public static override is = 'esl-carousel';
-  public static observedAttributes = ['media', 'type', 'loop', 'count', 'vertical'];
+  public static observedAttributes = ['media', 'type', 'loop', 'count', 'vertical', 'container'];
 
   /** Media query pattern used for {@link ESLMediaRuleList} of `type`, `loop` and `count` (default: `all`) */
   @attr({defaultValue: 'all'}) public media: string;
   /** Renderer type name (`multi` by default). Supports {@link ESLMediaRuleList} syntax */
   @attr({defaultValue: 'default'}) public type: string;
   /** Marker to enable loop mode for a carousel (`true` by default). Supports {@link ESLMediaRuleList} syntax */
-  @attr({defaultValue: 'false'}) public loop: string;
+  @attr({defaultValue: 'false'}) public loop: string | boolean;
   /** Count of slides to show on the screen (`1` by default). Supports {@link ESLMediaRuleList} syntax */
-  @attr({defaultValue: '1'}) public count: string;
+  @attr({defaultValue: '1'}) public count: string | number;
   /** Orientation of the carousel (`horizontal` by default). Supports {@link ESLMediaRuleList} syntax */
-  @attr({defaultValue: 'false'}) public vertical: string;
+  @attr({defaultValue: 'false'}) public vertical: string | boolean;
+
+  /** Container selector (supports traversing query). Carousel itself by default */
+  @attr({defaultValue: ''}) public container: string;
+  /** CSS class to add on the container when carousel is empty */
+  @attr({defaultValue: ''}) public containerEmptyClass: string;
+  /** CSS class to add on the container when carousel is incomplete */
+  @attr({defaultValue: ''}) public containerIncompleteClass: string;
 
   /** true if carousel is in process of animating */
   @boolAttr({readonly: true}) public animating: boolean;
+  /** true if carousel is empty */
+  @boolAttr({readonly: true}) public empty: boolean;
+  /** true if carousel is incomplete (total slides count is less or equal to visible slides count) */
+  @boolAttr({readonly: true}) public incomplete: boolean;
 
   /** Marker/mixin attribute to define slide element */
   public get slideAttrName(): string {
@@ -70,17 +83,17 @@ export class ESLCarousel extends ESLBaseElement {
   /** Loop marker {@link ESLMediaRuleList} instance */
   @memoize()
   public get loopRule(): ESLMediaRuleList<boolean> {
-    return ESLMediaRuleList.parseTuple(this.media, this.loop, parseBoolean);
+    return ESLMediaRuleList.parseTuple(this.media, this.loop as string, parseBoolean);
   }
   /** Count of visible slides {@link ESLMediaRuleList} instance */
   @memoize()
   public get countRule(): ESLMediaRuleList<number> {
-    return ESLMediaRuleList.parseTuple(this.media, this.count, parseInt);
+    return ESLMediaRuleList.parseTuple(this.media, this.count as string, parseInt);
   }
   /** Orientation of the carousel {@link ESLMediaRuleList} instance */
   @memoize()
   public get verticalRule(): ESLMediaRuleList<boolean> {
-    return ESLMediaRuleList.parseTuple(this.media, this.vertical, parseBoolean);
+    return ESLMediaRuleList.parseTuple(this.media, this.vertical as string, parseBoolean);
   }
 
   /** Returns observed media rules */
@@ -122,11 +135,14 @@ export class ESLCarousel extends ESLBaseElement {
     super.connectedCallback();
     this.update();
     this.updateA11y();
-    Promise.resolve().then(() => this.dispatchEvent(ESLCarouselChangeEvent.createInitial(this)));
   }
 
   protected override attributeChangedCallback(attrName: string, oldVal: string, newVal: string): void {
     if (!this.connected) return;
+    if (attrName === 'container') {
+      memoize.clear(this, '$container');
+      return this.updateStateMarkers();
+    }
     memoize.clear(this, `${attrName}Rule`);
     this.update();
   }
@@ -141,7 +157,8 @@ export class ESLCarousel extends ESLBaseElement {
   public update(): void {
     const config = this.configCurrent;
     const oldConfig = this.config;
-    const $oldSlides = this.$slides;
+    const initial = !this.renderer.bound;
+    const $oldSlides = initial ? [] : this.$slides;
 
     memoize.clear(this, '$slides');
     const added = this.$slides.filter((slide) => !$oldSlides.includes(slide));
@@ -151,16 +168,18 @@ export class ESLCarousel extends ESLBaseElement {
 
     this.renderer.unbind();
     memoize.clear(this, 'renderer');
-    this.renderer && this.renderer.bind();
-    this.updateContainer();
-    this.dispatchEvent(ESLCarouselChangeEvent.create({added, removed, config, oldConfig}));
+    this.renderer.bind();
+    this.updateStateMarkers();
+    this.dispatchEvent(ESLCarouselChangeEvent.create({initial, added, removed, config, oldConfig}));
   }
 
-  public updateContainer(): void {
+  protected updateStateMarkers(): void {
+    this.$$attr('empty', !this.size);
+    this.$$attr('incomplete', this.size <= this.renderer.count);
+
     if (!this.$container) return;
-    this.$container.toggleAttribute('empty', this.size === 0);
-    this.$container.toggleAttribute('single', this.size === 1);
-    this.$container.toggleAttribute('incomplete', this.size <= this.renderer.count);
+    CSSClassUtils.toggle(this.$container, this.containerEmptyClass, this.empty, this);
+    CSSClassUtils.toggle(this.$container, this.containerIncompleteClass, this.incomplete, this);
   }
 
   /** Appends slide instance to the current carousel */
@@ -181,10 +200,9 @@ export class ESLCarousel extends ESLBaseElement {
   }
 
   protected updateA11y(): void {
-    const $container = this.$container || this;
-    if (!$container.role) {
-      $container.setAttribute('role', 'region');
-      $container.setAttribute('aria-roledescription', 'Carousel');
+    if (!this.role) {
+      this.setAttribute('role', 'region');
+      this.setAttribute('aria-roledescription', 'Carousel');
     }
     if (!this.id) this.id = sequentialUID('esl-carousel-');
     if (!this.$slidesArea.id) this.$slidesArea.id = `${this.id}-slides`;
@@ -222,10 +240,12 @@ export class ESLCarousel extends ESLBaseElement {
     return els.filter((el) => el.hasAttribute(slideAttrName));
   }
 
-  /** @returns carousel container */
+  /**
+   * @returns carousel container
+   */
   @memoize()
-  public get $container(): HTMLElement | null {
-    return this.closest(`[${this.tagName}-container]`);
+  public get $container(): Element | null {
+    return ESLTraversingQuery.first(this.container, this) as HTMLElement;
   }
 
   /** @returns carousel slides area */
diff --git a/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts b/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts
index bc1863e8a..226404ef5 100644
--- a/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts
+++ b/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts
@@ -11,11 +11,20 @@ export function normalize(index: number, size: number): number {
   return (size + (index % size)) % size;
 }
 
-/** @returns normalize slide index according to the carousel mode */
+/** @returns normalize first slide index according to the carousel mode */
 export function normalizeIndex(index: number, {size, count, loop}: ESLCarouselStaticState): number {
   return loop ? normalize(index, size) : Math.max(0, Math.min(size - count, index));
 }
 
+/** @returns normalized sequence of slides starting from the current index */
+export function sequence(current: number, count: number, size: number): number[] {
+  const result = [];
+  for (let i = 0; i < count; i++) {
+    result.push(normalize(current + i, size));
+  }
+  return result;
+}
+
 /** Gets count of slides between active and passed considering given direction. */
 export function getDistance(from: number, direction: ESLCarouselDirection, {activeIndex, size}: ESLCarouselState): number {
   if (direction === 'prev') return normalize(activeIndex - from, size);
diff --git a/src/modules/esl-carousel/plugin/dots/README.md b/src/modules/esl-carousel/plugin/dots/README.md
index e97a59089..b8d31faa7 100644
--- a/src/modules/esl-carousel/plugin/dots/README.md
+++ b/src/modules/esl-carousel/plugin/dots/README.md
@@ -7,18 +7,14 @@ Slide group is a set of slides that are displayed together in the carousel (you
 
 Example:
 ```html
-    <esl-carousel-slide esl-carousel-container>
-      <esl-carousel count="1">
-        <ul esl-carousel-slides>
-          <li esl-carousel-slide>Slide 1</li>
-          <li esl-carousel-slide>Slide 2</li>
-          <li esl-carousel-slide>Slide 3</li>
-          <li esl-carousel-slide>Slide 4</li>
-        </ul>
-      </esl-carousel>
-
-      <esl-carousel-nav-dots></esl-carousel-nav-dots>
-    </div>
+<esl-carousel count="1">
+    <ul esl-carousel-slides>
+        <li esl-carousel-slide>Slide 1</li>
+        <li esl-carousel-slide>Slide 2</li>
+        <li esl-carousel-slide>Slide 3</li>
+        <li esl-carousel-slide>Slide 4</li>
+    </ul>
+</esl-carousel>
 ```
 
 Please note, the plugin does not render if the carousel not found or expected amount of dots is less than 2.
@@ -40,7 +36,7 @@ For example, you might want to perceive space that dots nav take, so you can use
 ### Attributes
 
 - `target` (`ESLCarouselNavDots.prototype.carousel`) - `ESLTraversingQuery` to specify the carousel instance to control. 
-  Finds the carousel in the closest `esl-carousel-container` by default.  
+  Finds the carousel in the closest `.esl-carousel-nav-container` by default.  
 
 - `dot-label-format` (`ESLCarouselNavDots.prototype.dotLabelFormat`) - format string for dot label. 
   The string can contain `{index}` and `{count}` placeholders. 
diff --git a/src/modules/esl-carousel/plugin/dots/esl-carousel.nav.dots.ts b/src/modules/esl-carousel/plugin/dots/esl-carousel.nav.dots.ts
index 8e07bdc7d..3ddda6f3a 100644
--- a/src/modules/esl-carousel/plugin/dots/esl-carousel.nav.dots.ts
+++ b/src/modules/esl-carousel/plugin/dots/esl-carousel.nav.dots.ts
@@ -57,7 +57,7 @@ export class ESLCarouselNavDots extends ESLBaseElement {
   /** {@link ESLTraversingQuery} string to find {@link ESLCarousel} instance */
   @attr({
     name: 'target',
-    defaultValue: '::parent([esl-carousel-container])::find(esl-carousel)'
+    defaultValue: '::parent(.esl-carousel-nav-container)::find(esl-carousel)'
   })
   public carousel: string;
 
@@ -85,7 +85,7 @@ export class ESLCarouselNavDots extends ESLBaseElement {
    */
   @memoize()
   public get count(): number {
-    if (!this.$carousel) return 0;
+    if (!this.$carousel?.renderer) return 0;
     const {count, size} = this.$carousel.state;
     const value = Math.ceil(size / count);
     return value > 1 ? value : 0;
@@ -94,7 +94,7 @@ export class ESLCarouselNavDots extends ESLBaseElement {
   /** Active dot index according to carousel config. (Note: memoization used during update stage) */
   @memoize()
   public get activeIndex(): number {
-    if (!this.$carousel) return 0;
+    if (!this.$carousel?.renderer) return 0;
     const {activeIndex, count, size} = this.$carousel.state;
     return indexToGroup(activeIndex, count, size);
   }
@@ -131,11 +131,9 @@ export class ESLCarouselNavDots extends ESLBaseElement {
   }
 
   @ready
-  public override async connectedCallback(): Promise<void> {
-    this.replaceChildren();
-    if (!this.$carousel) return;
-    await customElements.whenDefined(this.$carousel.tagName.toLowerCase());
+  public override connectedCallback(): void {
     super.connectedCallback();
+    this.replaceChildren();
     this.update();
     this.updateA11y();
   }
@@ -149,7 +147,6 @@ export class ESLCarouselNavDots extends ESLBaseElement {
     if (!this.connected) return;
     if (name === 'target') {
       memoize.clear(this, '$carousel');
-      this.$$off(this._onSlideChange);
       this.$$on(this._onSlideChange);
     }
     this.update();
diff --git a/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.arrows.less b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.arrows.less
index d03185555..97da2d656 100644
--- a/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.arrows.less
+++ b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.arrows.less
@@ -1,8 +1,11 @@
 /** Arrow navigation styles */
-[esl-carousel-container] {
+.esl-carousel-nav-container {
+  position: relative;
+  // variable to make clickable area larger
+  --esl-carousel-arrow-padding: 0px;
   --esl-carousel-arrow-size: 40px;
   --esl-carousel-arrow-offset: calc(
-    (var(--esl-carousel-arrow-size) + var(--esl-carousel-side-space)) * -1
+    (var(--esl-carousel-arrow-size) + var(--esl-carousel-arrow-padding) + var(--esl-carousel-side-space)) * -1
   );
   --esl-carousel-arrow-bg: grey;
 
@@ -11,8 +14,7 @@
     z-index: 3;
     top: 50%;
     transform: translateY(-50%);
-
-    visibility: var(--esl-carousel-control-visibility, visible);
+    padding: var(--esl-carousel-arrow-padding);
 
     display: grid;
     place-content: center;
@@ -32,6 +34,9 @@
     &[disabled] {
       opacity: 0.25;
     }
+    &[disabled='hidden'] {
+      visibility: hidden;
+    }
   }
 
   // Positioning
diff --git a/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.mixin.ts b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.mixin.ts
index 53972e56c..487ff3991 100644
--- a/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.mixin.ts
+++ b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.mixin.ts
@@ -13,7 +13,7 @@ import type {ESLCarouselSlideTarget} from '../../core/nav/esl-carousel.nav.types
  *
  * Example:
  * ```
- * <div esl-carousel-container>
+ * <div class="esl-carousel-nav-container">
  *  <button esl-carousel-nav="group:prev">Prev</button>
  *  <esl-carousel>...</esl-carousel>
  *  <button esl-carousel-nav="group:next">Next</button>
@@ -25,12 +25,12 @@ export class ESLCarouselNavMixin extends ESLMixinElement {
   static override is = 'esl-carousel-nav';
 
   /** {@link ESLCarouselSlideTarget} target to navigate in carousel */
-  @attr({name: ESLCarouselNavMixin.is}) public target: ESLCarouselSlideTarget;
+  @attr({name: ESLCarouselNavMixin.is}) public command: ESLCarouselSlideTarget;
 
   /** {@link ESLTraversingQuery} string to find {@link ESLCarousel} instance */
   @attr({
     name: ESLCarouselNavMixin.is + '-target',
-    defaultValue: '::parent([esl-carousel-container])::find(esl-carousel)'
+    defaultValue: '::parent(.esl-carousel-nav-container)::find(esl-carousel)'
   })
   public carousel: string;
 
@@ -42,17 +42,15 @@ export class ESLCarouselNavMixin extends ESLMixinElement {
 
   /** @returns accessible target ID */
   public get targetID(): string {
-    return this.$carousel ? this.$carousel.$slidesArea.id : '';
+    return this.$carousel?.$slidesArea?.id || '';
   }
 
   @ready
-  public override async connectedCallback(): Promise<void> {
+  public override connectedCallback(): void {
+    super.connectedCallback();
     this.$$attr('disabled', true);
     if (!this.$carousel) return;
-    await customElements.whenDefined(this.$carousel.tagName.toLowerCase());
-    super.connectedCallback();
-    this._onSlideChange();
-    this.$$attr('aria-controls', this.targetID);
+    if (this.$carousel.renderer) this._onUpdate();
   }
 
   public override disconnectedCallback(): void {
@@ -62,19 +60,21 @@ export class ESLCarouselNavMixin extends ESLMixinElement {
 
   /** Handles carousel state changes */
   @listen({
-    event: `${ESLCarouselSlideEvent.AFTER} ${ESLCarouselChangeEvent.TYPE}`,
+    event: `${ESLCarouselChangeEvent.TYPE} ${ESLCarouselSlideEvent.AFTER}`,
     target: ($nav: ESLCarouselNavMixin) => $nav.$carousel
   })
-  protected _onSlideChange(): void {
-    const canNavigate = this.$carousel && this.$carousel.canNavigate(this.target);
-    this.$$attr('disabled', !canNavigate);
+  protected _onUpdate(): void {
+    const isIncomplete = !this.$carousel?.renderer || this.$carousel.incomplete;
+    const isDisabled = isIncomplete || !this.$carousel.canNavigate(this.command);
+    this.$$attr('disabled', isIncomplete ? 'hidden' : (isDisabled ? 'inactive' : false));
+    this.$$attr('aria-controls', this.targetID);
   }
 
   /** Handles $host element click */
   @listen('click')
   protected _onClick(e: PointerEvent): void {
     if (!this.$carousel || typeof this.$carousel.goTo !== 'function') return;
-    this.$carousel.goTo(this.target);
+    this.$carousel.goTo(this.command).catch(console.error);
     e.preventDefault();
   }
 }


Test patch:
diff --git a/src/modules/esl-carousel/test/common/esl-carousel.dummy.ts b/src/modules/esl-carousel/test/common/esl-carousel.dummy.ts
new file mode 100644
index 000000000..c9909246b
--- /dev/null
+++ b/src/modules/esl-carousel/test/common/esl-carousel.dummy.ts
@@ -0,0 +1,23 @@
+import {ESLCarousel} from '../../core/esl-carousel';
+import {ESLCarouselDummyRenderer} from './esl-carousel.dummy.renderer';
+
+export function createDummyCarousel(size: number): {$carousel: ESLCarousel, $slides: HTMLElement[]} {
+  ESLCarousel.register();
+  ESLCarouselDummyRenderer.register();
+
+  const $carousel = ESLCarousel.create();
+  const $slides = Array.from({length: size}, () => {
+    const $el = document.createElement('div');
+    $el.setAttribute('esl-carousel-slide', '');
+    return $el;
+  });
+
+  beforeAll(async () => {
+    document.body.appendChild($carousel);
+    await ESLCarousel.registered;
+    $slides.forEach(($slide) => $carousel.addSlide($slide));
+  });
+  afterAll(() => document.body.removeChild($carousel));
+
+  return {$carousel, $slides};
+}
diff --git a/src/modules/esl-carousel/test/core/esl-carousel.renderer.test.ts b/src/modules/esl-carousel/test/core/esl-carousel.renderer.test.ts
index 4b509b3ec..655090158 100644
--- a/src/modules/esl-carousel/test/core/esl-carousel.renderer.test.ts
+++ b/src/modules/esl-carousel/test/core/esl-carousel.renderer.test.ts
@@ -1,28 +1,15 @@
 import {ESLCarousel} from '../../core/esl-carousel';
+import {createDummyCarousel} from '../common/esl-carousel.dummy';
 import {ESLCarouselDummyRenderer} from '../common/esl-carousel.dummy.renderer';
 
+
 jest.mock('../../../esl-utils/dom/ready', () => ({
   onDocumentReady: (cb: any) => cb()
 }));
 
 describe('ESLCarouselRenderer: base class tests', () => {
-  ESLCarousel.register();
-  ESLCarouselDummyRenderer.register();
-
   describe('Slide markers defined correctly (`setActive` method of the base class)', () => {
-    const $carousel = ESLCarousel.create();
-    const $slides = Array.from({length: 5}, () => {
-      const $el = document.createElement('div');
-      $el.setAttribute('esl-carousel-slide', '');
-      return $el;
-    });
-
-    beforeAll(async () => {
-      document.body.appendChild($carousel);
-      await ESLCarousel.registered;
-      $slides.forEach(($slide) => $carousel.addSlide($slide));
-    });
-    afterAll(() => document.body.removeChild($carousel));
+    const {$carousel} = createDummyCarousel(5);
 
     test('Carousel size defined correctly', () => {
       expect($carousel.size).toBe(5);
diff --git a/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts b/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts
new file mode 100644
index 000000000..1d00fc8f1
--- /dev/null
+++ b/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts
@@ -0,0 +1,95 @@
+import {createDummyCarousel} from '../common/esl-carousel.dummy';
+import {ESLCarouselSlideEvent} from '../../core/esl-carousel.events';
+
+jest.mock('../../../esl-utils/dom/ready', () => ({
+  onDocumentReady: (cb: any) => cb()
+}));
+
+describe('ESLCarouselRenderer: Slide change events created correctly', () => {
+  const beforeEventTrap = jest.fn();
+  const afterEventTrap = jest.fn();
+  document.addEventListener(ESLCarouselSlideEvent.BEFORE, beforeEventTrap);
+  document.addEventListener(ESLCarouselSlideEvent.AFTER, afterEventTrap);
+
+  const {$carousel} = createDummyCarousel(5);
+
+  describe('3 slides visible, no loop', () => {
+    beforeEach(() => {
+      beforeEventTrap.mockReset();
+      afterEventTrap.mockReset();
+    });
+
+    beforeAll(async () => {
+      $carousel.count = '3';
+      $carousel.loop = 'false';
+      await Promise.resolve();
+    });
+
+    test('ESLCarouselSlideEvent: Initial slide triggered correct events', async () => {
+      const request = $carousel.renderer.navigate(0, 'next', {activator: 'user'});
+      expect(beforeEventTrap).toHaveBeenCalledTimes(1);
+      expect(afterEventTrap).toHaveBeenCalledTimes(0);
+      expect(beforeEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({
+        type: ESLCarouselSlideEvent.BEFORE,
+        indexesAfter: [0, 1, 2],
+        direction: 'next',
+        activator: 'user'
+      }));
+
+      await request;
+      expect(afterEventTrap).toHaveBeenCalledTimes(1);
+      expect(afterEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({
+        type: ESLCarouselSlideEvent.AFTER,
+        indexesAfter: [0, 1, 2],
+        direction: 'next',
+        activator: 'user'
+      }));
+    });
+
+    test('ESLCarouselSlideEvent: correct events triggered in the middle state', async () => {
+      const request = $carousel.renderer.navigate(1, 'next', {activator: 'user'});
+      expect(beforeEventTrap).toHaveBeenCalledTimes(1);
+      expect(afterEventTrap).toHaveBeenCalledTimes(0);
+      expect(beforeEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({
+        type: ESLCarouselSlideEvent.BEFORE,
+        indexesBefore: [0, 1, 2],
+        indexesAfter: [1, 2, 3],
+        direction: 'next',
+        activator: 'user'
+      }));
+
+      await request;
+      expect(afterEventTrap).toHaveBeenCalledTimes(1);
+      expect(afterEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({
+        type: ESLCarouselSlideEvent.AFTER,
+        indexesBefore: [0, 1, 2],
+        indexesAfter: [1, 2, 3],
+        direction: 'next',
+        activator: 'user'
+      }));
+    });
+
+    test('ESLCarouselSlideEvent: Last slide triggered correct events', async () => {
+      const request = $carousel.renderer.navigate(2, 'next', {activator: 'user'});
+      expect(beforeEventTrap).toHaveBeenCalledTimes(1);
+      expect(afterEventTrap).toHaveBeenCalledTimes(0);
+      expect(beforeEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({
+        type: ESLCarouselSlideEvent.BEFORE,
+        indexesBefore: [1, 2, 3],
+        indexesAfter: [2, 3, 4],
+        direction: 'next',
+        activator: 'user'
+      }));
+
+      await request;
+      expect(afterEventTrap).toHaveBeenCalledTimes(1);
+      expect(afterEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({
+        type: ESLCarouselSlideEvent.AFTER,
+        indexesBefore: [1, 2, 3],
+        indexesAfter: [2, 3, 4],
+        direction: 'next',
+        activator: 'user'
+      }));
+    });
+  });
+});