carousel.js

import { queryOne, queryAll } from '@ecl/dom-utils';

/**
 * @param {HTMLElement} element DOM element for component instantiation and scope
 * @param {Object} options
 * @param {String} options.toggleSelector Selector for toggling element
 * @param {String} options.contentClass Selector for the content container
 * @param {String} options.slidesClass Selector for the slides container
 * @param {String} options.slideClass Selector for the slide items
 * @param {String} options.navigationClass Selector for the navigation container
 * @param {String} options.currentSlideClass Selector for the counter current slide number
 */
export class Carousel {
  /**
   * @static
   * Shorthand for instance creation and initialisation.
   *
   * @param {HTMLElement} root DOM element for component instantiation and scope
   *
   * @return {Carousel} An instance of Carousel.
   */
  static autoInit(root, { CAROUSEL: defaultOptions = {} } = {}) {
    const carousel = new Carousel(root, defaultOptions);
    carousel.init();
    root.ECLCarousel = carousel;
    return carousel;
  }

  constructor(
    element,
    {
      playSelector = '.ecl-carousel__play',
      pauseSelector = '.ecl-carousel__pause',
      containerClass = '.ecl-carousel__container',
      slidesClass = '.ecl-carousel__slides',
      slideClass = '.ecl-carousel__slide',
      currentSlideClass = '.ecl-carousel__current',
      navigationItemsClass = '.ecl-carousel__navigation-item',
      controlsClass = '.ecl-carousel__controls',
      attachClickListener = true,
      attachResizeListener = true,
    } = {},
  ) {
    // Check element
    if (!element || element.nodeType !== Node.ELEMENT_NODE) {
      throw new TypeError(
        'DOM element should be given to initialize this widget.',
      );
    }

    this.element = element;

    // Options
    this.playSelector = playSelector;
    this.pauseSelector = pauseSelector;
    this.containerClass = containerClass;
    this.slidesClass = slidesClass;
    this.slideClass = slideClass;
    this.currentSlideClass = currentSlideClass;
    this.navigationItemsClass = navigationItemsClass;
    this.controlsClass = controlsClass;
    this.attachClickListener = attachClickListener;
    this.attachResizeListener = attachResizeListener;

    // Private variables
    this.container = null;
    this.slides = null;
    this.btnPlay = null;
    this.btnPause = null;
    this.index = 1;
    this.total = 0;
    this.allowShift = true;
    this.activeNav = null;
    this.autoPlay = null;
    this.autoPlayInterval = null;
    this.hoverAutoPlay = null;
    this.resizeTimer = null;
    this.posX1 = 0;
    this.posX2 = 0;
    this.posInitial = 0;
    this.posFinal = 0;
    this.threshold = 80;
    this.navigationItems = null;
    this.navigation = null;
    this.controls = null;
    this.direction = 'ltr';
    this.cloneFirstSLide = null;
    this.cloneLastSLide = null;
    this.executionCount = 0;
    this.maxExecutions = 5;
    this.slideWidth = 0;

    // Bind `this` for use in callbacks
    this.handleAutoPlay = this.handleAutoPlay.bind(this);
    this.handleMouseOver = this.handleMouseOver.bind(this);
    this.handleMouseOut = this.handleMouseOut.bind(this);
    this.shiftSlide = this.shiftSlide.bind(this);
    this.checkIndex = this.checkIndex.bind(this);
    this.moveSlides = this.moveSlides.bind(this);
    this.handleResize = this.handleResize.bind(this);
    this.dragStart = this.dragStart.bind(this);
    this.dragEnd = this.dragEnd.bind(this);
    this.dragAction = this.dragAction.bind(this);
    this.handleFocus = this.handleFocus.bind(this);
    this.handleKeyboardOnPlay = this.handleKeyboardOnPlay.bind(this);
    this.handleKeyboardOnBullets = this.handleKeyboardOnBullets.bind(this);
    this.checkBannerHeights = this.checkBannerHeights.bind(this);
    this.resetBannerHeights = this.resetBannerHeights.bind(this);
  }

  /**
   * Initialise component.
   */
  init() {
    if (!ECL) {
      throw new TypeError('Called init but ECL is not present');
    }
    ECL.components = ECL.components || new Map();
    // Hide the carousel initially, we will show it in handleesize()
    this.element.style.opacity = 0;
    this.btnPlay = queryOne(this.playSelector, this.element);
    this.btnPause = queryOne(this.pauseSelector, this.element);
    this.slidesContainer = queryOne(this.slidesClass, this.element);
    this.container = queryOne(this.containerClass, this.element);
    this.navigation = queryOne('.ecl-carousel__navigation', this.element);
    this.navigationItems = queryAll(this.navigationItemsClass, this.element);
    this.controls = queryOne(this.controlsClass, this.element);
    this.currentSlide = queryOne(this.currentSlideClass, this.element);
    this.direction = getComputedStyle(this.element).direction;

    this.slides = queryAll(this.slideClass, this.element);
    this.total = this.slides.length;

    // If only one slide, don't initialize carousel and hide controls
    if (this.total <= 1) {
      if (this.controls) {
        this.controls.style.display = 'none';
      }
      if (this.slidesContainer) {
        this.slidesContainer.style.display = 'block';
      }
      return false;
    }

    // Start initializing carousel
    const firstSlide = this.slides[0];
    const lastSlide = this.slides[this.slides.length - 1];

    // Clone first and last slide
    this.cloneFirstSlide = firstSlide.cloneNode(true);
    this.cloneLastSlide = lastSlide.cloneNode(true);
    this.slidesContainer.appendChild(this.cloneFirstSlide);
    this.slidesContainer.insertBefore(this.cloneLastSlide, firstSlide);

    // Initialize the js for the two cloned slides
    const cloneFirstBanner = new ECL.Banner(
      this.cloneFirstSlide.firstElementChild,
    );
    const cloneLastBanner = new ECL.Banner(
      this.cloneLastSlide.firstElementChild,
    );

    cloneFirstBanner.init();
    cloneLastBanner.init();

    // Refresh the slides variable after adding new cloned slides
    this.slides = queryAll(this.slideClass, this.element);

    // Initialze pagination and navigation
    this.handleResize();
    // Bind events
    if (this.navigationItems) {
      this.navigationItems.forEach((nav, index) => {
        nav.addEventListener(
          'click',
          this.shiftSlide.bind(this, index + 1, true),
        );
      });
    }
    if (this.navigation) {
      this.navigation.addEventListener('keydown', this.handleKeyboardOnBullets);
    }
    if (this.attachClickListener && this.btnPlay && this.btnPause) {
      this.btnPlay.addEventListener('click', this.handleAutoPlay);
      this.btnPause.addEventListener('click', this.handleAutoPlay);
    }
    if (this.btnPlay) {
      this.btnPlay.addEventListener('keydown', this.handleKeyboardOnPlay);
    }

    if (this.slidesContainer) {
      // Mouse events
      this.slidesContainer.addEventListener('mouseover', this.handleMouseOver);
      this.slidesContainer.addEventListener('mouseout', this.handleMouseOut);

      // Touch events
      this.slidesContainer.addEventListener('touchstart', this.dragStart);
      this.slidesContainer.addEventListener('touchend', this.dragEnd);
      this.slidesContainer.addEventListener('touchmove', this.dragAction);
      this.slidesContainer.addEventListener('transitionend', this.checkIndex);
    }
    if (this.container) {
      this.container.addEventListener('focus', this.handleFocus, true);
    }
    if (this.attachResizeListener) {
      window.addEventListener('resize', this.handleResize);
    }

    // Set ecl initialized attribute
    this.element.setAttribute('data-ecl-auto-initialized', 'true');
    ECL.components.set(this.element, this);

    return this;
  }

  /**
   * Destroy component.
   */
  destroy() {
    if (this.cloneFirstSLide && this.cloneLastSLide) {
      this.cloneFirstSLide.remove();
      this.cloneLastSLide.remove();
    }
    if (this.btnPlay) {
      this.btnPlay.replaceWith(this.btnPlay.cloneNode(true));
    }
    if (this.btnPause) {
      this.btnPause.replaceWith(this.btnPause.cloneNode(true));
    }
    if (this.slidesContainer) {
      this.slidesContainer.removeEventListener(
        'mouseover',
        this.handleMouseOver,
      );
      this.slidesContainer.removeEventListener('mouseout', this.handleMouseOut);
      this.slidesContainer.removeEventListener('touchstart', this.dragStart);
      this.slidesContainer.removeEventListener('touchend', this.dragEnd);
      this.slidesContainer.removeEventListener('touchmove', this.dragAction);
      this.slidesContainer.removeEventListener(
        'transitionend',
        this.checkIndex,
      );
    }
    if (this.container) {
      this.container.removeEventListener('focus', this.handleFocus, true);
    }
    if (this.navigationItems) {
      this.navigationItems.forEach((nav) => {
        nav.replaceWith(nav.cloneNode(true));
      });
    }
    if (this.attachResizeListener) {
      window.removeEventListener('resize', this.handleResize);
    }
    if (this.autoPlayInterval) {
      clearInterval(this.autoPlayInterval);
      this.autoPlay = null;
    }
    if (this.element) {
      this.element.removeAttribute('data-ecl-auto-initialized');
      ECL.components.delete(this.element);
    }
  }

  /**
   * Set the banners height above the xl breakpoint
   */
  checkBannerHeights() {
    this.executionCount += 1;
    if (this.executionCount > this.maxExecutions) {
      clearInterval(this.intervalId);
      this.executionCount = 0;
      return;
    }
    const heightValues = this.slides.map((slide) => {
      const banner = queryOne('.ecl-banner', slide);
      const bannerInstance = ECL.components.get(banner);
      const ratio = bannerInstance.defaultRatio();
      bannerInstance.setHeight(ratio);
      const height = parseInt(banner.style.height, 10);
      if (banner.style.height === 'auto') {
        return 0;
      }
      if (Number.isNaN(height) || height === 100) {
        return 1;
      }

      return height;
    });

    const elementHeights = heightValues.filter(
      (height) => height !== undefined,
    );

    const tallestElementHeight = Math.max(...elementHeights);
    // We stop checking the heights of the banner if we know that all the slides
    // have height: auto; or if a banner with an height that is not 100% or undefined is found.
    if (
      (elementHeights.length === this.slides.length &&
        tallestElementHeight === 0) ||
      tallestElementHeight > 1
    ) {
      clearInterval(this.intervalId);

      if (tallestElementHeight > 0) {
        this.executionCount = 0;
        this.slides.forEach((slide) => {
          let bannerImage = null;
          const banner = queryOne('.ecl-banner', slide);
          if (banner) {
            bannerImage = queryOne('img', banner);
            banner.style.height = `${tallestElementHeight}px`;
          }
          if (bannerImage) {
            bannerImage.style.aspectRatio = 'auto';
          }
        });
      }
    }
  }

  /**
   * Set the banners height below the xl breakpoint
   */
  resetBannerHeights() {
    this.slides.forEach((slide) => {
      const banner = queryOne('.ecl-banner', slide);
      let bannerImage = null;
      let bannerVideo = null;
      let bannerFooter = null;
      if (banner) {
        banner.style.height = '';
        bannerImage = queryOne('img', banner);
        bannerVideo = queryOne('video', banner);
        bannerFooter = queryOne('.ecl-banner__credit', banner);

        if (bannerImage) {
          bannerImage.style.aspectRatio = '';
        }
        if (bannerVideo) {
          bannerVideo.style.aspectRatio = '';
        }
        if (bannerFooter) {
          setTimeout(() => {
            banner.style.setProperty(
              '--banner-footer-height',
              `${bannerFooter.offsetHeight}px`,
            );
          }, 100);
        }
      }
    });
  }

  /**
   * TouchStart handler.
   * @param {Event} e
   */
  dragStart(e) {
    e = e || window.event;

    this.posInitial = this.slidesContainer.offsetLeft;

    if (e.type === 'touchstart') {
      this.posX1 = e.touches[0].clientX;
    }
  }

  /**
   * TouchMove handler.
   * @param {Event} e
   */
  dragAction(e) {
    e = e || window.event;

    if (e.type === 'touchmove') {
      e.preventDefault();
      this.posX2 = this.posX1 - e.touches[0].clientX;
      this.posX1 = e.touches[0].clientX;
    }

    this.slidesContainer.style.left = `${
      this.slidesContainer.offsetLeft - this.posX2
    }px`;
  }

  /**
   * TouchEnd handler.
   */
  dragEnd() {
    this.posFinal = this.slidesContainer.offsetLeft;
    if (this.posFinal - this.posInitial < -this.threshold) {
      this.shiftSlide('next', true);
    } else if (this.posFinal - this.posInitial > this.threshold) {
      this.shiftSlide('prev', true);
    } else {
      this.slidesContainer.style.left = `${this.posInitial}px`;
    }
  }

  /**
   * Action to shift next or previous slide.
   * @param {int|string} dir
   * @param {Boolean} stopAutoPlay
   */
  shiftSlide(dir, stopAutoPlay) {
    if (this.allowShift) {
      if (typeof dir === 'number') {
        this.index = dir;
      } else {
        this.index = dir === 'next' ? this.index + 1 : this.index - 1;
      }
      this.moveSlides(true);
    }
    if (stopAutoPlay && this.autoPlay) {
      this.handleAutoPlay();
    }

    this.allowShift = false;
  }

  /**
   * Transition for the slides.
   * @param {Boolean} transition
   */
  moveSlides(transition) {
    const newOffset = this.slideWidth * this.index;

    this.slidesContainer.style.transitionDuration = transition ? '0.4s' : '0s';
    if (this.direction === 'rtl') {
      this.slidesContainer.style.right = `-${newOffset}px`;
    } else {
      this.slidesContainer.style.left = `-${newOffset}px`;
    }
  }

  /**
   * Action to update slides index and position.
   * @param {Event} e
   */
  checkIndex(e) {
    if (e) {
      if (e.propertyName !== 'left') {
        return;
      }
    }
    // Update index
    if (this.index === 0) {
      this.index = this.total;
    }
    if (this.index === this.total + 1) {
      this.index = 1;
    }

    // Move slide without transition to ensure infinity loop
    this.moveSlides(false);

    // Update pagination
    if (this.currentSlide) {
      this.currentSlide.textContent = this.index;
    }

    // Update slides
    if (this.slides) {
      this.slides.forEach((slide, index) => {
        const cta = queryOne('.ecl-link--cta', slide);
        if (this.index === index) {
          slide.removeAttribute('inert', 'true');
          if (cta) {
            cta.removeAttribute('tabindex', -1);
          }
        } else {
          slide.setAttribute('inert', 'true');
          if (cta) {
            cta.setAttribute('tabindex', -1);
          }
        }
      });
    }

    // Update navigation
    if (this.navigationItems) {
      this.navigationItems.forEach((nav, index) => {
        if (this.index === index + 1) {
          nav.setAttribute('aria-current', 'true');
          nav.removeAttribute('tabindex', -1);
        } else {
          nav.removeAttribute('aria-current', 'true');
          nav.setAttribute('tabindex', -1);
        }
      });
    }

    this.allowShift = true;
  }

  /**
   * Toggles play/pause slides.
   */
  handleAutoPlay() {
    if (!this.autoPlay) {
      this.autoPlayInterval = setInterval(() => {
        this.shiftSlide('next');
      }, 5000);
      this.autoPlay = true;
      const isFocus = document.activeElement === this.btnPlay;
      this.btnPlay.style.display = 'none';
      this.btnPause.style.display = 'flex';
      if (isFocus) {
        this.btnPause.focus();
      }
    } else {
      clearInterval(this.autoPlayInterval);
      this.autoPlay = false;
      const isFocus = document.activeElement === this.btnPause;
      this.btnPlay.style.display = 'flex';
      this.btnPause.style.display = 'none';
      if (isFocus) {
        this.btnPlay.focus();
      }
    }
  }

  /**
   * Trigger events on mouseover.
   */
  handleMouseOver() {
    this.hoverAutoPlay = this.autoPlay;
    if (this.hoverAutoPlay) {
      this.handleAutoPlay();
    }
    return this;
  }

  /**
   * Trigger events on mouseout.
   */
  handleMouseOut() {
    if (this.hoverAutoPlay) {
      this.handleAutoPlay();
    }
    return this;
  }

  /**
   * Trigger events on resize.
   */
  handleResize() {
    const vw = Math.max(
      document.documentElement.clientWidth || 0,
      window.innerWidth || 0,
    );
    clearInterval(this.intervalId);
    clearTimeout(this.resizeTimer);

    // We set 250ms delay which is higher than the 200ms delay in the banner.
    this.resizeTimer = setTimeout(() => {
      if (vw >= 998) {
        this.intervalId = setInterval(this.checkBannerHeights, 100);
      } else {
        this.resetBannerHeights();
      }

      this.slideWidth = this.slides[0].scrollWidth;
      this.checkIndex();
      setTimeout(() => {
        // Reveal the carousel
        this.element.style.opacity = 1;
      }, 250);
    }, 250);

    // Add class to set a left margin to banner content and avoid arrow overlapping
    if (vw >= 1140 && vw <= 1260) {
      this.container.classList.add('ecl-carousel-container--padded');
    } else {
      this.container.classList.remove('ecl-carousel-container--padded');
    }
    // Deactivate autoPlay for mobile or activate autoPlay onLoad for desktop
    if ((vw <= 768 && this.autoPlay) || (vw > 768 && this.autoPlay === null)) {
      this.handleAutoPlay();
    }
  }

  /**
   * @param {Event} e
   */
  handleKeyboardOnPlay(e) {
    if (e.key === 'Tab' && e.shiftKey) {
      return;
    }

    switch (e.key) {
      case 'Tab':
      case 'ArrowRight':
        e.preventDefault();
        this.activeNav = queryOne(
          `${this.navigationItemsClass}[aria-current="true"]`,
        );
        if (this.activeNav) {
          this.activeNav.focus();
        }
        if (this.autoPlay) {
          this.handleAutoPlay();
        }
        break;

      default:
    }
  }

  /**
   * @param {Event} e
   */
  handleKeyboardOnBullets(e) {
    const focusedEl = document.activeElement;
    switch (e.key) {
      case 'ArrowRight':
        if (focusedEl.nextSibling) {
          e.preventDefault();
          this.shiftSlide('next', true);
          setTimeout(() => focusedEl.nextSibling.focus(), 400);
        }
        break;

      case 'ArrowLeft':
        if (focusedEl.previousSibling) {
          this.shiftSlide('prev', true);
          setTimeout(() => focusedEl.previousSibling.focus(), 400);
        } else {
          this.btnPlay.focus();
        }
        break;

      default:
      // Handle other key events here
    }
  }

  /**
   * Trigger events on focus.
   * @param {Event} e
   */
  handleFocus(e) {
    const focusElement = e.target;
    // Disable autoplay if focus is on a slide CTA
    if (
      focusElement &&
      focusElement.contains(document.activeElement) &&
      this.autoPlay
    ) {
      this.handleAutoPlay();
    }
    return this;
  }
}

export default Carousel;