banner.js

import { queryOne } from '@ecl/dom-utils';
import EventManager from '@ecl/event-manager';

/**
 * @param {HTMLElement} element DOM element for component instantiation and scope
 * @param {Object} options
 * @param {String} options.bannerContainer Selector for the banner content
 * @param {String} options.bannerFooter Selector for the banner footer
 * @param {String} options.bannerVPadding Optional additional padding
 * @param {String} options.bannerPicture Selector for the banner picture
 * @param {String} options.bannerVideo Selector for the banner video
 * @param {String} options.bannerPlay Selector for the banner play button
 * @param {String} options.bannerPause Selector for the banner pause button
 * @param {String} options.maxIterations Used to limit the number of iterations when looking for css values
 * @param {String} options.breakpoint Breakpoint from which the script starts operating
 * @param {Boolean} options.attachResizeListener Whether to attach a listener on resize
 */
export class Banner {
  /**
   * @static
   * Shorthand for instance creation and initialisation.
   *
   * @param {HTMLElement} root DOM element for component instantiation and scope
   *
   * @return {Banner} An instance of Banner.
   */
  static autoInit(root, { BANNER: defaultOptions = {} } = {}) {
    const banner = new Banner(root, defaultOptions);
    banner.init();
    root.ECLBanner = banner;
    return banner;
  }

  /**
   * An array of supported events for this component.
   *
   * @type {Array<string>}
   * @event Banner#onCtaClick
   * @event Banner#onPlayClick
   * @event Banner#onPauseClick
   * @memberof Banner
   */
  supportedEvents = ['onCtaClick', 'onPlayClick', 'onPauseClick'];

  constructor(
    element,
    {
      bannerContainer = '[data-ecl-banner-container]',
      bannerFooter = '[data-ecl-banner-footer]',
      bannerVPadding = '8',
      bannerPicture = '[data-ecl-banner-image]',
      bannerVideo = '[data-ecl-banner-video]',
      bannerPlay = '[data-ecl-banner-play]',
      bannerPause = '[data-ecl-banner-pause]',
      breakpoint = '996',
      attachResizeListener = true,
      maxIterations = 10,
    } = {},
  ) {
    // 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();

    this.bannerVPadding = bannerVPadding;
    this.resizeTimer = null;
    this.bannerContainer = queryOne(bannerContainer, this.element);
    this.bannerFooter = queryOne(bannerFooter, this.element);
    this.bannerPicture = queryOne(bannerPicture, this.element);
    this.bannerVideo = queryOne(bannerVideo, this.element);
    this.bannerPlay = queryOne(bannerPlay, this.element);
    this.bannerPause = queryOne(bannerPause, this.element);
    this.bannerImage = this.bannerPicture
      ? queryOne('img', this.bannerPicture)
      : false;
    this.bannerCTA = this.bannerPicture
      ? queryOne('.ecl-banner__cta', this.element)
      : false;
    this.breakpoint = breakpoint;
    this.attachResizeListener = attachResizeListener;
    this.maxIterations = maxIterations;

    // Bind `this` for use in callbacks
    this.setBannerHeight = this.setBannerHeight.bind(this);
    this.checkViewport = this.checkViewport.bind(this);
    this.resetBannerHeight = this.resetBannerHeight.bind(this);
    this.handleResize = this.handleResize.bind(this);
    this.waitForAspectRatioToBeDefined =
      this.waitForAspectRatioToBeDefined.bind(this);
    this.setHeight = this.setHeight.bind(this);
  }

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

    this.defaultRatio = () => {
      if (this.element.classList.contains('ecl-banner--xs')) {
        return '6/1';
      }
      if (this.element.classList.contains('ecl-banner--s')) {
        return '5/1';
      }
      if (this.element.classList.contains('ecl-banner--l')) {
        return '3/1';
      }
      return '4/1';
    };

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

    if (this.bannerCTA) {
      this.bannerCTA.addEventListener('click', (e) => this.handleCtaClick(e));
    }
    if (this.bannerPlay) {
      this.bannerPlay.addEventListener('click', (e) => this.handlePlayClick(e));
      this.bannerPlay.style.display = 'none';
    }
    if (this.bannerPause) {
      this.bannerPause.addEventListener('click', (e) =>
        this.handlePauseClick(e),
      );
      this.bannerPause.style.display = 'flex';
    }

    this.checkViewport();
    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 Banner
   * @instance
   *
   * @example
   * // Registering a callback for the 'onCtaClick' event
   * banner.on('onCtaClick', (event) => {
   *   console.log('The cta was clicked', 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 Banner
   */
  trigger(eventName, eventData) {
    this.eventManager.trigger(eventName, eventData);
  }

  /**
   * Retrieve the value of the aspect ratio in the styles.
   */
  waitForAspectRatioToBeDefined() {
    this.attemptCounter = (this.attemptCounter || 0) + 1;
    let aspectRatio = '';
    if (this.bannerVideo) {
      // Ensure that the video is loaded (width > 0) before passing the ratio
      if (this.bannerVideo.videoWidth > 0) {
        aspectRatio = this.defaultRatio();
      }
    } else if (this.bannerImage) {
      aspectRatio = getComputedStyle(this.bannerImage).getPropertyValue(
        '--css-aspect-ratio',
      );
    }

    if (
      (typeof aspectRatio === 'undefined' || aspectRatio === '') &&
      this.maxIterations > this.attemptCounter
    ) {
      setTimeout(() => this.waitForAspectRatioToBeDefined(), 100);
    } else {
      this.setHeight(aspectRatio);
    }
  }

  /**
   * Sets or resets the banner height
   *
   * @param {string} aspect ratio
   */
  setHeight(ratio) {
    if (this.bannerContainer) {
      const bannerHeight =
        this.bannerContainer.offsetHeight +
        2 * parseInt(this.bannerVPadding, 10);
      const bannerWidth = parseInt(
        getComputedStyle(this.element).getPropertyValue('width'),
        10,
      );
      const [denominator, numerator] = ratio.split('/').map(Number);
      const currentHeight = (bannerWidth * numerator) / denominator;
      if (bannerHeight > currentHeight) {
        if (this.bannerImage) {
          this.bannerImage.style.aspectRatio = 'auto';
        }
        if (this.bannerVideo) {
          this.bannerVideo.style.aspectRatio = 'auto';
        }
        this.element.style.height = `${bannerHeight}px`;
      } else {
        this.resetBannerHeight();
      }
    }

    // Add margin to the banner container when there is a footer
    // This is needed to keep the vertical alignment
    if (this.bannerFooter) {
      this.element.style.setProperty(
        '--banner-footer-height',
        `${this.bannerFooter.offsetHeight}px`,
      );
    }
  }

  /**
   * Prepare to set the banner height
   */
  setBannerHeight() {
    if (this.bannerImage || this.bannerVideo) {
      this.waitForAspectRatioToBeDefined();
    } else {
      this.setHeight(this.defaultRatio());
    }
  }

  /**
   * Remove any override and get back the css
   */
  resetBannerHeight() {
    if (this.bannerImage) {
      const computedStyle = getComputedStyle(this.bannerImage);
      this.bannerImage.style.aspectRatio =
        computedStyle.getPropertyValue('--css-aspect-ratio');
    }
    if (this.bannerVideo) {
      this.bannerVideo.style.aspectRatio = this.defaultRatio();
    }

    this.element.style.height = 'auto';

    if (this.bannerFooter) {
      this.element.style.setProperty(
        '--banner-footer-height',
        `${this.bannerFooter.offsetHeight}px`,
      );
    }
  }

  /**
   * Check the current viewport width and act accordingly.
   */
  checkViewport() {
    if (window.innerWidth > this.breakpoint) {
      this.setBannerHeight();
    } else {
      this.resetBannerHeight();
    }
  }

  /**
   * Trigger events on resize
   * Uses a debounce, for performance
   */
  handleResize() {
    clearTimeout(this.resizeTimer);
    this.resizeTimer = setTimeout(() => {
      this.checkViewport();
    }, 200);
  }

  /**
   * Triggers a custom event when clicking on the cta.
   *
   * @param {e} Event
   * @fires Banner#onCtaClick
   */
  handleCtaClick(e) {
    let href = null;
    const anchor = e.target.closest('a');
    if (anchor) {
      href = anchor.getAttribute('href');
    }

    const eventData = { item: this.bannerCTA, target: href || e.target };
    this.trigger('onCtaClick', eventData);
  }

  /**
   * Triggers a custom event when clicking on the play button.
   *
   * @param {e} Event
   * @fires Banner#onPlayClick
   */
  handlePlayClick() {
    if (this.bannerVideo) {
      this.bannerVideo.play();
    }

    this.bannerPlay.style.display = 'none';
    if (this.bannerPause) {
      this.bannerPause.style.display = 'flex';
      this.bannerPause.focus();
    }

    const eventData = { item: this.bannerPlay };
    this.trigger('onPlayClick', eventData);
  }

  /**
   * Triggers a custom event when clicking on the pause button.
   *
   * @param {e} Event
   * @fires Banner#onPauseClick
   */
  handlePauseClick() {
    if (this.bannerVideo) {
      this.bannerVideo.pause();
    }

    this.bannerPause.style.display = 'none';
    if (this.bannerPlay) {
      this.bannerPlay.style.display = 'flex';
      this.bannerPlay.focus();
    }

    const eventData = { item: this.bannerPause };
    this.trigger('onPauseClick', eventData);
  }

  /**
   * Destroy component.
   */
  destroy() {
    this.resetBannerHeight();
    this.element.removeAttribute('data-ecl-auto-initialized');
    ECL.components.delete(this.element);
    if (this.attachResizeListener) {
      window.removeEventListener('resize', this.handleResize);
    }
    if (this.bannerCTA) {
      this.bannerCTA.removeEventListener('click', this.handleCtaClick);
    }
    if (this.bannerPlay) {
      this.bannerPlay.removeEventListener('click', this.handlePlayClick);
    }
    if (this.bannerPause) {
      this.bannerPause.removeEventListener('click', this.handlePauseClick);
    }
  }
}

export default Banner;