slogan-ticker.js

import { queryOne, queryAll } from '@ecl/dom-utils';
import EmblaCarousel from 'embla-carousel';
import AutoScroll from 'embla-carousel-auto-scroll';

/**
 * @param {HTMLElement} element DOM element for component instantiation and scope
 * @param {Object} options
 * @param {String} options.sliderSelector Selector for the slider element
 * @param {String} options.playSelector Selector for the play button
 * @param {String} options.pauseSelector Selector for the pause button
 * @param {String} options.slideClass Selector for the slide items
 * @param {String} options.autoplaySelector Selector for autoplay state (data attribute with boolean value)
 * @param {Number} options.autoScrollSpeed Speed value for embla auto scroll plugin
 * @param {Boolean} options.attachClickListener Whether to attach click listeners
 * @param {Boolean} options.attachResizeListener Whether to attach resize listener
 */
export class SloganTicker {
  /**
   * @static
   * Shorthand for instance creation and initialisation.
   *
   * @param {HTMLElement} root DOM element for component instantiation and scope
   *
   * @return {SloganTicker} An instance of SloganTicker.
   */
  static autoInit(root, { SLOGAN_TICKER: defaultOptions = {} } = {}) {
    const sloganTicker = new SloganTicker(root, defaultOptions);
    sloganTicker.init();
    root.ECLSloganTicker = sloganTicker;
    return sloganTicker;
  }

  constructor(
    element,
    {
      sliderSelector = '[data-ecl-slogan-ticker-slider]',
      playSelector = '[data-ecl-slogan-ticker-play]',
      pauseSelector = '[data-ecl-slogan-ticker-pause]',
      slideClass = '.ecl-slogan-ticker__slide',
      autoplaySelector = 'data-ecl-slogan-ticker-autoplay',
      autoScrollSpeed = 0.9,
      attachClickListener = true,
      attachResizeListener = true,
    } = {},
  ) {
    if (!element || element.nodeType !== Node.ELEMENT_NODE) {
      throw new TypeError(
        'DOM element should be given to initialize this widget.',
      );
    }

    this.element = element;
    this.sliderSelector = sliderSelector;
    this.playSelector = playSelector;
    this.pauseSelector = pauseSelector;
    this.autoplaySelector = autoplaySelector;
    this.slideClass = slideClass;
    this.autoScrollSpeed = autoScrollSpeed;
    this.attachClickListener = attachClickListener;
    this.attachResizeListener = attachResizeListener;

    this.slider = null;
    this.playButton = null;
    this.pauseButton = null;
    this.slides = [];
    this.isPlaying = false;
    this.rafId = null;
    this.resizeTimer = null;
    this.direction = null;
    this.isPaused = false;

    this.toggleAutoScroll = this.toggleAutoScroll.bind(this);
    this.handleResize = this.handleResize.bind(this);
  }

  /**
   * Initialise component: query DOM nodes, initialise slider and bind events.
   * @return {SloganTicker|false} this instance or false if not initialised
   */
  init() {
    if (!ECL) {
      throw new TypeError('Called init but ECL is not present');
    }
    ECL.components = ECL.components || new Map();

    this.sliderEl = queryOne(this.sliderSelector, this.element);
    this.playButton = queryOne(this.playSelector, this.element);
    this.pauseButton = queryOne(this.pauseSelector, this.element);
    this.slides = queryAll(this.slideClass, this.element);
    this.direction = getComputedStyle(this.element).direction;

    if (!this.sliderEl || this.slides.length < 1) {
      if (this.playButton) this.playButton.style.display = 'none';
      if (this.pauseButton) this.pauseButton.style.display = 'none';
      return false;
    }

    this.waitForLayoutReady();

    if (this.attachClickListener && this.playButton) {
      this.playButton.addEventListener('click', this.toggleAutoScroll, false);
    }
    if (this.attachClickListener && this.pauseButton) {
      this.pauseButton.addEventListener('click', this.toggleAutoScroll, false);
    }
    if (this.attachResizeListener) {
      window.addEventListener('resize', this.handleResize);
    }

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

    return this;
  }

  /**
   * Stop the slider auto-scroll when the component is not visible in the viewport, and resume when it is visible again.
   */
  setupVisibilityObserver() {
    this.visibilityObserver = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          if (!this.isPlaying && !this.isPaused) {
            this.startAutoScroll();
          }
        } else if (this.isPlaying) {
          this.stopAutoScroll();
        }
      },
      {
        threshold: 0,
      },
    );

    this.visibilityObserver.observe(this.element);
  }

  /**
   * For the loop to work properly we need a decent amount of items
   */
  ensureEnoughContent() {
    const track = queryOne('.ecl-slogan-ticker__track', this.sliderEl);

    queryAll('[data-ecl-clone="true"]', track).forEach((clone) => {
      clone.remove();
    });

    const slides = Array.from(track.children);

    const containerWidth = this.sliderEl.offsetWidth;

    let totalWidth = track.scrollWidth;

    // duplicate until we exceed 2–3x viewport width
    while (totalWidth < containerWidth * 3) {
      slides.forEach((slide) => {
        const clone = slide.cloneNode(true);
        clone.setAttribute('aria-hidden', 'true');
        clone.setAttribute('data-ecl-clone', 'true');
        track.appendChild(clone);
      });

      totalWidth = track.scrollWidth;
    }
  }

  waitForLayoutReady() {
    const tick = () => {
      if (this.isLayoutReady()) {
        this.ensureEnoughContent();
        this.initSlider();
        this.setupVisibilityObserver();

        if (
          this.element.getAttribute(this.autoplaySelector) !== 'false' &&
          !this.isPlaying
        ) {
          this.startAutoScroll();
          this.isPlaying = true;
        } else if (
          this.element.getAttribute(this.autoplaySelector) === 'false'
        ) {
          this.isPaused = true;
        }

        return;
      }

      requestAnimationFrame(tick);
    };

    requestAnimationFrame(tick);
  }

  isLayoutReady() {
    const track = queryOne('.ecl-slogan-ticker__track', this.sliderEl);
    if (!track) return false;

    const trackStyles = getComputedStyle(track);

    return (
      trackStyles.display === 'flex' &&
      this.sliderEl.offsetWidth > 0 &&
      track.offsetHeight > 0
    );
  }

  /**
   * Initialise the Embla slider instance and attach the auto-scroll plugin.
   */
  initSlider() {
    this.slider = EmblaCarousel(
      this.sliderEl,
      {
        loop: true,
        align: 'start',
        dragFree: true,
        draggable: false,
        containScroll: false,
        direction: this.direction === 'rtl' ? 'rtl' : 'ltr',
      },
      [
        AutoScroll({
          speed: this.autoScrollSpeed,
          startDelay: 0,
          stopOnInteraction: false,
          stopOnMouseEnter: false,
          direction: this.direction === 'rtl' ? 'backward' : 'forward',
        }),
      ],
    );
  }

  /**
   * Start the auto-scroll behaviour and update buttons visibility.
   */
  startAutoScroll() {
    this.isPlaying = true;
    this.updateButtonVisibility();
    const autoScroll = this.slider?.plugins()?.autoScroll;
    const direction = getComputedStyle(this.element).direction;

    if (this.direction !== direction) {
      this.direction = direction;
      this.slider?.plugins()?.autoScroll?.destroy();
      this.slider.destroy();
      this.initSlider();
      this.slider?.plugins()?.autoScroll?.play();
      return;
    }

    autoScroll?.play();
  }

  /**
   * Stop the auto-scroll behaviour and update buttons visibility.
   */
  stopAutoScroll() {
    this.isPlaying = false;
    this.updateButtonVisibility();

    const autoScroll = this.slider?.plugins()?.autoScroll;
    autoScroll?.stop();
  }

  /**
   * Toggle the auto-scroll state (play / pause).
   * @param {Event} [event] Click event that triggered the toggle
   */
  toggleAutoScroll(event) {
    if (event && event.preventDefault) {
      event.preventDefault();
    }

    if (event?.currentTarget === this.pauseButton) {
      this.isPaused = true;
    }

    if (event?.currentTarget === this.playButton) {
      this.isPaused = false;
    }

    if (this.isPlaying) {
      this.stopAutoScroll();
    } else {
      this.startAutoScroll();
    }
  }

  /**
   * Update visibility of play and pause buttons according to `isPlaying`.
   */
  updateButtonVisibility() {
    if (this.playButton) {
      this.playButton.style.display = this.isPlaying ? 'none' : 'flex';
    }
    if (this.pauseButton) {
      this.pauseButton.style.display = this.isPlaying ? 'flex' : 'none';
    }
  }

  /**
   * Handle window resize: debounce re-initialisation of the slider and restart auto-scroll.
   */
  handleResize() {
    if (this.resizeTimer) {
      clearTimeout(this.resizeTimer);
    }

    this.resizeTimer = setTimeout(() => {
      if (this.slider) {
        this.slider.reInit();
        if (!this.isPaused) {
          this.slider?.plugins()?.autoScroll?.play();
        }
      }
    }, 100);
  }

  /**
   * Destroy the component: remove listeners, cancel RAF, destroy slider and cleanup DOM attributes.
   */
  destroy() {
    if (this.playButton) {
      this.playButton.replaceWith(this.playButton.cloneNode(true));
    }

    if (this.pauseButton) {
      this.pauseButton.replaceWith(this.pauseButton.cloneNode(true));
    }

    if (this.attachResizeListener) {
      window.removeEventListener('resize', this.handleResize);
    }

    if (this.rafId) {
      window.cancelAnimationFrame(this.rafId);
      this.rafId = null;
    }

    if (this.slider) {
      this.slider?.plugins()?.autoScroll?.destroy();
      this.slider.destroy();
      this.slider = null;
    }

    const track = queryOne('.ecl-slogan-ticker__track', this.sliderEl);
    if (track) {
      queryAll('[data-ecl-clone="true"]', track).forEach((clone) => {
        clone.remove();
      });
      track.style.transform = '';
      track.style.willChange = '';
    }

    if (this.sliderEl) {
      this.sliderEl.style.pointerEvents = 'none';
      // force reflow
      this.sliderEl.getBoundingClientRect();
      this.sliderEl.style.pointerEvents = '';
    }

    if (this.visibilityObserver) {
      this.visibilityObserver.disconnect();
      this.visibilityObserver = null;
    }

    if (this.element) {
      this.element.removeAttribute('data-ecl-auto-initialized');
      ECL.components.delete(this.element);
    }
  }
}

export default SloganTicker;