story-card.js

import { queryOne, queryAll } from '@ecl/dom-utils';
import EventManager from '@ecl/event-manager';
import EmblaCarousel from 'embla-carousel';
import Accessibility from 'embla-carousel-accessibility';
import SliderPager from '@ecl/slider';

/**
 * @param {HTMLElement} element DOM element for component instantiation and scope
 * @param {Object} options
 * @param {String} options.viewportSelector Selector for carousel viewport
 * @param {String} options.containerSelector Selector for carousel container
 * @param {String} options.slideSelector Selector for carousel slides
 * @param {String} options.prevSelector Selector for prev button
 * @param {String} options.nextSelector Selector for next button
 * @param {String} options.playSelector Selector for play button
 * @param {String} options.pauseSelector Selector for pause button
 * @param {String} options.currentSelector Selector for current slide number
 * @param {String} options.totalSelector Selector for total slides
 * @param {String} options.gridItemSelector Selector for grid items
 * @param {String} options.gridButtonSelector Selector for grid buttons
 * @param {String} options.gridDetailsSelector Selector for grid details
 * @param {String} options.gridPrevSelector Selector for grid previous button
 * @param {String} options.gridNextSelector Selector for grid next button
 * @param {String} options.gridPlaySelector Selector for grid play button
 * @param {String} options.gridPauseSelector Selector for grid pause button
 * @param {String} options.desktopBreakpointCssVar CSS variable for the desktop breakpoint
 * @param {String} options.minHeightDesktopCssVar CSS variable for the minimum desktop height
 * @param {Number} options.gridAutoplayDelay Autoplay delay for grid items
 * @param {String} options.pagerClass Selector for the pager container
 * @param {String} options.dotsClass Selector for the dots container
 * @param {String} options.dotClass Selector for a dot button
 * @param {String} options.activeDotClass Class applied to the active dot
 * @param {Boolean} options.attachClickListener Whether to attach click listeners
 * @param {Boolean} options.attachResizeListener Whether to attach resize listeners
 */
export class StoryCard {
  /**
   * @static
   * Shorthand for instance creation and initialisation.
   *
   * @param {HTMLElement} root DOM element for component instantiation and scope
   *
   * @return {StoryCard} An instance of StoryCard.
   */
  static autoInit(root, { STORYCARD: defaultOptions = {} } = {}) {
    const storyCard = new StoryCard(root, defaultOptions);
    storyCard.init();
    root.ECLStoryCard = storyCard;
    return storyCard;
  }

  /**
   * An array of supported events for this component.
   *
   * @type {Array<string>}
   * @memberof StoryCard
   *
   */
  supportedEvents = ['onSelection'];

  /**
   * @event StoryCard#onSelection
   * @type {Object}
   * @property {number} index
   * @property {HTMLElement} button
   * @property {?HTMLElement} details
   */

  constructor(
    element,
    {
      viewportSelector = '[data-ecl-story-card-viewport]',
      containerSelector = '[data-ecl-story-card-container]',
      slideSelector = '[data-ecl-story-card-slide]',
      prevSelector = '[data-ecl-story-card-prev]',
      nextSelector = '[data-ecl-story-card-next]',
      pagerClass = '.ecl-story-card__pager',
      completionBarClass = '.ecl-story-card__grid-loading-bar-completion',
      dotsClass = '.ecl-story-card__dots',
      dotClass = '.ecl-story-card__dot',
      activeDotClass = 'ecl-slider-pager__dot--active',
      currentSelector = '[data-ecl-story-card-current]',
      totalSelector = '[data-ecl-story-card-total]',
      gridItemSelector = '[data-ecl-story-card-grid-item]',
      gridButtonSelector = '[data-ecl-story-card-grid-button]',
      gridDetailsSelector = '[data-ecl-story-card-grid-details]',
      gridPrevSelector = '[data-ecl-story-card-grid-prev]',
      gridNextSelector = '[data-ecl-story-card-grid-next]',
      gridPlaySelector = '[data-ecl-story-card-grid-play]',
      gridPauseSelector = '[data-ecl-story-card-grid-pause]',
      gridAutoplayDelay = 10000,
      attachClickListener = true,
      attachResizeListener = true,
      desktopBreakpointCssVar = '--story-card-grid-breakpoint',
      minHeightDesktopCssVar = '--story-card-min-height',
    } = {},
  ) {
    // Check element
    if (!element || element.nodeType !== Node.ELEMENT_NODE) {
      throw new TypeError(
        'DOM element should be given to initialize this widget.',
      );
    }

    this.element = element;
    this.eventManager = new EventManager();

    // Options
    this.viewportSelector = viewportSelector;
    this.containerSelector = containerSelector;
    this.slideSelector = slideSelector;
    this.prevSelector = prevSelector;
    this.nextSelector = nextSelector;
    this.pagerClass = pagerClass;
    this.dotsClass = dotsClass;
    this.dotClass = dotClass;
    this.activeDotClass = activeDotClass;
    this.currentSelector = currentSelector;
    this.totalSelector = totalSelector;
    this.gridItemSelector = gridItemSelector;
    this.gridButtonSelector = gridButtonSelector;
    this.gridDetailsSelector = gridDetailsSelector;
    this.completionBarClass = completionBarClass;
    this.gridPrevSelector = gridPrevSelector;
    this.gridNextSelector = gridNextSelector;
    this.gridPlaySelector = gridPlaySelector;
    this.gridPauseSelector = gridPauseSelector;
    this.gridAutoplayDelay = gridAutoplayDelay;
    this.attachClickListener = attachClickListener;
    this.attachResizeListener = attachResizeListener;
    this.desktopBreakpointCssVar = desktopBreakpointCssVar;
    this.minHeightDesktopCssVar = minHeightDesktopCssVar;

    // Private variables - Carousel
    this.viewport = null;
    this.container = null;
    this.slides = null;
    this.slider = null;
    this.btnPrev = null;
    this.btnNext = null;
    this.currentElement = null;
    this.totalElement = null;
    this.index = 1;
    this.total = 0;
    this.toggleButtonsDisabled = null;
    this.accessibility = null;
    this.direction = getComputedStyle(this.element).direction;

    // Private variables - Grid (Desktop)
    this.gridItems = null;
    this.gridButtons = null;
    this.gridDetails = null;
    this.btnGridPrev = null;
    this.btnGridNext = null;
    this.btnGridPlay = null;
    this.btnGridPause = null;
    this.expandedItem = null;
    this.desktopBreakpoint = null;
    this.minHeightDesktop = null;
    this.gridDetailsHeightFrame = null;
    this.isGridAutoPlaying = false;

    this.onWindowResize = this.onWindowResize.bind(this);
    this.pauseGridAutoplay = this.pauseGridAutoplay.bind(this);
    this.toggleGridAutoplay = this.toggleGridAutoplay.bind(this);
  }

  /**
   * Initialise the component.
   */
  init() {
    this.viewport = queryOne(this.viewportSelector, this.element);
    this.container = queryOne(this.containerSelector, this.element);
    this.slides = queryAll(this.slideSelector, this.element);
    this.btnPrev = queryOne(this.prevSelector, this.element);
    this.btnNext = queryOne(this.nextSelector, this.element);
    this.pagerNode = queryOne(this.pagerClass, this.element);
    this.currentElement = queryOne(this.currentSelector, this.element);
    this.totalElement = queryOne(this.totalSelector, this.element);
    this.gridItems = queryAll(this.gridItemSelector, this.element);
    this.gridButtons = queryAll(this.gridButtonSelector, this.element);
    this.gridDetails = queryAll(this.gridDetailsSelector, this.element);
    this.btnGridPrev = queryOne(this.gridPrevSelector, this.element);
    this.btnGridNext = queryOne(this.gridNextSelector, this.element);
    this.btnGridPlay = queryOne(this.gridPlaySelector, this.element);
    this.btnGridPause = queryOne(this.gridPauseSelector, this.element);
    this.completionBars = queryAll(this.completionBarClass, this.element);
    this.element.style.setProperty(
      '--ecl-story-card-slide-duration',
      `${this.gridAutoplayDelay}ms`,
    );
    this.desktopBreakpoint =
      getComputedStyle(this.element)
        .getPropertyValue(this.desktopBreakpointCssVar)
        .trim() || '970px';
    this.minHeightDesktop =
      getComputedStyle(this.element)
        .getPropertyValue(this.minHeightDesktopCssVar)
        .trim() || '548px';

    this.total = this.slides ? this.slides.length : 0;

    // Initialize Embla carousel if slides exist
    if (this.viewport && this.container && this.total > 0) {
      this.initCarousel();
    }

    // Initialize desktop grid accordion
    if (this.gridButtons && this.gridButtons.length > 0) {
      this.initGridAccordion();
    }

    // Attach listeners
    if (this.attachClickListener) {
      this.attachListeners();
    }

    if (this.attachResizeListener) {
      window.addEventListener('resize', this.onWindowResize, false);
    }

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

  /**
   * Register a callback function for a specific event.
   *
   * @param {string} eventName - The name of the event to listen for.
   * @param {Function} callback - The callback function to be invoked when the event occurs.
   * @returns {void}
   * @memberof StoryCard
   * @instance
   *
   * @example
   * // Registering a callback for the 'onSelection' event
   * storyCard.on('onSelection', (event) => {
   *   console.log('Selection event occurred!', event);
   * });
   */
  on(eventName, callback) {
    this.eventManager.on(eventName, callback);
  }

  /**
   * Trigger a component event.
   *
   * @param {string} eventName - The name of the event to trigger.
   * @param {any} eventData - Data associated with the event.
   *
   * @memberof StoryCard
   */
  trigger(eventName, eventData) {
    this.eventManager.trigger(eventName, eventData);
  }

  /**
   * Initialize Embla carousel.
   */
  initCarousel() {
    this.slider = EmblaCarousel(
      this.viewport,
      {
        loop: true,
        skipSnaps: false,
        direction: this.direction,
      },
      [
        Accessibility({
          carouselAriaLabel: 'Story card slider',
          carouselAriaRoleDescription: '',
          slideAriaRoleDescription: '',
          slideRole: '',
          previousButtonAriaLabel: 'Show previous Slide',
          nextButtonAriaLabel: 'Show next Slide',
          dotButtonAriaLabel: (
            hasAnyGroupedSlides,
            firstSlideIndex,
            lastSlideIndex,
            totalSlides,
          ) => `Show slide ${firstSlideIndex + 1} of ${totalSlides}`,
          slideAriaLabel: () => '',
        }),
      ],
    );

    this.accessibility = this.slider.plugins().accessibility;

    this.pager = new SliderPager({
      slider: this.slider,
      pagerElement: this.pagerNode,
      accessibility: this.accessibility,
      prevSelector: this.prevSelector,
      nextSelector: this.nextSelector,
      dotsSelector: this.dotsClass,
      dotTemplateSelector: '[data-ecl-slider-dot-template]',
      dotSelector: this.dotClass,
      activeDotSelector: this.activeDotClass,
    });

    this.pager.init();
  }

  /**
   * Initialize desktop grid accordion.
   */
  initGridAccordion() {
    this.gridButtons.forEach((button, index) => {
      if (button.getAttribute('aria-selected') === 'true') {
        this.expandedItem = index;
        this.setGridItem(index);
      }
    });

    if (this.expandedItem === null) {
      this.setGridItem(0);
    }

    this.scheduleGridDetailsHeight();
    this.playGridAutoplay();
  }

  /**
   * Schedule grid details height calculation after layout settles.
   */
  scheduleGridDetailsHeight() {
    if (typeof window === 'undefined' || !window.requestAnimationFrame) {
      this.setGridDetailsHeight();
      return;
    }

    if (
      this.gridDetailsHeightFrame &&
      typeof window !== 'undefined' &&
      window.cancelAnimationFrame
    ) {
      window.cancelAnimationFrame(this.gridDetailsHeightFrame);
    }

    this.gridDetailsHeightFrame = window.requestAnimationFrame(() => {
      this.gridDetailsHeightFrame = null;
      this.setGridDetailsHeight();
    });
  }

  /**
   * Based on the checks decides whether to apply or reset the grid details height.
   */
  setGridDetailsHeight() {
    if (typeof window === 'undefined' || !this.gridDetails?.length) return;

    const containerWidth = this.element.getBoundingClientRect().width;

    if (containerWidth < this.desktopBreakpoint) {
      this.resetGridDetailsHeight();
      return;
    }

    this.applyGridDetailsHeight();
  }

  /**
   * Reset every grid details panel height and properties previously set.
   */
  resetGridDetailsHeight() {
    this.gridDetails.forEach((details) => {
      details.style.height = '';
      details.style.visibility = '';
      details.style.pointerEvents = '';
    });
  }

  /**
   * Set every grid details panel to the height of the tallest one.
   */
  applyGridDetailsHeight() {
    if (
      typeof window === 'undefined' ||
      !this.gridDetails ||
      this.gridDetails.length === 0
    ) {
      return;
    }

    const activeIndex = this.expandedItem === null ? 0 : this.expandedItem;
    let maxHeight = 0;

    this.gridDetails.forEach((details) => {
      details.style.height = '';
    });

    this.gridDetails.forEach((details) => {
      details.hidden = false;
      details.style.visibility = 'hidden';
      details.style.pointerEvents = 'none';

      maxHeight = Math.max(maxHeight, details.getBoundingClientRect().height);

      details.hidden = true;
      details.style.pointerEvents = '';
      details.style.visibility = '';
    });

    this.gridDetails[activeIndex].hidden = false;

    if (maxHeight > parseFloat(this.minHeightDesktop)) {
      this.gridDetails.forEach((details) => {
        details.style.height = `${Math.ceil(maxHeight)}px`;
      });
    }
  }

  /**
   * Set the expanded grid item.
   *
   * @param {Number} index Index of the item to toggle
   * @fires StoryCard#onSelection
   */
  setGridItem(index) {
    if (!this.gridButtons || this.gridButtons.length === 0) return;

    const nextIndex =
      (index + this.gridButtons.length) % this.gridButtons.length;
    const button = this.gridButtons[nextIndex];

    if (!button) return;

    this.gridButtons.forEach((gridButton, buttonIndex) => {
      const item = queryOne(
        `${this.gridItemSelector}[data-ecl-story-card-grid-item="${buttonIndex}"]`,
        this.element,
      );
      const details = this.gridDetails[buttonIndex];
      const isActive = buttonIndex === nextIndex;

      gridButton.setAttribute('aria-selected', isActive ? 'true' : 'false');

      if (item) {
        item.classList.toggle('ecl-story-card__grid-item--expanded', isActive);
      }

      if (details) {
        details.hidden = !isActive;
      }
    });

    this.expandedItem = nextIndex;

    const activeButton = this.gridButtons[nextIndex];

    const eventData = {
      index: nextIndex,
      button: activeButton,
      details: this.gridDetails?.[nextIndex] ?? null,
    };

    this.trigger('onSelection', eventData);
  }

  /**
   * Move the grid accordion to the previous item.
   */
  goToPreviousGridItem = () => {
    this.setGridItem((this.expandedItem || 0) - 1);
  };

  /**
   * Move the grid accordion to the next item.
   */
  goToNextGridItem = () => {
    this.setGridItem((this.expandedItem || 0) + 1);
  };

  /**
   * Move the grid accordion to the next item when the css animation ends.
   */
  handleProgressEnd = () => {
    this.goToNextGridItem();
  };

  /**
   * Start the grid auto-play.
   */
  playGridAutoplay() {
    if (!this.gridButtons || this.gridButtons.length < 2) {
      return;
    }

    this.isGridAutoPlaying = true;

    this.completionBars.forEach((bar) => {
      bar.classList.remove('is-paused');
    });

    if (this.btnGridPlay) this.btnGridPlay.style.display = 'none';
    if (this.btnGridPause) this.btnGridPause.style.display = 'flex';
  }

  /**
   * Pause the grid auto-play.
   */
  pauseGridAutoplay() {
    this.isGridAutoPlaying = false;

    this.completionBars.forEach((bar) => {
      bar.classList.add('is-paused');
    });

    if (this.btnGridPlay) this.btnGridPlay.style.display = 'flex';
    if (this.btnGridPause) this.btnGridPause.style.display = 'none';
  }

  /**
   * Toggle the grid auto-play state.
   *
   * @param {Boolean} shouldPlay Whether auto-play should start
   */
  toggleGridAutoplay(shouldPlay) {
    if (shouldPlay) {
      const isFocus = document.activeElement === this.btnGridPlay;
      this.playGridAutoplay();
      if (isFocus && this.btnGridPause) {
        this.btnGridPause.focus();
      }
    } else {
      const isFocus = document.activeElement === this.btnGridPause;
      this.pauseGridAutoplay();
      if (isFocus && this.btnGridPlay) {
        this.btnGridPlay.focus();
      }
    }
  }

  /**
   * Handle the click on the pause button.
   */
  handleGridPauseClick = () => {
    this.toggleGridAutoplay(false);
  };

  /**
   * Handle the click on the  pause button.
   */
  handleGridPlayClick = () => {
    this.toggleGridAutoplay(true);
  };

  handleClickOnGridButtons = (event) => {
    if (
      event.currentTarget
        .closest('.ecl-story-card__grid-item')
        .classList.contains('ecl-story-card__grid-item--expanded') &&
      !this.isGridAutoPlaying
    ) {
      this.playGridAutoplay();
    } else {
      this.pauseGridAutoplay();
    }
    const index = this.gridButtons.indexOf(event.currentTarget);
    this.setGridItem(index);
  };

  /**
   * Attach event listeners.
   */
  attachListeners() {
    this.btnGridPrev?.addEventListener('click', this.goToPreviousGridItem);
    this.btnGridNext?.addEventListener('click', this.goToNextGridItem);
    this.btnGridPlay?.addEventListener('click', this.handleGridPlayClick);
    this.btnGridPause?.addEventListener('click', this.handleGridPauseClick);
    this.gridButtons.forEach((button) => {
      button.addEventListener('click', this.handleClickOnGridButtons);
    });
    this.completionBars.forEach((bar) => {
      bar.addEventListener('animationend', this.handleProgressEnd);
    });
  }

  /**
   * Handle window resize.
   */
  onWindowResize() {
    this.scheduleGridDetailsHeight();
  }

  /**
   * Destroy the component.
   */
  destroy() {
    if (this.pager) {
      this.pager.destroy();
    }

    if (this.slider) {
      this.slider.destroy();
    }

    if (
      this.gridDetailsHeightFrame &&
      typeof window !== 'undefined' &&
      window.cancelAnimationFrame
    ) {
      window.cancelAnimationFrame(this.gridDetailsHeightFrame);
    }

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

    this.btnGridPrev?.removeEventListener('click', this.goToPreviousGridItem);
    this.btnGridNext?.removeEventListener('click', this.goToNextGridItem);
    this.btnGridPlay?.removeEventListener('click', this.handleGridPlayClick);
    this.btnGridPause?.removeEventListener('click', this.handleGridPauseClick);
    this.completionBars?.forEach((bar) => {
      bar.removeEventListener('animationend', this.handleProgressEnd);
    });
    this.gridButtons?.forEach((button) => {
      button.removeEventListener('click', this.handleClickOnGridButtons);
    });

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

export default StoryCard;