news-ticker.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.prevSelector Selector for prev element
 * @param {String} options.nextSelector Selector for next 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.currentSlideClass Selector for the counter current slide number
 */
export class NewsTicker {
  /**
   * @static
   * Shorthand for instance creation and initialisation.
   *
   * @param {HTMLElement} root DOM element for component instantiation and scope
   *
   * @return {NewsTicker} An instance of News ticker.
   */
  static autoInit(root, { NEWS_TICKER: defaultOptions = {} } = {}) {
    const newsTicker = new NewsTicker(root, defaultOptions);
    newsTicker.init();
    root.ECLNewsTicker = newsTicker;
    return newsTicker;
  }

  constructor(
    element,
    {
      playSelector = '[data-ecl-news-ticker-play]',
      pauseSelector = '[data-ecl-news-ticker-pause]',
      prevSelector = '[data-ecl-news-ticker-prev]',
      nextSelector = '[data-ecl-news-ticker-next]',
      containerClass = '.ecl-news-ticker__container',
      contentClass = '.ecl-news-ticker__content',
      slidesClass = '.ecl-news-ticker__slides',
      slideClass = '.ecl-news-ticker__slide',
      currentSlideClass = '.ecl-news-ticker__counter--current',
      controlsClass = '.ecl-news-ticker__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.prevSelector = prevSelector;
    this.nextSelector = nextSelector;
    this.containerClass = containerClass;
    this.contentClass = contentClass;
    this.slidesClass = slidesClass;
    this.slideClass = slideClass;
    this.currentSlideClass = currentSlideClass;
    this.controlsClass = controlsClass;
    this.attachClickListener = attachClickListener;
    this.attachResizeListener = attachResizeListener;

    // Private variables
    this.container = null;
    this.content = null;
    this.slides = null;
    this.btnPlay = null;
    this.btnPause = null;
    this.btnPrev = null;
    this.btnNext = null;
    this.index = 1;
    this.total = 0;
    this.allowShift = true;
    this.autoPlay = null;
    this.autoPlayInterval = null;
    this.hoverAutoPlay = null;
    this.resizeTimer = null;
    this.cloneFirstSLide = null;
    this.cloneLastSLide = null;
    this.resizeTimer = null;

    // 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.handleFocus = this.handleFocus.bind(this);
  }

  /**
   * Initialise component.
   */
  init() {
    if (!ECL) {
      throw new TypeError('Called init but ECL is not present');
    }
    ECL.components = ECL.components || new Map();

    this.btnPlay = queryOne(this.playSelector, this.element);
    this.btnPause = queryOne(this.pauseSelector, this.element);
    this.btnPrev = queryOne(this.prevSelector, this.element);
    this.btnNext = queryOne(this.nextSelector, this.element);
    this.slidesContainer = queryOne(this.slidesClass, this.element);
    this.container = queryOne(this.containerClass, this.element);
    this.content = queryOne(this.contentClass, this.element);
    this.controls = queryOne(this.controlsClass, this.element);

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

    // If only one slide, don't initialize ticker and hide controls
    if (this.total <= 1 && this.controls) {
      this.content.style.height = 'auto';
      this.controls.style.display = 'none';
      return false;
    }

    const firstSlide = this.slides[0];
    const lastSlide = this.slides[this.slides.length - 1];
    this.cloneFirstSLide = firstSlide.cloneNode(true);
    this.cloneLastSLide = lastSlide.cloneNode(true);

    // Clone first and last slide
    this.slidesContainer.appendChild(this.cloneFirstSLide);
    this.slidesContainer.insertBefore(this.cloneLastSLide, firstSlide);

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

    // Initialize ticker position and size
    this.handleResize();

    // Activate autoPlay
    this.handleAutoPlay();

    // Bind events
    if (this.attachClickListener && this.btnPlay && this.btnPause) {
      this.btnPlay.addEventListener('click', this.handleAutoPlay);
      this.btnPause.addEventListener('click', this.handleAutoPlay);
    }
    if (this.attachClickListener && this.btnNext) {
      this.btnNext.addEventListener(
        'click',
        this.shiftSlide.bind(this, 1, true),
      );
    }
    if (this.attachClickListener && this.btnPrev) {
      this.btnPrev.addEventListener(
        'click',
        this.shiftSlide.bind(this, -1, true),
      );
    }
    if (this.slidesContainer) {
      this.slidesContainer.addEventListener('transitionend', this.checkIndex);
      this.slidesContainer.addEventListener('mouseover', this.handleMouseOver);
      this.slidesContainer.addEventListener('mouseout', this.handleMouseOut);
    }
    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.btnNext) {
      this.btnNext.replaceWith(this.btnNext.cloneNode(true));
    }
    if (this.btnPrev) {
      this.btnPrev.replaceWith(this.btnPrev.cloneNode(true));
    }
    if (this.slidesContainer) {
      this.slidesContainer.removeEventListener(
        'transitionend',
        this.checkIndex,
      );
      this.slidesContainer.removeEventListener(
        'mouseover',
        this.handleMouseOver,
      );
      this.slidesContainer.removeEventListener('mouseout', this.handleMouseOut);
    }
    if (this.container) {
      this.container.removeEventListener('focus', this.handleFocus, 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);
    }
  }

  /**
   * Action to shift next or previous slide.
   * @param {int} dir
   * @param {Boolean} stopAutoPlay
   */
  shiftSlide(dir, stopAutoPlay) {
    if (this.allowShift) {
      this.index = dir === 1 ? 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.slides[this.index].offsetTop;
    const newHeight = this.slides[this.index].offsetHeight;
    this.content.style.height = `${newHeight}px`;
    this.slidesContainer.style.transitionDuration = transition ? '0.4s' : '1ms';
    this.slidesContainer.style.transform = `translate3d(0px, -${newOffset}px, 0px)`;
  }

  /**
   * Action to update slides index and position.
   */
  checkIndex() {
    // Update index
    if (this.index === 0) {
      this.index = this.total;
      this.moveSlides(false);
    }
    if (this.index === this.total + 1) {
      this.index = 1;
      this.moveSlides(false);
    }

    // Update pagination
    const currentSlide = queryOne(this.currentSlideClass, this.element);
    currentSlide.textContent = this.index;

    // Update slides
    if (this.slides) {
      this.slides.forEach((slide, index) => {
        const cta = queryOne('.ecl-link', 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);
          }
        }
      });
    }

    this.allowShift = true;
  }

  /**
   * Toggles play/pause slides.
   */
  handleAutoPlay() {
    if (!this.autoPlay) {
      this.autoPlayInterval = setInterval(() => {
        this.shiftSlide(1);
      }, 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() {
    clearTimeout(this.resizeTimer);
    this.resizeTimer = setTimeout(() => {
      this.moveSlides(false);
    }, 100);
  }

  /**
   * 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 NewsTicker;